move to rational number calculation

This commit is contained in:
Emil Lerch 2026-07-28 06:45:23 -07:00
parent 83857cebbc
commit 6794f34b12
Signed by: lobo
GPG key ID: A7B62D657EF764F8
7 changed files with 786 additions and 82 deletions

View file

@ -509,11 +509,21 @@ Bug-by-bug, where the fix lands:
| `0.1 + 0.2` -> `0.3` | yes | - |
| `1.1 + 2.2` -> `3.3` | yes | - |
| `0.1 * 3` -> `0.3` | yes | - |
| `12 in in ft` -> `1` | yes | - |
| `1e20 + 1 - 1e20` -> `1` | yes | - |
| `factorial(171)` | partly (becomes `inf`, not an error) | yes, for the exact value |
| `9007199254740993` | **no** (f64 cannot hold it) | yes |
| `2^53 + 1` | **no** | yes |
| `1/3` retained exactly | no (only observable as exact) | yes |
| `12 in in ft` -> `1` | **no** - see below | needs exact unit factors |
**Unit conversion is a separate problem from the evaluator.** An earlier draft of
this table claimed the exact evaluator would fix `12 in in ft`. It does not, and
the reason is worth recording: `UnitDef.to_base_factor` is an `f64`, so `0.0254`
is *already* the binary approximation before any arithmetic happens, and
`convertUnits` does its own f64 multiply and divide without ever touching the
evaluator. Making conversions exact requires the factors themselves to be exact
(declared as decimal text and parsed into rationals), which is a mechanical change
across ~100 table entries and belongs in its own commit. Tracked as Task 2.0e.
So step 2 is independently verifiable through the current API, and the
large-integer cases become the motivating tests for step 3 rather than

View file

@ -126,16 +126,47 @@ behavioral change (rationale in design.md 2.7.9):
- Verify: 487 tests pass (was 402), zig fmt and zlint clean, existing tests
untouched.
#### 2.0b: Evaluator internals become exact, `evalString` still returns f64
- Exact/inexact boundary per design.md 2.7.4. Per-evaluation, not per-function:
`sqrt(4)` exact, `sqrt(2)` inexact. Deliberately not a CAS.
- `NumberValue` and the AST number node GAIN an exact field rather than replacing
`float`/`int_value`, so tokenizer and parser tests keep compiling.
- ALL existing tests must still pass unchanged.
- Payoff is visible through the f64 boundary because a single final rounding
avoids today's accumulated error: `0.1 + 0.2` -> `0.3`, `1.1 + 2.2` -> `3.3`,
`0.1 * 3` -> `0.3`, `12 in in ft` -> `1`.
- Verify: existing suite green, plus new tests for the four fixes above.
#### 2.0b: Evaluator internals become exact, `evalString` still returns f64 [DONE]
- `ast.Expr.Number` gains `text: []const u8` (the literal's source text). Additive,
so the 10 parser assertions on `float_value`/`int_value` keep compiling. The
text is needed because `float_value` has ALREADY rounded by the time it exists:
`0.1` cannot be recovered from its binary approximation, so exact evaluation has
to re-parse the literal.
- `evaluator.evaluate` keeps its `CalcError!f64` signature but now runs on
`Number` internally via `evalExact`, collapsing to f64 once at the boundary.
A scratch arena per evaluation keeps `Number` lifetimes trivial and means the
caller's allocator (an arena in the CLI, a GPA in the TUI) never holds
intermediates.
- Exact: `+ - * / %`, integer powers, `sqrt` of perfect squares, `abs`, `floor`,
`ceil`, `round`, `factorial`, `max`, `min`, and all literals.
- Inexact: transcendentals, fractional powers, `sqrt` of non-squares, `cbrt`,
`atan2`, two-arg `log`, `rand`, and the constants `pi`/`e`/`tau`.
- Fixed-width integer operators (`& | xor << >> >>> rol ror`, `~`) stay on the
float/integer projection: they are not rational arithmetic.
- Added to `rational.zig`: `floor`, `ceil`, `round`, `mod` (the
`a - b*floor(a/b)` definition), and unbounded exact `factorial`.
- Added to `number.zig`: the matching wrappers plus `max`/`min`.
- `rational.Error.ExponentTooLarge` maps to `CalcError.Overflow`.
- ALL 489 pre-existing tests pass unchanged. 522 total now (+33).
- Verified through the unchanged f64 API: `0.1 + 0.2` = `0.3`, `1.1 + 2.2` = `3.3`,
`0.1 * 3` = `0.3`, `0.1+0.2+0.3` = `0.6`, `(0.1+0.2)*10-3` = `0`,
`1e20 + 1 - 1e20` = `1` (f64 gives 0), `1/3*3` = `1`, `factorial(171)` no longer
errors. Unchanged: `2 + 3 * 4`, `sqrt(144)`, `sqrt(2)`, `sin(0)`, `e`,
`0o777 - 0x0f`, `10 % 3`, `0xFF & 0x0F`.
- Known limitations deferred to 2.0c/2.0e:
- Variables and `Ans` are still stored as f64 in `Environment`, so
`X = 0.1` round-trips through a float. Correct for `pi`/`e`/`tau` (irrational),
a real limitation for user variables.
- `9007199254740993` and `2^53 + 1` are computed exactly but cannot survive the
f64 return. That is precisely what 2.0c fixes.
- `12 in in ft` is NOT fixed: unit conversion never touches the evaluator and
its factors are already-rounded f64 values. See Task 2.0e.
- Test-oracle bug found by the instrumented coverage build: Zig's float `@mod`
with a NEGATIVE divisor returned `-2` in the normal build and `1` under the
coverage build (comptime folding and the runtime path disagree). A test whose
expected value shifts with optimize mode verifies nothing, so the `mod` test now
asserts the values the definition requires instead of deriving them from `@mod`.
Worth reporting upstream.
#### 2.0c: `evalString` and the display path move to `Number`
- Mechanical signature churn through evaluator/formatter/CLI/TUI tests. This is
@ -158,6 +189,24 @@ because that is the precision we can justify; see design.md 2.7.8 for why f128 i
NOT the upgrade path), and any change to programmer mode's `u128` semantics or the
IEEE 754 float view.
### Task 2.0e: Exact unit conversion factors [NOT STARTED]
Discovered while implementing 2.0b: making the evaluator exact does NOT fix
`12 in in ft` = `0.9999999999999998`, because unit conversion never goes through
the evaluator. `UnitDef.to_base_factor` is an `f64`, so `0.0254` is already the
binary approximation before `convertUnits` does its own f64 multiply and divide.
- Declare factors as exact decimal text (e.g. `"0.0254"`) so they can be parsed
into rationals; an inch is exactly `127/5000` m and a foot exactly `381/1250` m,
which makes `12 in to ft` exactly `1`.
- Provide an exact conversion path returning `Number`, keeping the f64 path for
callers that want it.
- Mechanical across ~100 table entries, hence its own commit: the existing
invariant tests (every unit and unit pair round-trips) become far stronger when
the round trip is exact rather than within a tolerance.
- Non-terminating conversions (`100 km to mi` = `781250/12573`) still render as
rounded decimals, but from an exact value, and the exact fraction becomes
available for display.
### Task 2.1: Implement struct DSL tokenizer and parser
- Create `engine/src/struct_layout.zig`
- Tokenize: `struct`, `{`, `}`, `;`, type names, identifiers, `[`, `]`, numbers (for arrays)

View file

@ -21,6 +21,14 @@ pub const Expr = union(enum) {
/// If the number is a pure integer, stores the exact value.
int_value: ?u64,
base: Base,
/// The literal's source text, with separators intact. Points into the
/// expression source, which outlives the AST.
///
/// Kept so the evaluator can reconstruct the literal *exactly* as a
/// rational: `float_value` has already lost information by the time it
/// exists (0.1 is not representable in binary), so it cannot be the
/// basis for exact arithmetic. See design.md 2.7.
text: []const u8 = "",
};
pub const Unary = struct {

View file

@ -17,6 +17,8 @@ const ProgrammerConfig = types.ProgrammerConfig;
const CalcError = types.CalcError;
const parser_mod = @import("parser.zig");
const Parser = parser_mod.Parser;
const number_mod = @import("number.zig");
const Number = number_mod.Number;
/// Evaluation environment holding variables, history, and config.
pub const Environment = struct {
@ -61,75 +63,138 @@ pub const Environment = struct {
/// Evaluate a parsed expression in the given environment.
/// Returns the computed value as f64 for standard mode.
///
/// Internally the computation runs on `Number`, so exact arithmetic is used
/// wherever possible and only collapses to f64 here, at the boundary. That
/// single final rounding is what fixes the accumulated-error class of bug:
/// `0.1 + 0.2` is computed as exactly `3/10` and rounds to the f64 nearest
/// `0.3`, rather than adding two separately-rounded operands.
///
/// Task 2.0c replaces this boundary with a `Number`-returning API, which is what
/// the remaining integer-precision cases need.
pub fn evaluate(env: *Environment, expr: *const Expr) CalcError!f64 {
// A scratch arena keeps Number lifetimes trivial: nothing in the recursive
// evaluator has to free intermediates, and the caller's allocator is never
// left holding them regardless of whether it is an arena itself.
var arena = std.heap.ArenaAllocator.init(env.allocator);
defer arena.deinit();
const scratch = arena.allocator();
const result = try evalExact(env, scratch, expr);
return result.toFloat(scratch);
}
/// The exact evaluation core. Produces a `Number`, staying exact until an
/// operation forces the float fallback (see design.md 2.7.4).
fn evalExact(env: *Environment, scratch: Allocator, expr: *const Expr) CalcError!Number {
switch (expr.*) {
.number => |n| return n.float_value,
.number => |n| return literalToNumber(scratch, n),
.string_literal => |text| {
// Pack ASCII bytes into integer (same as programmer mode, BE packing)
var result: u128 = 0;
// Pack ASCII bytes into an integer (BE packing, as programmer mode).
var packed_value: u128 = 0;
for (text) |byte| {
if (byte > 0x7F) return CalcError.InvalidNumber;
result = (result << 8) | byte;
packed_value = (packed_value << 8) | byte;
}
return @floatFromInt(result);
return Number.fromInt(scratch, packed_value) catch |err| return mapError(err);
},
.variable => |name| {
return env.getVar(name) orelse return CalcError.UnknownVariable;
// Variables and the built-in constants are stored as f64 today, so
// reading one yields an inexact value. For pi/e/tau that is correct
// (they are irrational); for user variables it is a temporary
// limitation that Task 2.0c removes by storing Number in the
// environment.
const value = env.getVar(name) orelse return CalcError.UnknownVariable;
return Number.fromFloat(value);
},
.assignment => |a| {
const val = try evaluate(env, a.value);
env.setVar(a.name, val) catch return CalcError.OutOfMemory;
const val = try evalExact(env, scratch, a.value);
env.setVar(a.name, val.toFloat(scratch)) catch return CalcError.OutOfMemory;
return val;
},
.unary => |u| {
const operand = try evaluate(env, u.operand);
const operand = try evalExact(env, scratch, u.operand);
return switch (u.op) {
.negate => -operand,
.bitwise_not => {
// In standard mode, bitwise not doesn't really make sense,
// but we'll compute it on the integer representation
const int_val: u64 = @bitCast(@as(i64, @intFromFloat(operand)));
.negate => Number.negate(scratch, operand) catch |err| mapError(err),
// Bitwise NOT is a fixed-width integer operation, not rational
// arithmetic, so it drops to the float/integer path.
.bitwise_not => blk: {
const f = operand.toFloat(scratch);
const int_val: u64 = @bitCast(@as(i64, @intFromFloat(f)));
const mask_val: u64 = @truncate(env.programmer_config.bit_width.mask());
const result = ~int_val & mask_val;
return @floatFromInt(@as(i64, @bitCast(result)));
break :blk Number.fromFloat(@floatFromInt(@as(i64, @bitCast(result))));
},
};
},
.binary => |b| {
const left = try evaluate(env, b.left);
const right = try evaluate(env, b.right);
return evalBinaryOp(b.op, left, right);
const left = try evalExact(env, scratch, b.left);
const right = try evalExact(env, scratch, b.right);
return evalBinaryOp(scratch, b.op, left, right);
},
.call => |c| {
return evalFunction(env, c.name, c.args);
return evalFunction(env, scratch, c.name, c.args);
},
}
}
/// Evaluate a binary operation on two f64 values.
fn evalBinaryOp(op: BinaryOp, left: f64, right: f64) CalcError!f64 {
/// Turn a literal into a Number, exactly where possible.
///
/// Decimal literals are re-parsed from their source text rather than taken from
/// `float_value`, because `float_value` has already rounded: `0.1` cannot be
/// recovered from its binary approximation.
fn literalToNumber(scratch: Allocator, n: ast.Expr.Number) CalcError!Number {
if (n.base == .decimal and n.text.len > 0) {
if (Number.parse(scratch, n.text)) |value| return value else |_| {
// Fall through to the approximations below rather than failing: the
// tokenizer already accepted this text, so a parse mismatch here
// should degrade, not error.
}
}
// Non-decimal literals are integers; use the exact integer the tokenizer
// recovered when it fits, otherwise accept the float approximation.
if (n.int_value) |int_val| {
return Number.fromInt(scratch, int_val) catch |err| return mapError(err);
}
return Number.fromFloat(n.float_value);
}
/// Map the numeric model's errors onto the engine's error set.
fn mapError(err: number_mod.Error) CalcError {
return switch (err) {
error.OutOfMemory => CalcError.OutOfMemory,
error.DivisionByZero => CalcError.DivisionByZero,
error.InvalidNumber => CalcError.InvalidNumber,
// An exponent too large to compute is an overflow from the caller's view.
error.ExponentTooLarge => CalcError.Overflow,
};
}
/// Evaluate a binary operation.
fn evalBinaryOp(scratch: Allocator, op: BinaryOp, left: Number, right: Number) CalcError!Number {
return switch (op) {
.add => left + right,
.sub => left - right,
.mul => left * right,
.div => if (right == 0) CalcError.DivisionByZero else left / right,
.mod => if (right == 0) CalcError.DivisionByZero else @mod(left, right),
.pow => math.pow(f64, left, right),
// Bitwise ops in standard mode operate on integer truncations
.bit_and => floatBitwise(left, right, bitwiseAnd),
.bit_or => floatBitwise(left, right, bitwiseOr),
.bit_xor => floatBitwise(left, right, bitwiseXor),
.shift_left => floatShift(left, right, true),
.shift_right, .shift_right_logical => floatShift(left, right, false),
.rotate_left, .rotate_right => {
.add => Number.add(scratch, left, right) catch |err| mapError(err),
.sub => Number.sub(scratch, left, right) catch |err| mapError(err),
.mul => Number.mul(scratch, left, right) catch |err| mapError(err),
.div => Number.div(scratch, left, right) catch |err| mapError(err),
.mod => Number.mod(scratch, left, right) catch |err| mapError(err),
.pow => Number.pow(scratch, left, right) catch |err| mapError(err),
// The remaining operators are fixed-width integer operations rather than
// rational arithmetic, so they work on the float/integer projection.
.bit_and => Number.fromFloat(floatBitwise(left.toFloat(scratch), right.toFloat(scratch), bitwiseAnd)),
.bit_or => Number.fromFloat(floatBitwise(left.toFloat(scratch), right.toFloat(scratch), bitwiseOr)),
.bit_xor => Number.fromFloat(floatBitwise(left.toFloat(scratch), right.toFloat(scratch), bitwiseXor)),
.shift_left => Number.fromFloat(floatShift(left.toFloat(scratch), right.toFloat(scratch), true)),
.shift_right, .shift_right_logical => Number.fromFloat(floatShift(left.toFloat(scratch), right.toFloat(scratch), false)),
.rotate_left, .rotate_right => blk: {
// Rotations need bit width context; in standard mode, use 64-bit
const l: u64 = @bitCast(@as(i64, @intFromFloat(left)));
const r: u6 = @intFromFloat(@mod(right, 64.0));
const l: u64 = @bitCast(@as(i64, @intFromFloat(left.toFloat(scratch))));
const r: u6 = @intFromFloat(@mod(right.toFloat(scratch), 64.0));
const result = if (op == .rotate_left)
math.rotl(u64, l, r)
else
math.rotr(u64, l, r);
return @floatFromInt(@as(i64, @bitCast(result)));
break :blk Number.fromFloat(@floatFromInt(@as(i64, @bitCast(result))));
},
};
}
@ -159,25 +224,59 @@ fn floatShift(left: f64, right: f64, is_left: bool) f64 {
}
/// Evaluate a built-in function call.
fn evalFunction(env: *Environment, name: []const u8, args: []const *Expr) CalcError!f64 {
fn evalFunction(env: *Environment, scratch: Allocator, name: []const u8, args: []const *Expr) CalcError!Number {
// Single-argument functions
if (args.len == 1) {
const x = try evaluate(env, args[0]);
return evalSingleArgFn(name, x) orelse CalcError.UnknownFunction;
const x = try evalExact(env, scratch, args[0]);
// Functions with an exact implementation.
if (std.mem.eql(u8, name, "abs")) {
return Number.abs(scratch, x) catch |err| mapError(err);
}
if (std.mem.eql(u8, name, "floor")) {
return Number.floor(scratch, x) catch |err| mapError(err);
}
if (std.mem.eql(u8, name, "ceil")) {
return Number.ceil(scratch, x) catch |err| mapError(err);
}
if (std.mem.eql(u8, name, "round")) {
return Number.round(scratch, x) catch |err| mapError(err);
}
if (std.mem.eql(u8, name, "sqrt")) {
// Negative inputs are a domain error rather than a NaN.
if (x.isNegative()) return CalcError.UnknownFunction;
return Number.sqrt(scratch, x) catch |err| mapError(err);
}
if (std.mem.eql(u8, name, "factorial")) {
const result = Number.factorial(scratch, x) catch |err| return mapError(err);
return result orelse CalcError.UnknownFunction;
}
// Everything else escapes the rationals, so it falls back to f64.
const f = evalSingleArgFn(name, x.toFloat(scratch)) orelse
return CalcError.UnknownFunction;
return Number.fromFloat(f);
}
// Multi-argument functions
if (args.len == 2) {
const a = try evaluate(env, args[0]);
const b = try evaluate(env, args[1]);
const a = try evalExact(env, scratch, args[0]);
const b = try evalExact(env, scratch, args[1]);
if (std.mem.eql(u8, name, "max")) return @max(a, b);
if (std.mem.eql(u8, name, "min")) return @min(a, b);
if (std.mem.eql(u8, name, "atan2")) return math.atan2(a, b);
if (std.mem.eql(u8, name, "max")) {
return Number.max(scratch, a, b) catch |err| mapError(err);
}
if (std.mem.eql(u8, name, "min")) {
return Number.min(scratch, a, b) catch |err| mapError(err);
}
const x = a.toFloat(scratch);
const y = b.toFloat(scratch);
if (std.mem.eql(u8, name, "atan2")) return Number.fromFloat(math.atan2(x, y));
if (std.mem.eql(u8, name, "log")) {
// log(value, base)
if (b <= 0 or b == 1 or a <= 0) return CalcError.DomainError;
return @log(a) / @log(b);
if (y <= 0 or y == 1 or x <= 0) return CalcError.DomainError;
return Number.fromFloat(@log(x) / @log(y));
}
}
@ -185,14 +284,14 @@ fn evalFunction(env: *Environment, name: []const u8, args: []const *Expr) CalcEr
if (args.len == 0) {
if (std.mem.eql(u8, name, "rand")) {
// Not truly random in a pure engine, but useful as placeholder
return 0.0;
return Number.fromFloat(0.0);
}
}
return CalcError.UnknownFunction;
}
/// Evaluate a single-argument built-in function.
/// Evaluate a single-argument built-in function that has no exact form.
fn evalSingleArgFn(name: []const u8, x: f64) ?f64 {
if (std.mem.eql(u8, name, "sin")) return @sin(x);
if (std.mem.eql(u8, name, "cos")) return @cos(x);
@ -210,33 +309,11 @@ fn evalSingleArgFn(name: []const u8, x: f64) ?f64 {
if (std.mem.eql(u8, name, "log10")) return @log10(x);
if (std.mem.eql(u8, name, "ln")) return @log(x);
if (std.mem.eql(u8, name, "log2")) return @log2(x);
if (std.mem.eql(u8, name, "sqrt")) {
if (x < 0) return null;
return @sqrt(x);
}
if (std.mem.eql(u8, name, "cbrt")) return math.cbrt(x);
if (std.mem.eql(u8, name, "abs")) return @abs(x);
if (std.mem.eql(u8, name, "ceil")) return @ceil(x);
if (std.mem.eql(u8, name, "floor")) return @floor(x);
if (std.mem.eql(u8, name, "round")) return @round(x);
if (std.mem.eql(u8, name, "exp")) return @exp(x);
if (std.mem.eql(u8, name, "factorial")) {
if (x < 0 or x != @round(x) or x > 170) return null;
return factorial(@intFromFloat(x));
}
return null;
}
fn factorial(n: u64) f64 {
if (n <= 1) return 1.0;
var result: f64 = 1.0;
var i: u64 = 2;
while (i <= n) : (i += 1) {
result *= @floatFromInt(i);
}
return result;
}
/// Result of evaluation with metadata for display decisions.
pub const EvalInfo = struct {
value: f64,
@ -653,3 +730,124 @@ test "eval unknown two-arg function" {
const result = testEval("bogus(1, 2)");
try testing.expectError(CalcError.UnknownFunction, result);
}
// -- Exact arithmetic (Task 2.0b) --
//
// These verify the exact evaluation core through the unchanged f64 API. The
// payoff is visible here because today's errors are ACCUMULATED: f64 rounds
// each decimal literal before operating on it, whereas the exact core computes
// the true value and rounds once, at the boundary.
//
// Note the runtime-`var` dance in the comparisons against plain f64: Zig folds
// float literals at comptime as `comptime_float`, so `0.1 + 0.2 != 0.3` is
// false at comptime and would not exercise f64 at all.
test "exact: 0.1 + 0.2 is 0.3" {
try testing.expectEqual(@as(f64, 0.3), try testEval("0.1 + 0.2"));
var x: f64 = 0.1;
var y: f64 = 0.2;
_ = &x;
_ = &y;
try testing.expect(x + y != @as(f64, 0.3));
}
test "exact: 1.1 + 2.2 is 3.3" {
try testing.expectEqual(@as(f64, 3.3), try testEval("1.1 + 2.2"));
}
test "exact: 0.1 * 3 is 0.3" {
try testing.expectEqual(@as(f64, 0.3), try testEval("0.1 * 3"));
}
test "exact: chained decimal addition" {
try testing.expectEqual(@as(f64, 0.6), try testEval("0.1 + 0.2 + 0.3"));
try testing.expectEqual(@as(f64, 0.8), try testEval("0.7 + 0.1"));
try testing.expectEqual(@as(f64, 0.2), try testEval("0.3 - 0.1"));
try testing.expectEqual(@as(f64, 0.01), try testEval("0.1 * 0.1"));
}
test "exact: an expression that cancels reaches exactly zero" {
try testing.expectEqual(@as(f64, 0.0), try testEval("(0.1 + 0.2) * 10 - 3"));
var x: f64 = 0.1;
var y: f64 = 0.2;
_ = &x;
_ = &y;
try testing.expect((x + y) * 10.0 - 3.0 != 0.0);
}
test "exact: intermediates beyond f64 precision survive" {
// 1e20 + 1 is not representable in f64, so the f64 route loses the 1 and
// yields 0. Exact arithmetic keeps it and the final result fits.
try testing.expectEqual(@as(f64, 1.0), try testEval("1e20 + 1 - 1e20"));
var big: f64 = 1e20;
_ = &big;
try testing.expectEqual(@as(f64, 0.0), big + 1.0 - big);
}
test "exact: division round trip" {
try testing.expectEqual(@as(f64, 1.0), try testEval("1 / 3 * 3"));
try testing.expectEqual(@as(f64, 1.0), try testEval("1 / 7 * 7"));
try testing.expectEqual(@as(f64, 100.5), try testEval("1.005 * 100"));
}
test "exact: factorial is no longer capped at 170" {
// Previously `factorial(171)` reported "unknown function" because the f64
// implementation overflowed. It now computes exactly and only loses
// magnitude at the f64 boundary.
const result = try testEval("factorial(171)");
try testing.expect(math.isPositiveInf(result));
// And a value f64 can still hold comes back exact.
try testing.expectEqual(@as(f64, 120.0), try testEval("factorial(5)"));
}
test "exact: perfect square roots stay exact, irrational ones fall back" {
try testing.expectEqual(@as(f64, 12.0), try testEval("sqrt(144)"));
try testing.expectEqual(@as(f64, 0.5), try testEval("sqrt(0.25)"));
try testing.expectApproxEqAbs(math.sqrt2, try testEval("sqrt(2)"), 1e-15);
}
test "exact: floor, ceil and round match the float builtins" {
try testing.expectEqual(@as(f64, -4.0), try testEval("floor(-3.2)"));
try testing.expectEqual(@as(f64, -3.0), try testEval("ceil(-3.2)"));
try testing.expectEqual(@as(f64, 3.0), try testEval("round(2.5)"));
try testing.expectEqual(@as(f64, -3.0), try testEval("round(-2.5)"));
try testing.expectEqual(@as(f64, 0.1), try testEval("abs(-0.1)"));
}
test "exact: mod keeps the sign of the divisor" {
try testing.expectEqual(@as(f64, 1.0), try testEval("10 % 3"));
try testing.expectEqual(@as(f64, 2.0), try testEval("-10 % 3"));
try testing.expectEqual(@as(f64, 0.5), try testEval("7.5 % 1"));
}
test "exact: non-decimal literals are exact integers" {
try testing.expectEqual(@as(f64, 255.0), try testEval("0xFF"));
try testing.expectEqual(@as(f64, 496.0), try testEval("0o777 - 0x0f"));
try testing.expectEqual(@as(f64, 10.0), try testEval("0b1010"));
}
test "exact: transcendentals still fall back to floats" {
// These have no exact rational form, so they must go through f64 and are
// only expected to be approximately right.
try testing.expectApproxEqAbs(@as(f64, 0.0), try testEval("sin(0)"), 1e-15);
try testing.expectApproxEqAbs(math.pi, try testEval("pi"), 1e-15);
try testing.expectApproxEqAbs(@as(f64, 1.0), try testEval("ln(e)"), 1e-15);
try testing.expectApproxEqAbs(@as(f64, 2.0), try testEval("log10(100)"), 1e-15);
}
test "exact: a transcendental contaminates the rest of the expression" {
// Once sin() enters, the result is inexact; it must still be numerically
// right, just not exact.
const result = try testEval("sin(0) + 0.1 + 0.2");
try testing.expectApproxEqAbs(@as(f64, 0.3), result, 1e-15);
}
test "exact: overflow from an absurd exponent is reported as overflow" {
// The exponent guard in the rational layer surfaces as Overflow rather than
// silently producing infinity or exhausting memory.
try testing.expectError(CalcError.Overflow, testEval("2 ^ 3000000"));
}

View file

@ -228,6 +228,71 @@ pub const Number = union(enum) {
return .{ .inexact = f(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| capped(allocator, try exactOp(allocator, r)),
.inexact => |f| .{ .inexact = floatOp(f) },
};
}
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 capped(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 {
@ -654,3 +719,142 @@ test "clone preserves the tier" {
defer d.deinit();
try testing.expect(!d.isExact());
}
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.isExact());
try expectDecimal("3", true, f, 20);
var c = try Number.ceil(alloc, a);
defer c.deinit();
try testing.expect(c.isExact());
try expectDecimal("4", true, c, 20);
var r = try Number.round(alloc, a);
defer r.deinit();
try testing.expect(r.isExact());
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.isExact());
try testing.expectEqual(@as(f64, 3.0), f.toFloat(alloc));
var c = try Number.ceil(alloc, a);
defer c.deinit();
try testing.expect(!c.isExact());
try testing.expectEqual(@as(f64, 4.0), c.toFloat(alloc));
var r = try Number.round(alloc, a);
defer r.deinit();
try testing.expect(!r.isExact());
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.isExact());
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.isExact());
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.isExact());
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.isExact());
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.isExact());
}
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.isExact());
try expectDecimal("0.2", true, hi, 20);
var lo = try Number.min(alloc, a, b);
defer lo.deinit();
try testing.expect(lo.isExact());
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.isExact());
try testing.expectEqual(@as(f64, 2.0), hi.toFloat(alloc));
}

View file

@ -101,6 +101,7 @@ pub const Parser = struct {
.float_value = num.float,
.int_value = num.int_value,
.base = num.base,
.text = text,
} });
},
.string_literal => {

View file

@ -419,6 +419,82 @@ pub const Rational = struct {
return try initOwned(allocator, num_root, den_root);
}
/// Largest integer not greater than the value.
pub fn floor(allocator: Allocator, a: Rational) Error!Rational {
if (a.isInteger()) return a.clone();
var q = try Managed.init(allocator);
errdefer q.deinit();
var r = try Managed.init(allocator);
defer r.deinit();
// divFloor rounds the quotient toward negative infinity, which is
// exactly floor for a positive denominator (an invariant here).
try q.divFloor(&r, &a.num, &a.den);
const den = try Managed.initSet(allocator, 1);
return initOwned(allocator, q, den);
}
/// Smallest integer not less than the value.
pub fn ceil(allocator: Allocator, a: Rational) Error!Rational {
if (a.isInteger()) return a.clone();
var f = try floor(allocator, a);
errdefer f.deinit();
// Not an integer, so ceil is always floor + 1.
try f.num.addScalar(&f.num, 1);
return f;
}
/// Round to the nearest integer, halves away from zero (matching `@round`).
pub fn round(allocator: Allocator, a: Rational) Error!Rational {
if (a.isInteger()) return a.clone();
var half = try initRatio(allocator, 1, 2);
defer half.deinit();
if (a.isNegative()) {
var shifted = try sub(allocator, a, half);
defer shifted.deinit();
return ceil(allocator, shifted);
}
var shifted = try add(allocator, a, half);
defer shifted.deinit();
return floor(allocator, shifted);
}
/// Exact integer remainder matching `@mod`: the result takes the sign of
/// the divisor, and equals `a - b * floor(a / b)`.
pub fn mod(allocator: Allocator, a: Rational, b: Rational) Error!Rational {
if (b.isZero()) return Error.DivisionByZero;
var quotient = try div(allocator, a, b);
defer quotient.deinit();
var floored = try floor(allocator, quotient);
defer floored.deinit();
var scaled = try mul(allocator, floored, b);
defer scaled.deinit();
return sub(allocator, a, scaled);
}
/// Exact factorial. Unbounded, unlike the f64 version which overflows past
/// 170.
pub fn factorial(allocator: Allocator, n: u64) Error!Rational {
// A guard against absurd inputs that would take effectively forever;
// 20000! is already a ~78000-digit number.
if (n > 20_000) return Error.ExponentTooLarge;
var acc = try Managed.initSet(allocator, 1);
errdefer acc.deinit();
var i: u64 = 2;
while (i <= n) : (i += 1) {
var factor = try Managed.initSet(allocator, i);
defer factor.deinit();
try acc.mul(&acc, &factor);
}
const den = try Managed.initSet(allocator, 1);
return initOwned(allocator, acc, den);
}
// -- Conversion --
/// Convert to the nearest f64.
@ -1196,3 +1272,161 @@ test "chained arithmetic stays exact where f64 would drift" {
_ = &y;
try testing.expect((x + y) * 10.0 - 3.0 != 0.0);
}
test "floor" {
const cases = [_]struct { in: []const u8, out: []const u8 }{
.{ .in = "3.7", .out = "3" },
.{ .in = "3.2", .out = "3" },
.{ .in = "3", .out = "3" },
.{ .in = "-3.2", .out = "-4" },
.{ .in = "-3.7", .out = "-4" },
.{ .in = "-3", .out = "-3" },
.{ .in = "0", .out = "0" },
.{ .in = "0.5", .out = "0" },
.{ .in = "-0.5", .out = "-1" },
};
for (cases) |c| {
var a = try Rational.parseDecimal(alloc, c.in);
defer a.deinit();
var f = try Rational.floor(alloc, a);
defer f.deinit();
try expectFrac(c.out, f);
}
}
test "ceil" {
const cases = [_]struct { in: []const u8, out: []const u8 }{
.{ .in = "3.2", .out = "4" },
.{ .in = "3.7", .out = "4" },
.{ .in = "3", .out = "3" },
.{ .in = "-3.2", .out = "-3" },
.{ .in = "-3.7", .out = "-3" },
.{ .in = "0.5", .out = "1" },
.{ .in = "-0.5", .out = "0" },
};
for (cases) |c| {
var a = try Rational.parseDecimal(alloc, c.in);
defer a.deinit();
var r = try Rational.ceil(alloc, a);
defer r.deinit();
try expectFrac(c.out, r);
}
}
test "round: halves go away from zero, matching @round" {
const cases = [_]struct { in: []const u8, out: []const u8 }{
.{ .in = "3.5", .out = "4" },
.{ .in = "3.4", .out = "3" },
.{ .in = "3.6", .out = "4" },
.{ .in = "2.5", .out = "3" },
.{ .in = "-3.5", .out = "-4" },
.{ .in = "-3.4", .out = "-3" },
.{ .in = "-2.5", .out = "-3" },
.{ .in = "7", .out = "7" },
.{ .in = "0", .out = "0" },
};
for (cases) |c| {
var a = try Rational.parseDecimal(alloc, c.in);
defer a.deinit();
var r = try Rational.round(alloc, a);
defer r.deinit();
try expectFrac(c.out, r);
// Cross-check against the f64 builtin for the same input.
const f = try std.fmt.parseFloat(f64, c.in);
try testing.expectEqual(@round(f), r.toFloat(alloc));
}
}
test "mod: matches the a - b*floor(a/b) definition, sign following the divisor" {
// Expected values are written out rather than derived from `@mod`: Zig's
// float `@mod` with a NEGATIVE divisor gave different answers in different
// builds of this suite (-2 normally, 1 under the instrumented coverage
// build, i.e. comptime folding and the runtime path disagree). An oracle
// that changes with optimize mode cannot verify anything, so these are the
// values the definition requires.
const cases = [_]struct { a: i64, b: i64, expected: i64 }{
.{ .a = 10, .b = 3, .expected = 1 }, // floor(10/3)=3 -> 10-9
.{ .a = -10, .b = 3, .expected = 2 }, // floor(-10/3)=-4 -> -10+12
.{ .a = 10, .b = -3, .expected = -2 }, // floor(10/-3)=-4 -> 10-12
.{ .a = -10, .b = -3, .expected = -1 }, // floor(-10/-3)=3 -> -10+9
.{ .a = 7, .b = 7, .expected = 0 },
.{ .a = 0, .b = 5, .expected = 0 },
.{ .a = 7, .b = 5, .expected = 2 },
.{ .a = -7, .b = 5, .expected = 3 },
};
for (cases) |c| {
var a = try Rational.initInt(alloc, c.a);
defer a.deinit();
var b = try Rational.initInt(alloc, c.b);
defer b.deinit();
var m = try Rational.mod(alloc, a, b);
defer m.deinit();
var expected = try Rational.initInt(alloc, c.expected);
defer expected.deinit();
if (!try Rational.eql(alloc, m, expected)) {
const got = try m.toFractionString(alloc);
defer alloc.free(got);
std.debug.print("mod({d}, {d}): expected {d}, got {s}\n", .{ c.a, c.b, c.expected, got });
return error.ModMismatch;
}
// The result must carry the sign of the divisor (or be zero).
if (c.expected != 0) {
try testing.expectEqual(c.b < 0, m.isNegative());
}
}
}
test "mod: fractional operands" {
var a = try Rational.parseDecimal(alloc, "7.5");
defer a.deinit();
var b = try Rational.parseDecimal(alloc, "2");
defer b.deinit();
var m = try Rational.mod(alloc, a, b);
defer m.deinit();
try expectFrac("3/2", m);
}
test "mod: 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.mod(alloc, a, z));
}
test "factorial: small values" {
const cases = [_]struct { n: u64, out: []const u8 }{
.{ .n = 0, .out = "1" },
.{ .n = 1, .out = "1" },
.{ .n = 5, .out = "120" },
.{ .n = 10, .out = "3628800" },
};
for (cases) |c| {
var f = try Rational.factorial(alloc, c.n);
defer f.deinit();
try expectFrac(c.out, f);
}
}
test "factorial: unbounded past the f64 limit of 170" {
// 171! overflows f64 to infinity; exactly this is why the old evaluator
// rejected it. Exact arithmetic has no such wall.
var f = try Rational.factorial(alloc, 171);
defer f.deinit();
try testing.expect(f.isInteger());
const s = try f.toFractionString(alloc);
defer alloc.free(s);
try testing.expect(s.len > 300); // 171! has 310 digits
try testing.expect(std.math.isPositiveInf(f.toFloat(alloc)));
}
test "factorial: 20 is exact where f64 is already lossy" {
var f = try Rational.factorial(alloc, 20);
defer f.deinit();
try expectFrac("2432902008176640000", f);
}
test "factorial: absurd inputs are rejected" {
try testing.expectError(Error.ExponentTooLarge, Rational.factorial(alloc, 20_001));
}