tally/engine/src/evaluator.zig

1938 lines
77 KiB
Zig

//! AST evaluator for Tally.
//!
//! Walks an AST and produces a Value. In standard mode, all computations
//! use f64 floating-point arithmetic. In programmer mode, integer operations
//! are exact (masked to bit width). The evaluator uses an Environment for
//! variable storage, history, and configuration.
const std = @import("std");
const math = std.math;
const Allocator = std.mem.Allocator;
const ast = @import("ast.zig");
const Expr = ast.Expr;
const BinaryOp = ast.BinaryOp;
const Integer = @import("Integer.zig");
/// What standard-mode evaluation can fail with.
///
/// Its own name and range errors, plus everything its dependencies can raise. The
/// `||` chain is the honest signature: financial functions are callable from an
/// expression, so `ConvergenceFailure` really can come out of `evalString`, while a
/// bare `parser.parse` cannot produce it and no longer claims to.
pub const Error = error{
UnknownFunction,
/// A known function called with a number of arguments it does not take. Was
/// reported as `UnknownFunction`, so `log(2)` complained about the name.
WrongArgumentCount,
UnknownVariable,
/// A name the environment answers itself, used as an assignment target.
AssignmentToConstant,
DomainError,
Overflow,
} || Parser.Error || number_mod.Error || bitwise.Error || financial.Error;
const Parser = @import("Parser.zig");
const number_mod = @import("number.zig");
const Number = number_mod.Number;
const bitwise = @import("bitwise.zig");
const financial = @import("financial.zig");
/// The built-in constants. Inexact by nature: pi, e and tau are irrational and have
/// no rational representation.
///
/// One list, consulted by both `getVar` and `isBuiltIn`, so a name cannot be readable
/// as a constant and writable as a variable at the same time.
const constants = [_]struct { name: []const u8, value: f64 }{
.{ .name = "pi", .value = math.pi },
.{ .name = "e", .value = math.e },
.{ .name = "tau", .value = math.tau },
};
fn constantValue(name: []const u8) ?f64 {
for (constants) |c| {
if (std.mem.eql(u8, name, c.name)) return c.value;
}
return null;
}
/// `Ans` is spelled either way, and is the environment's own, so it is a built-in
/// name too even though its value is not a constant.
fn isAnsName(name: []const u8) bool {
return std.mem.eql(u8, name, "Ans") or std.mem.eql(u8, name, "ans");
}
/// Evaluation environment holding variables and the last answer.
///
/// Variables and `Ans` are stored as `Number`, so an assignment keeps whatever
/// exactness its expression had: `X = 0.1` stores exactly one tenth rather than
/// a binary approximation of it.
///
/// It holds no mode and no programmer configuration. It used to hold both and read
/// neither: the caller chooses between `evalString` and `evalProgrammerString`, and
/// standard mode's integer type is fixed (see `standardInt`). The TUI was writing
/// a mode into this on every mode change, into a field nothing consulted.
pub const Environment = struct {
allocator: Allocator,
variables: std.StringHashMap(Number),
ans: Number,
pub fn init(allocator: Allocator) Environment {
return .{
.allocator = allocator,
.variables = std.StringHashMap(Number).init(allocator),
// Starts inexact so that `init` cannot fail; the first evaluation
// replaces it.
.ans = Number.fromFloat(0),
};
}
pub fn deinit(self: *Environment) void {
var it = self.variables.iterator();
while (it.next()) |entry| {
self.allocator.free(entry.key_ptr.*);
entry.value_ptr.deinit();
}
self.variables.deinit();
self.ans.deinit();
}
/// Store a variable. Both the name and the value are copied, so neither has
/// to outlive this call.
///
/// The name is duplicated because it points into the expression source,
/// which callers are free to release: the TUI frees history entries on
/// Ctrl-L, which previously left dangling keys in this map.
pub fn setVar(self: *Environment, name: []const u8, value: Number) !void {
var copy = try value.cloneWith(self.allocator);
errdefer copy.deinit();
const gop = try self.variables.getOrPut(name);
if (gop.found_existing) {
gop.value_ptr.deinit();
} else {
const owned_name = self.allocator.dupe(u8, name) catch |err| {
// Remove the entry keyed by the borrowed name so the map never
// retains a key it does not own.
_ = self.variables.remove(name);
return err;
};
gop.key_ptr.* = owned_name;
}
gop.value_ptr.* = copy;
}
/// Replace the last answer, taking a copy.
pub fn setAns(self: *Environment, value: Number) !void {
const copy = try value.cloneWith(self.allocator);
self.ans.deinit();
self.ans = copy;
}
/// Borrowed view of a variable or built-in constant.
///
/// The result is owned by the environment (or is a freshly built constant),
/// so callers that need it to outlive the environment, or that will free it
/// separately, must `cloneWith` first.
pub fn getVar(self: *const Environment, name: []const u8) ?Number {
if (constantValue(name)) |value| return Number.fromFloat(value);
if (isAnsName(name)) return self.ans;
return self.variables.get(name);
}
/// True for a name this environment answers itself. Such a name cannot be
/// assigned: `getVar` checks the constants and `Ans` before the variable map, so
/// a stored value of the same name would never be read again.
pub fn isBuiltIn(name: []const u8) bool {
return constantValue(name) != null or isAnsName(name);
}
/// The last answer collapsed to f64, for frontends that only need a float.
pub fn ansFloat(self: *const Environment) f64 {
return self.ans.toFloat(self.allocator);
}
};
/// Evaluate a parsed expression in the given environment.
///
/// The exact evaluation core. Produces a `Number`, staying exact until an
/// operation forces the float fallback (see design.md 2.7.4).
///
/// There used to be a `pub fn evaluate` above this that ran the same walk and
/// collapsed the result to `f64`. It was the last f64-returning expression API in
/// the engine, and by the time both frontends had moved to `evalString` nothing
/// called it.
fn evalExact(env: *Environment, scratch: Allocator, expr: *const Expr) Error!Number {
switch (expr.*) {
.number => |n| return literalToNumber(scratch, n),
.string_literal => |text| {
// Pack ASCII bytes into an integer (BE packing, as programmer mode).
var packed_value: u128 = 0;
for (text) |byte| {
if (byte > 0x7F) return Error.InvalidNumber;
packed_value = (packed_value << 8) | byte;
}
return try Number.fromInt(scratch, packed_value);
},
.variable => |name| {
// getVar hands back a borrowed value owned by the environment, so
// copy it into the evaluation arena before it takes part in
// arithmetic that the arena will later free.
const value = env.getVar(name) orelse return Error.UnknownVariable;
return try value.cloneWith(scratch);
},
.assignment => |a| {
// A built-in name is answered by `getVar` before the variable map, so
// storing one would be write-only: `pi = 3` used to return 3 and leave
// pi untouched, with nothing to tell the user the name had not taken.
if (Environment.isBuiltIn(a.name)) return Error.AssignmentToConstant;
const val = try evalExact(env, scratch, a.value);
// setVar copies, so storing an arena-allocated value is safe. What comes
// back is the arena's copy, not the environment's.
env.setVar(a.name, val) catch return Error.OutOfMemory;
return val;
},
.unary => |u| {
const operand = try evalExact(env, scratch, u.operand);
return switch (u.op) {
.negate => try Number.negate(scratch, operand),
// Bitwise NOT is a fixed-width integer operation, not rational
// arithmetic, so it drops to the float/integer path. The width is
// the fixed standard-mode one: this used to read
// `env.programmer_config.bit_width`, so `tally --bits 8 '~0'` gave
// 255 in standard mode while the shifts alongside it ignored the
// setting entirely.
.bitwise_not => blk: {
const projected = try standardOperand(scratch, operand);
break :blk try fromStandardInt(
scratch,
bitwise.not(projected.value),
projected.lossless,
);
},
};
},
.binary => |b| {
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, scratch, c.name, c.args);
},
}
}
/// Turn a literal into a Number, exactly.
///
/// Both bases parse the literal's own source text, because that is the only
/// lossless form of it: `0.1` cannot be recovered from the nearest f64, and
/// `0x1FFFFFFFFFFFFFFFF` does not fit the u64 the tokenizer used to precompute.
///
/// Failures propagate. This used to swallow every `Number.parse` error and fall
/// back to a float, so `1e100001` printed `inf` instead of reporting that the
/// exponent was out of range, and the exact tier silently became the inexact one.
fn literalToNumber(scratch: Allocator, n: ast.Expr.Number) Error!Number {
if (n.base == .decimal) return Number.parse(scratch, n.text);
// A non-decimal literal is a bit pattern, so it is an integer of at most the
// 128 bits `Integer` holds. Wider than that is `Overflow`, not a float.
return Number.fromInt(scratch, try n.base.parseDigits(n.text));
}
/// Evaluate a binary operation.
fn evalBinaryOp(scratch: Allocator, op: BinaryOp, left: Number, right: Number) Error!Number {
return switch (op) {
.add => try Number.add(scratch, left, right),
.sub => try Number.sub(scratch, left, right),
.mul => try Number.mul(scratch, left, right),
.div => try Number.div(scratch, left, right),
.mod => try Number.mod(scratch, left, right),
.pow => try Number.pow(scratch, left, right),
// The remaining operators are fixed-width integer operations rather than
// rational arithmetic, so they work on the 64-bit projection, in the shared
// implementation programmer mode also uses (FR-2.12). `inline else`
// resolves the operator at comptime, so an operator added to `BinaryOp`
// that `bitwise.fromBinaryOp` does not know is a compile error here.
inline else => |fixed_op| blk: {
const l = try standardOperand(scratch, left);
const r = try standardOperand(scratch, right);
const result = try bitwise.apply(comptime bitwise.fromBinaryOp(fixed_op).?, l.value, r.value);
break :blk try fromStandardInt(scratch, result, l.lossless and r.lossless);
},
};
}
/// An operand of a fixed-width operation, and whether getting it here cost anything.
///
/// The projection is lossless when the operand is already an exact integer that fits
/// the width, which is the common case (`0xFF`, `2^62`, `-8`). Otherwise the only
/// representation available is an f64, and the result inherits that: `Number`'s
/// contagion rule says exact means no rounding anywhere in the value's history, so a
/// result computed from a rounded operand must not come back exact.
const StandardOperand = struct {
value: Integer,
lossless: bool,
};
/// Project an operand onto standard mode's integer type: 64-bit two's complement,
/// fixed (FR-2.3), which is also `Integer`'s default.
///
/// Standard mode does not consult the programmer-mode width. A width other than 64
/// is what programmer mode is for, and pretending otherwise is how `~` came to
/// honour the setting while the shifts beside it did not.
///
/// Every one of these operators used to do `@intFromFloat` straight onto the
/// unchecked value, which is illegal behaviour out of range and aborted the
/// process: `2^64 and 1` and `~1e30` both killed it, and a NaN operand produced a
/// garbage answer instead. An operand that does not fit the width is a reportable
/// error, not a crash.
fn standardOperand(scratch: Allocator, n: Number) Error!StandardOperand {
// An exact integer goes in as itself. Through f64 it would not: `2^53 + 1` is
// not representable, so `(2^53 + 1) and -1` used to answer 2^53.
if (n.asExactInt(i64)) |exact| {
return .{ .value = .{ .raw = @as(u64, @bitCast(exact)) }, .lossless = true };
}
return .{ .value = try standardInt(n.toFloat(scratch)), .lossless = false };
}
/// The f64 projection, for an operand with no exact integer form.
fn standardInt(value: f64) Error!Integer {
if (!math.isFinite(value)) return Error.DomainError;
// i64 covers [-2^63, 2^63); 2^63 itself is the first excluded value and is
// exactly representable, so these bounds are exact.
if (value >= 9223372036854775808.0 or value < -9223372036854775808.0) {
return Error.Overflow;
}
const bits: u64 = @bitCast(@as(i64, @intFromFloat(value)));
return .{ .raw = @as(u128, bits) };
}
/// Read a fixed-width result back as a number, signed, since standard mode is
/// signed.
///
/// Exact when nothing was rounded on the way in. The result used to go back through
/// f64 unconditionally, which lost the answer for values above 2^53: `2^62 or 1`
/// reported 4.611686018427388e18 rather than 4611686018427387905.
fn fromStandardInt(scratch: Allocator, value: Integer, lossless: bool) Error!Number {
if (lossless) return Number.fromInt(scratch, value.signedValue());
return Number.fromFloat(@floatFromInt(value.signedValue()));
}
/// Every built-in function the language has.
///
/// The names are the enum's, so `std.meta.stringToEnum` is the lookup and there is no
/// hand-written list of strings to drift. Nothing in the engine can name a function
/// that is not here, and `builtinNames` below lets a test walk the set against
/// FR-5.7.
///
/// This replaced a chain of `if (mem.eql(u8, name, ...))` blocks grouped by argument
/// count, spread across `evalFunction`, `evalSingleArgFn` and `evalFinancialFn`. That
/// shape could not tell a wrong name from a wrong argument count: `log(2)` matched
/// nothing in the one-argument group and came out as "unknown function", about a
/// function that exists.
const Builtin = enum {
// Exact where the operand allows it.
abs,
floor,
ceil,
round,
sqrt,
factorial,
max,
min,
// Float-only by nature: these escape the rationals (design.md 2.7.4).
sin,
cos,
tan,
asin,
acos,
atan,
cbrt,
exp,
ln,
log2,
log10,
/// Arity-overloaded: `log(x)` is log10, `log(x, base)` is the general form.
log,
atan2,
apy,
// Financial (FR-5.7), inexact by construction: every formula here needs a
// non-integer power or a logarithm.
cagr,
fv,
pv,
compound_rate,
compound_years,
tvm_fv,
tvm_pv,
tvm_pmt,
tvm_n,
tvm_rate,
amort_payment,
amort_total_interest,
amort_total_paid,
amort_interest,
amort_principal,
amort_balance,
rand,
};
/// How many arguments a built-in takes. `min` and `max` differ only where a trailing
/// argument is optional.
const Arity = struct { min: u8, max: u8 };
/// The widest argument list any built-in takes, which is what the float projection
/// buffer is sized for.
const max_args = 4;
fn arityOf(builtin: Builtin) Arity {
return switch (builtin) {
.rand => .{ .min = 0, .max = 0 },
.abs,
.floor,
.ceil,
.round,
.sqrt,
.factorial,
.sin,
.cos,
.tan,
.asin,
.acos,
.atan,
.cbrt,
.exp,
.ln,
.log2,
.log10,
=> .{ .min = 1, .max = 1 },
// log10 with one argument, general with two.
.log => .{ .min = 1, .max = 2 },
.max, .min, .atan2, .apy => .{ .min = 2, .max = 2 },
.cagr, .amort_payment, .amort_total_interest, .amort_total_paid => .{ .min = 3, .max = 3 },
// The compounding frequency defaults to annual when it is left off.
.fv, .pv, .compound_rate, .compound_years => .{ .min = 3, .max = max_args },
.tvm_fv,
.tvm_pv,
.tvm_pmt,
.tvm_n,
.tvm_rate,
.amort_interest,
.amort_principal,
.amort_balance,
=> .{ .min = max_args, .max = max_args },
};
}
/// Evaluate a built-in function call.
///
/// The name and the argument count are checked before anything is evaluated, so a
/// misspelled name and a miscounted argument list are different errors.
fn evalFunction(env: *Environment, scratch: Allocator, name: []const u8, args: []const *Expr) Error!Number {
const builtin = std.meta.stringToEnum(Builtin, name) orelse return Error.UnknownFunction;
const arity = arityOf(builtin);
if (args.len < arity.min or args.len > arity.max) return Error.WrongArgumentCount;
switch (builtin) {
// These have exact implementations, so their operands stay `Number`.
.abs, .floor, .ceil, .round, .sqrt, .factorial, .max, .min => {
return exactBuiltin(env, scratch, builtin, args);
},
else => {},
}
// Everything else has no exact form, so the operands collapse to f64 once, here.
var values: [max_args]f64 = undefined;
for (args, 0..) |arg, i| {
var value = try evalExact(env, scratch, arg);
values[i] = value.toFloat(scratch);
}
return Number.fromFloat(try floatBuiltin(builtin, values[0..args.len]));
}
/// The built-ins with an exact implementation: an exact operand gives an exact
/// result, so `sqrt(4)` is 2 and `factorial(171)` is every one of its 310 digits.
fn exactBuiltin(
env: *Environment,
scratch: Allocator,
builtin: Builtin,
args: []const *Expr,
) Error!Number {
const x = try evalExact(env, scratch, args[0]);
return switch (builtin) {
.abs => Number.abs(scratch, x),
.floor => Number.floor(scratch, x),
.ceil => Number.ceil(scratch, x),
.round => Number.round(scratch, x),
// The negative-input rule lives in Number.sqrt, which raises NegativeRoot.
// That name reaches the user instead of being flattened into "domain error".
.sqrt => Number.sqrt(scratch, x),
// Null means the argument was negative or fractional, which is a domain
// error, not an unknown function.
.factorial => (try Number.factorial(scratch, x)) orelse Error.DomainError,
.max, .min => blk: {
const y = try evalExact(env, scratch, args[1]);
break :blk if (builtin == .max)
Number.max(scratch, x, y)
else
Number.min(scratch, x, y);
},
// `evalFunction` routes only the group above here.
else => unreachable,
};
}
/// The built-ins with no exact form, over operands already collapsed to f64.
///
/// `a.len` is within the arity `evalFunction` checked, so an optional trailing
/// argument is the only thing that varies.
fn floatBuiltin(builtin: Builtin, a: []const f64) Error!f64 {
// The compounding frequency the compound functions take optionally.
const per_year: f64 = if (a.len == max_args) a[max_args - 1] else 1;
return switch (builtin) {
.sin => @sin(a[0]),
.cos => @cos(a[0]),
.tan => @tan(a[0]),
.asin => if (a[0] < -1 or a[0] > 1) Error.DomainError else math.asin(a[0]),
.acos => if (a[0] < -1 or a[0] > 1) Error.DomainError else math.acos(a[0]),
.atan => math.atan(a[0]),
.cbrt => math.cbrt(a[0]),
.exp => @exp(a[0]),
// A logarithm of a non-positive value has no real result. These used to
// return -inf or NaN, while the two-argument `log` reported it properly.
.ln => if (a[0] <= 0) Error.DomainError else @log(a[0]),
.log2 => if (a[0] <= 0) Error.DomainError else @log2(a[0]),
.log10 => if (a[0] <= 0) Error.DomainError else @log10(a[0]),
.log => blk: {
if (a[0] <= 0) break :blk Error.DomainError;
if (a.len == 1) break :blk @log10(a[0]);
if (a[1] <= 0 or a[1] == 1) break :blk Error.DomainError;
break :blk @log(a[0]) / @log(a[1]);
},
.atan2 => math.atan2(a[0], a[1]),
// apy(nominal_rate, compounds_per_year): the effective annual rate, so a
// nominal rate can be compared against one.
.apy => financial.effectiveAnnualRate(a[0], a[1]),
// cagr(start, end, periods) -> growth rate as a fraction.
.cagr => financial.cagr(a[0], a[1], a[2]),
// fv(pv, annual_rate_percent, years[, per_year]) and its inverse. The rate is
// NOMINAL; use apy() to convert.
.fv => financial.compoundFutureValue(a[0], a[1], a[2], per_year),
.pv => financial.compoundPresentValue(a[0], a[1], a[2], per_year),
// The same relationship solved for its other two variables.
.compound_rate => financial.compoundRate(a[0], a[1], a[2], per_year),
.compound_years => financial.compoundPeriods(a[0], a[1], a[2], per_year),
// TVM: each function names the variable it solves for and takes the other
// four in the calculator's N, I/Y, PV, PMT, FV order.
.tvm_fv => (try financial.solveTvm(.{
.periods = a[0],
.rate = a[1],
.present_value = a[2],
.payment = a[3],
})).value,
.tvm_pv => (try financial.solveTvm(.{
.periods = a[0],
.rate = a[1],
.payment = a[2],
.future_value = a[3],
})).value,
.tvm_pmt => (try financial.solveTvm(.{
.periods = a[0],
.rate = a[1],
.present_value = a[2],
.future_value = a[3],
})).value,
.tvm_n => (try financial.solveTvm(.{
.rate = a[0],
.present_value = a[1],
.payment = a[2],
.future_value = a[3],
})).value,
.tvm_rate => (try financial.solveTvm(.{
.periods = a[0],
.present_value = a[1],
.payment = a[2],
.future_value = a[3],
})).value,
// Amortization: (principal, rate_per_period_percent, periods[, period]).
.amort_payment => financial.amortizationPayment(try loan(a)),
.amort_total_interest => (try financial.amortizationTotals(try loan(a))).interest,
.amort_total_paid => (try financial.amortizationTotals(try loan(a))).paid,
.amort_interest => (try amortRow(a)).interest,
.amort_principal => (try amortRow(a)).principal,
.amort_balance => (try amortRow(a)).balance,
// Not truly random in a pure engine, but useful as a placeholder.
.rand => 0,
// `evalFunction` sends the exact group to `exactBuiltin` before collapsing
// anything to a float, so none of it arrives here.
.abs, .floor, .ceil, .round, .sqrt, .factorial, .max, .min => unreachable,
};
}
/// The loan an amortization built-in describes: principal, rate per period, periods.
fn loan(a: []const f64) Error!financial.AmortizationParams {
return .{ .principal = a[0], .rate = a[1], .periods = try periodCount(a[2]) };
}
/// One row of an amortization schedule, for the built-ins that report a single
/// period.
fn amortRow(a: []const f64) Error!financial.AmortizationEntry {
return financial.amortizationEntry(try loan(a), try periodCount(a[3]));
}
/// A whole period count or 1-based period index, validated.
fn periodCount(value: f64) Error!usize {
if (!math.isFinite(value)) return Error.DomainError;
if (@floor(value) != value) return Error.DomainError;
if (value < 1 or value > @as(f64, @floatFromInt(financial.max_schedule_periods))) {
return Error.DomainError;
}
return @intFromFloat(value);
}
/// Result of evaluation with metadata for display decisions.
pub const EvalInfo = struct {
/// The computed value. Allocated with the allocator passed to
/// `evalStringInfo`; the caller owns it and should `deinit` when done.
value: Number,
/// True if the expression contained any non-decimal literal (hex/oct/bin).
has_nondecimal_literal: bool,
};
/// High-level evaluate: parse a string and evaluate it.
/// Updates env.ans on success. The caller owns the returned value.
pub fn evalString(env: *Environment, allocator: Allocator, source: []const u8) Error!Number {
const info = try evalStringInfo(env, allocator, source);
return info.value;
}
/// Like evalString but returns metadata (whether the expression used
/// non-decimal literals) so frontends can decide to show a multi-base view.
pub fn evalStringInfo(env: *Environment, allocator: Allocator, source: []const u8) Error!EvalInfo {
var p = Parser.init(allocator, source);
const expr = try p.parse();
// The parser hands over ownership. Nothing in the result borrows from the
// tree (literal text points into `source`, and the value is cloned out of the
// scratch arena), so it can be released as soon as evaluation is done.
// Without this every evaluated expression leaked its whole AST, which only
// went unnoticed because the CLI hands in an arena.
defer Parser.freeExpr(allocator, expr);
// Intermediates live in a scratch arena; only the final value is copied out
// into the caller's allocator.
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const scratch = arena.allocator();
const raw = try evalExact(env, scratch, expr);
const result = try raw.cloneWith(allocator);
env.setAns(result) catch return Error.OutOfMemory;
return .{
.value = result,
.has_nondecimal_literal = hasNonDecimalLiteral(expr),
};
}
/// Walk an AST and report whether any number literal is non-decimal.
fn hasNonDecimalLiteral(expr: *const Expr) bool {
return switch (expr.*) {
.number => |n| n.base != .decimal,
.string_literal => false,
.variable => false,
.unary => |u| hasNonDecimalLiteral(u.operand),
.binary => |b| hasNonDecimalLiteral(b.left) or hasNonDecimalLiteral(b.right),
.call => |c| blk: {
for (c.args) |arg| {
if (hasNonDecimalLiteral(arg)) break :blk true;
}
break :blk false;
},
.assignment => |a| hasNonDecimalLiteral(a.value),
};
}
// -- Tests --
const testing = std.testing;
/// Evaluate and collapse to f64.
///
/// The exact result is converted here rather than at every call site, which is
/// what lets the ~90 pre-existing f64 assertions in this file stay untouched
/// while the engine itself moved to `Number`.
fn testEval(source: []const u8) !f64 {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const alloc = arena.allocator();
var env = Environment.init(alloc);
defer env.deinit();
const result = try evalString(&env, alloc, source);
return result.toFloat(alloc);
}
fn testEvalProgrammer(source: []const u8) !f64 {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const alloc = arena.allocator();
var env = Environment.init(alloc);
defer env.deinit();
const result = try evalString(&env, alloc, source);
return result.toFloat(alloc);
}
test "eval simple number" {
const result = try testEval("42");
try testing.expectEqual(@as(f64, 42.0), result);
}
test "eval addition" {
const result = try testEval("2 + 3");
try testing.expectEqual(@as(f64, 5.0), result);
}
test "eval subtraction" {
const result = try testEval("10 - 7");
try testing.expectEqual(@as(f64, 3.0), result);
}
test "eval multiplication" {
const result = try testEval("6 * 7");
try testing.expectEqual(@as(f64, 42.0), result);
}
test "eval division" {
const result = try testEval("10 / 4");
try testing.expectEqual(@as(f64, 2.5), result);
}
test "eval division by zero" {
const result = testEval("1 / 0");
try testing.expectError(Error.DivisionByZero, result);
}
test "eval modulo" {
const result = try testEval("10 % 3");
try testing.expectApproxEqAbs(@as(f64, 1.0), result, 1e-10);
}
test "eval power" {
const result = try testEval("2^10");
try testing.expectEqual(@as(f64, 1024.0), result);
}
test "eval precedence" {
const result = try testEval("2 + 3 * 4");
try testing.expectEqual(@as(f64, 14.0), result);
}
test "eval parentheses" {
const result = try testEval("(2 + 3) * 4");
try testing.expectEqual(@as(f64, 20.0), result);
}
test "eval unary negation" {
const result = try testEval("-5 + 3");
try testing.expectEqual(@as(f64, -2.0), result);
}
test "eval nested parens" {
const result = try testEval("((2 + 3) * (4 - 1))");
try testing.expectEqual(@as(f64, 15.0), result);
}
test "eval pi constant" {
const result = try testEval("pi");
try testing.expectApproxEqAbs(math.pi, result, 1e-10);
}
test "eval e constant" {
const result = try testEval("e");
try testing.expectApproxEqAbs(math.e, result, 1e-10);
}
test "eval tau constant" {
const result = try testEval("tau");
try testing.expectApproxEqAbs(math.tau, result, 1e-10);
}
test "eval 2*pi" {
const result = try testEval("2*pi");
try testing.expectApproxEqAbs(2.0 * math.pi, result, 1e-10);
}
test "eval 3*(4+5)" {
const result = try testEval("3*(4+5)");
try testing.expectEqual(@as(f64, 27.0), result);
}
test "eval sin" {
const result = try testEval("sin(0)");
try testing.expectApproxEqAbs(@as(f64, 0.0), result, 1e-10);
}
test "eval cos" {
const result = try testEval("cos(0)");
try testing.expectApproxEqAbs(@as(f64, 1.0), result, 1e-10);
}
test "eval sqrt" {
const result = try testEval("sqrt(144)");
try testing.expectEqual(@as(f64, 12.0), result);
}
test "eval abs" {
const result = try testEval("abs(-42)");
try testing.expectEqual(@as(f64, 42.0), result);
}
test "eval floor" {
const result = try testEval("floor(3.7)");
try testing.expectEqual(@as(f64, 3.0), result);
}
test "eval ceil" {
const result = try testEval("ceil(3.2)");
try testing.expectEqual(@as(f64, 4.0), result);
}
test "eval round" {
const result = try testEval("round(3.5)");
try testing.expectEqual(@as(f64, 4.0), result);
}
test "eval factorial" {
const result = try testEval("factorial(5)");
try testing.expectEqual(@as(f64, 120.0), result);
}
test "eval factorial 0" {
const result = try testEval("factorial(0)");
try testing.expectEqual(@as(f64, 1.0), result);
}
test "eval ln" {
const result = try testEval("ln(1)");
try testing.expectApproxEqAbs(@as(f64, 0.0), result, 1e-10);
}
test "eval exp" {
const result = try testEval("exp(0)");
try testing.expectEqual(@as(f64, 1.0), result);
}
test "eval max" {
const result = try testEval("max(3, 7)");
try testing.expectEqual(@as(f64, 7.0), result);
}
test "eval min" {
const result = try testEval("min(3, 7)");
try testing.expectEqual(@as(f64, 3.0), result);
}
test "eval unknown function" {
const result = testEval("bogus(1)");
try testing.expectError(Error.UnknownFunction, result);
}
test "eval unknown variable" {
const result = testEval("xyz");
try testing.expectError(Error.UnknownVariable, result);
}
test "eval variable assignment and use" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const alloc = arena.allocator();
var env = Environment.init(alloc);
defer env.deinit();
const assign_result = try evalString(&env, alloc, "X = 42");
try testing.expectEqual(@as(f64, 42.0), assign_result.toFloat(alloc));
const use_result = try evalString(&env, alloc, "X + 8");
try testing.expectEqual(@as(f64, 50.0), use_result.toFloat(alloc));
}
test "eval Ans" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const alloc = arena.allocator();
var env = Environment.init(alloc);
defer env.deinit();
_ = try evalString(&env, alloc, "7 * 6");
const result = try evalString(&env, alloc, "Ans + 1");
try testing.expectEqual(@as(f64, 43.0), result.toFloat(alloc));
}
test "eval complex expression" {
const result = try testEval("sin(pi/2) + cos(0)");
try testing.expectApproxEqAbs(@as(f64, 2.0), result, 1e-10);
}
test "eval 2^32 - 1" {
const result = try testEval("2^32 - 1");
try testing.expectEqual(@as(f64, 4294967295.0), result);
}
test "eval programmer XOR" {
const result = try testEvalProgrammer("0xF xor 0x3");
try testing.expectEqual(@as(f64, 12.0), result);
}
test "eval programmer AND" {
const result = try testEvalProgrammer("0xFF & 0x0F");
try testing.expectEqual(@as(f64, 15.0), result);
}
test "eval programmer OR" {
const result = try testEvalProgrammer("0xF0 | 0x0F");
try testing.expectEqual(@as(f64, 255.0), result);
}
test "eval programmer shift left" {
const result = try testEvalProgrammer("1 << 8");
try testing.expectEqual(@as(f64, 256.0), result);
}
test "eval programmer shift right" {
const result = try testEvalProgrammer("256 >> 4");
try testing.expectEqual(@as(f64, 16.0), result);
}
test "eval number with underscores" {
const result = try testEval("1_000_000 + 1");
try testing.expectEqual(@as(f64, 1_000_001.0), result);
}
test "eval number with commas" {
const result = try testEval("1,000 * 2.3");
try testing.expectApproxEqAbs(@as(f64, 2300.0), result, 1e-10);
}
test "eval commas not confused with function args" {
const result = try testEval("max(1,000, 500)");
try testing.expectEqual(@as(f64, 1000.0), result);
}
test "eval bitwise not in standard mode" {
const result = try testEval("~0");
// ~0 as i64 = -1
try testing.expectEqual(@as(f64, -1.0), result);
}
test "standard mode: the fixed-width operators use one implementation with programmer mode" {
// FR-2.12: an operator means the same thing in both modes. Standard mode is
// 64-bit signed (FR-2.3), so the same expression evaluated through the
// evaluator and through programmer.zig has to agree. Note that this compares
// the two real paths: `testEvalProgrammer` only sets the mode flag and still
// runs the evaluator, so it would have compared one implementation with itself.
const programmer_mod = @import("programmer.zig");
const shared = [_][]const u8{
"0xF0 and 0x0F",
"0xF0 or 0x0F",
"5 xor 3",
"1 << 10",
"1 << 63",
"1 << 64",
"1024 >> 4",
"1024 >>> 4",
"0 - 8 >> 1",
"1 rol 4",
"1 rol 65",
"16 ror 4",
"~0",
"~5",
};
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
for (shared) |source| {
const standard = try testEval(source);
const prog = try programmer_mod.evalProgrammerString(alloc, source, .{
.width = .bits64,
.signedness = .signed,
});
try testing.expectEqual(@as(i128, @intFromFloat(standard)), prog.signedValue());
}
}
test "standard mode: >> is arithmetic and >>> is logical" {
// This is the case that used to differ: standard mode had only a logical shift,
// so `-8 >> 1` was 9223372036854775804 rather than -4.
try testing.expectEqual(@as(f64, -4.0), try testEval("0 - 8 >> 1"));
try testing.expectEqual(@as(f64, -1.0), try testEval("0 - 1 >> 1"));
try testing.expectEqual(@as(f64, -64.0), try testEval("0 - 128 >> 1"));
// The zero-filling variant is still available and still huge.
try testing.expect(try testEval("0 - 8 >>> 1") > 9.0e18);
// Non-negative values shift identically either way.
try testing.expectEqual(try testEval("1024 >> 4"), try testEval("1024 >>> 4"));
}
test "standard mode: a shift runs to completion instead of wrapping the distance" {
// The distance used to be reduced modulo 64, so `1 << 64` was `1 << 0`.
try testing.expectEqual(@as(f64, 0.0), try testEval("1 << 64"));
try testing.expectEqual(@as(f64, 0.0), try testEval("1 << 65"));
try testing.expectEqual(@as(f64, 0.0), try testEval("1 << 1000"));
try testing.expectEqual(@as(f64, 0.0), try testEval("1024 >>> 64"));
// A negative value shifted all the way out is all sign bits, which is -1.
try testing.expectEqual(@as(f64, -1.0), try testEval("0 - 8 >> 64"));
// One place short of the width still keeps a bit: 1 << 63 is the sign bit.
try testing.expect(try testEval("1 << 63") < 0.0);
}
test "standard mode: a negative shift distance is a domain error" {
// It used to be reduced modulo 64, so `8 >> -1` quietly became `8 >> 63`.
try testing.expectError(Error.DomainError, testEval("8 >> 0 - 1"));
try testing.expectError(Error.DomainError, testEval("8 << 0 - 1"));
try testing.expectError(Error.DomainError, testEval("8 >>> 0 - 1"));
try testing.expectError(Error.DomainError, testEval("8 rol 0 - 1"));
}
test "standard mode: rotation is cyclic, not clamped" {
try testing.expectEqual(@as(f64, 1.0), try testEval("1 rol 64"));
try testing.expectEqual(@as(f64, 2.0), try testEval("1 rol 65"));
try testing.expectEqual(@as(f64, 1.0), try testEval("1 ror 64"));
}
test "standard mode: its integer type is fixed at 64-bit signed" {
// `~` used to read `env.programmer_config.bit_width` while the shifts beside it
// ignored it, so `--bits 8` changed one operator and not the others. The
// environment no longer carries a width to disagree about: there is nothing to
// set here, which is the point.
var env = Environment.init(testing.allocator);
defer env.deinit();
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var not_zero = try evalString(&env, alloc, "~0");
defer not_zero.deinit();
try testing.expectEqual(@as(f64, -1.0), not_zero.toFloat(alloc));
var shifted = try evalString(&env, alloc, "1 << 10");
defer shifted.deinit();
try testing.expectEqual(@as(f64, 1024.0), shifted.toFloat(alloc));
const projected = try standardInt(1);
try testing.expectEqual(@as(u8, 64), projected.width.bits());
try testing.expectEqual(Integer.Signedness.signed, projected.signedness);
}
test "eval rotate left in standard mode" {
// 1 rol 4 = 16 (for 64-bit)
const result = try testEvalProgrammer("1 rol 4");
try testing.expectEqual(@as(f64, 16.0), result);
}
test "eval rotate right in standard mode" {
const result = try testEvalProgrammer("16 ror 4");
try testing.expectEqual(@as(f64, 1.0), result);
}
test "eval atan2" {
const result = try testEval("atan2(1, 1)");
try testing.expectApproxEqAbs(math.pi / 4.0, result, 1e-10);
}
test "eval log with base" {
const result = try testEval("log(100, 10)");
try testing.expectApproxEqAbs(@as(f64, 2.0), result, 1e-10);
}
test "eval log domain error" {
const result = testEval("log(-1, 10)");
try testing.expectError(Error.DomainError, result);
}
test "domain errors are domain errors, not unknown functions" {
// These pinned the wrong contract: the name is known, the argument is not in
// its domain. Reporting "unknown function" sent the user looking for a typo.
try testing.expectError(Error.DomainError, testEval("asin(2)"));
try testing.expectError(Error.DomainError, testEval("asin(-2)"));
try testing.expectError(Error.DomainError, testEval("acos(2)"));
try testing.expectError(Error.DomainError, testEval("factorial(-1)"));
try testing.expectError(Error.DomainError, testEval("factorial(2.5)"));
// Logarithms of non-positive values, which used to return -inf or NaN. The
// two-argument form already reported this correctly.
try testing.expectError(Error.DomainError, testEval("ln(0)"));
try testing.expectError(Error.DomainError, testEval("ln(0 - 1)"));
try testing.expectError(Error.DomainError, testEval("log(0)"));
try testing.expectError(Error.DomainError, testEval("log10(0 - 5)"));
try testing.expectError(Error.DomainError, testEval("log2(0)"));
try testing.expectError(Error.DomainError, testEval("log(100, 1)"));
// sqrt says which domain rule was broken, because the numeric tier raises its
// own error and nothing flattens it on the way out.
try testing.expectError(Error.NegativeRoot, testEval("sqrt(-1)"));
try testing.expectError(Error.NegativeRoot, testEval("sqrt(0 - 4)"));
// A genuinely unknown name still reports one.
try testing.expectError(Error.UnknownFunction, testEval("nope(1)"));
// A known name with too many arguments is an arity error, not a name error:
// this line used to expect UnknownFunction, which was the bug.
try testing.expectError(Error.WrongArgumentCount, testEval("asin(1, 2)"));
}
test "the functions themselves still work inside their domains" {
try testing.expectApproxEqAbs(@as(f64, 0.0), try testEval("asin(0)"), 1e-15);
try testing.expectApproxEqAbs(math.pi / 2.0, try testEval("acos(0)"), 1e-15);
try testing.expectEqual(@as(f64, 2.0), try testEval("log10(100)"));
try testing.expectApproxEqAbs(@as(f64, 1.0), try testEval("ln(e)"), 1e-15);
try testing.expectEqual(@as(f64, 3.0), try testEval("log2(8)"));
try testing.expectEqual(@as(f64, 120.0), try testEval("factorial(5)"));
try testing.expectEqual(@as(f64, 12.0), try testEval("sqrt(144)"));
}
test "tan, atan and cbrt compute what they say" {
// These had no test at all. The old dispatch hid it: `mem.eql(u8, name, "tan")`
// ran on every single-argument call, so the line counted as covered even when
// `@tan` never did. One switch arm per function is what surfaced the gap.
try testing.expectApproxEqAbs(@as(f64, 1.0), try testEval("tan(pi / 4)"), 1e-15);
try testing.expectApproxEqAbs(@as(f64, 0.0), try testEval("tan(0)"), 1e-15);
try testing.expectApproxEqAbs(math.pi / 4.0, try testEval("atan(1)"), 1e-15);
try testing.expectApproxEqAbs(@as(f64, 0.0), try testEval("atan(0)"), 1e-15);
try testing.expectEqual(@as(f64, 3.0), try testEval("cbrt(27)"));
// The cube root of a negative value is real, unlike the square root.
try testing.expectEqual(@as(f64, -2.0), try testEval("cbrt(0 - 8)"));
}
test "eval acos" {
const result = try testEval("acos(1)");
try testing.expectApproxEqAbs(@as(f64, 0.0), result, 1e-10);
}
test "evalStringInfo: detects hex literal" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const alloc = arena.allocator();
var env = Environment.init(alloc);
defer env.deinit();
const info = try evalStringInfo(&env, alloc, "0o777 - 0x0f");
try testing.expectEqual(@as(f64, 496.0), info.value.toFloat(alloc));
try testing.expect(info.has_nondecimal_literal);
}
test "evalStringInfo: pure decimal has no nondecimal literal" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const alloc = arena.allocator();
var env = Environment.init(alloc);
defer env.deinit();
const info = try evalStringInfo(&env, alloc, "2 + 2");
try testing.expectEqual(@as(f64, 4.0), info.value.toFloat(alloc));
try testing.expect(!info.has_nondecimal_literal);
}
test "evalStringInfo: binary literal detected in nested expr" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const alloc = arena.allocator();
var env = Environment.init(alloc);
defer env.deinit();
const info = try evalStringInfo(&env, alloc, "sqrt(0b100) + 1");
try testing.expect(info.has_nondecimal_literal);
}
test "eval string literal in standard mode packs ASCII" {
// 'A' -> 0x41 -> 65
const result = try testEval("'A'");
try testing.expectEqual(@as(f64, 65.0), result);
}
test "eval string literal multi-char in standard mode" {
// 'AB' -> 0x4142 -> 16706
const result = try testEval("'AB'");
try testing.expectEqual(@as(f64, 16706.0), result);
}
test "eval string literal with non-ASCII byte errors" {
// byte > 0x7F is rejected
const result = testEval("'\x80'");
try testing.expectError(Error.InvalidNumber, result);
}
test "eval rand zero-arg function returns 0" {
const result = try testEval("rand()");
try testing.expectEqual(@as(f64, 0.0), result);
}
test "eval unknown zero-arg function" {
const result = testEval("bogus()");
try testing.expectError(Error.UnknownFunction, result);
}
test "eval unknown three-arg function" {
const result = testEval("bogus(1, 2, 3)");
try testing.expectError(Error.UnknownFunction, result);
}
test "eval unknown two-arg function" {
const result = testEval("bogus(1, 2)");
try testing.expectError(Error.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: an absurd exponent is reported as an exponent that is too large" {
// The rational layer's guard reaches the caller by its own name rather than as a
// generic Overflow, which is what the old single error set turned it into.
try testing.expectError(Error.ExponentTooLarge, testEval("2 ^ 3000000"));
}
// -- Exactness visible through the Number API (Task 2.0c) --
//
// These are the cases the f64 boundary could not express. `testEval` collapses
// to f64 and would lose exactly the information under test here.
/// Evaluate and keep the exact result. The arena owns everything.
fn testEvalNumber(arena: *std.heap.ArenaAllocator, source: []const u8) !Number {
const a = arena.allocator();
var env = Environment.init(a);
defer env.deinit();
return evalString(&env, a, source);
}
fn expectExactDecimal(expected: []const u8, source: []const u8) !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const result = try testEvalNumber(&arena, source);
try testing.expect(result == .exact);
const shown = try result.exact.toDecimalString(arena.allocator(), 20);
try testing.expectEqualStrings(expected, shown.text);
}
test "Number API: the integer f64 cannot hold round-trips" {
// The headline case. Through the f64 boundary this became
// 9.007199254740992e15, a DIFFERENT integer than the one typed.
try expectExactDecimal("9007199254740993", "9007199254740993");
try expectExactDecimal("9007199254740993", "2^53 + 1");
try expectExactDecimal("9007199254740992", "2^53");
}
test "Number API: exact integer arithmetic is unbounded" {
try expectExactDecimal("1267650600228229401496703205376", "2^100");
try expectExactDecimal("100000000000000000001", "1e20 + 1");
try expectExactDecimal("121932631112635269", "123456789 * 987654321");
}
test "Number API: one third is retained exactly, not as a decimal" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const result = try testEvalNumber(&arena, "1/3");
try testing.expect(result == .exact);
// The exact form is a fraction, which no float could express.
const frac = try result.exact.toFractionString(arena.allocator());
try testing.expectEqualStrings("1/3", frac);
// And its decimal rendering is correctly reported as approximate.
const shown = try result.exact.toDecimalString(arena.allocator(), 10);
try testing.expect(!shown.exact);
}
test "Number API: factorial is exact past the old 170 limit" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const result = try testEvalNumber(&arena, "factorial(171)");
try testing.expect(result == .exact);
const shown = try result.exact.toDecimalString(arena.allocator(), 0);
// 171! has 310 digits; f64 could only report infinity.
try testing.expectEqual(@as(usize, 310), shown.text.len);
try testing.expect(shown.exact);
}
test "Number API: transcendentals are reported as inexact" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const s = try testEvalNumber(&arena, "sin(1)");
try testing.expect(s != .exact);
const p = try testEvalNumber(&arena, "pi");
try testing.expect(p != .exact);
const r = try testEvalNumber(&arena, "sqrt(2)");
try testing.expect(r != .exact);
// But a perfect square stays exact.
const q = try testEvalNumber(&arena, "sqrt(144)");
try testing.expect(q == .exact);
}
test "Number API: inexactness is contagious across an expression" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const result = try testEvalNumber(&arena, "0.1 + 0.2 + sin(0)");
try testing.expect(result != .exact);
}
test "Number API: variables keep the exactness of their expression" {
// This is what storing Number in the Environment buys: previously the
// assignment round-tripped through f64 and 0.1 came back approximated.
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const a = arena.allocator();
var env = Environment.init(a);
defer env.deinit();
const assigned = try evalString(&env, a, "X = 0.1");
try testing.expect(assigned == .exact);
const sum = try evalString(&env, a, "X + 0.2");
try testing.expect(sum == .exact);
const shown = try sum.exact.toDecimalString(a, 20);
try testing.expectEqualStrings("0.3", shown.text);
try testing.expect(shown.exact);
}
test "Number API: Ans keeps exactness between evaluations" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const a = arena.allocator();
var env = Environment.init(a);
defer env.deinit();
_ = try evalString(&env, a, "1/3");
const doubled = try evalString(&env, a, "Ans * 3");
try testing.expect(doubled == .exact);
const shown = try doubled.exact.toDecimalString(a, 20);
try testing.expectEqualStrings("1", shown.text);
}
test "Number API: reassigning a variable releases the old value" {
// Exercises the replace path in setVar, which must deinit the previous
// Number rather than leaking it.
var env = Environment.init(testing.allocator);
defer env.deinit();
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const a = arena.allocator();
_ = try evalString(&env, a, "X = 1/3");
_ = try evalString(&env, a, "X = 2/7");
_ = try evalString(&env, a, "X = 5");
const result = try evalString(&env, a, "X * 2");
try testing.expectEqual(@as(f64, 10.0), result.toFloat(a));
}
test "Number API: a variable name outliving its source text stays valid" {
// setVar duplicates the name because it points into the expression source,
// which the caller may free (the TUI frees history on Ctrl-L).
var env = Environment.init(testing.allocator);
defer env.deinit();
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const a = arena.allocator();
{
const source = try testing.allocator.dupe(u8, "myvar = 42");
defer testing.allocator.free(source);
_ = try evalString(&env, a, source);
}
// The source is gone; the stored name must still resolve.
const result = try evalString(&env, a, "myvar + 1");
try testing.expectEqual(@as(f64, 43.0), result.toFloat(a));
}
// -- Financial functions in standard mode --
//
// The point of these tests is reachability and composition: the financial math
// itself is covered in financial.zig. What matters here is that a plain
// expression can call them, with the argument counts the parser now allows.
test "financial: cagr is callable from a standard expression" {
try testing.expectApproxEqAbs(@as(f64, 0.2011244), try testEval("cagr(10000, 25000, 5)"), 1e-7);
}
test "financial: a financial result composes with ordinary arithmetic" {
// The fraction-to-percent conversion users will reach for immediately.
try testing.expectApproxEqAbs(@as(f64, 20.11244), try testEval("cagr(10000, 25000, 5) * 100"), 1e-5);
}
test "financial: arguments may themselves be expressions" {
try testing.expectApproxEqAbs(
@as(f64, 0.2011244),
try testEval("cagr(10000, 5 * 5000, 60 / 12)"),
1e-7,
);
}
test "financial: compound interest with and without a frequency argument" {
// Three arguments compounds annually.
try testing.expectApproxEqAbs(@as(f64, 1628.894627), try testEval("fv(1000, 5, 10)"), 1e-6);
// The fourth argument is the compounding frequency; monthly beats annual.
try testing.expectApproxEqAbs(@as(f64, 1647.009498), try testEval("fv(1000, 5, 10, 12)"), 1e-6);
try testing.expect(try testEval("fv(1000, 5, 10, 12)") > try testEval("fv(1000, 5, 10)"));
}
test "financial: present value inverts future value" {
const future = try testEval("fv(1000, 5, 10, 4)");
var buffer: [64]u8 = undefined;
const source = try std.fmt.bufPrint(&buffer, "pv({d}, 5, 10, 4)", .{future});
try testing.expectApproxEqAbs(@as(f64, 1000.0), try testEval(source), 1e-6);
}
test "financial: compound interest solves for its rate and its time" {
// The same relationship as fv(), read backwards.
try testing.expectApproxEqAbs(
@as(f64, 5.0),
try testEval("compound_rate(1000, 1628.894627, 10)"),
1e-6,
);
try testing.expectApproxEqAbs(
@as(f64, 10.0),
try testEval("compound_years(1000, 1628.894627, 5)"),
1e-6,
);
// With a compounding frequency the rate is nominal, so it is lower.
const monthly = try testEval("compound_rate(1000, 2000, 10, 12)");
const annual = try testEval("compound_rate(1000, 2000, 10)");
try testing.expect(monthly < annual);
try testing.expectApproxEqAbs(@as(f64, 6.95152928), monthly, 1e-8);
// At annual compounding, solving for the rate is CAGR.
try testing.expectApproxEqAbs(
try testEval("cagr(10000, 25000, 5) * 100"),
try testEval("compound_rate(10000, 25000, 5)"),
1e-9,
);
}
test "financial: apy converts a nominal rate to an effective one" {
try testing.expectApproxEqAbs(@as(f64, 19.5618), try testEval("apy(18, 12)"), 1e-4);
// Annual compounding is its own effective rate.
try testing.expectApproxEqAbs(@as(f64, 5.0), try testEval("apy(5, 1)"), 1e-12);
// And it composes, which is the point of exposing it as a function.
try testing.expectApproxEqAbs(
@as(f64, 19.5618),
try testEval("apy(compound_rate(1000, fv(1000, 18, 5, 12), 5, 12), 12)"),
1e-4,
);
try testing.expectError(Error.DomainError, testEval("apy(5, 0)"));
}
test "financial: the tvm solvers are reachable as four-argument functions" {
// 200,000 at 0.5% a period over 360 periods: the classic mortgage payment.
try testing.expectApproxEqAbs(
@as(f64, -1199.10105),
try testEval("tvm_pmt(360, 0.5, 200000, 0)"),
1e-5,
);
try testing.expectApproxEqAbs(@as(f64, 7.1773462), try testEval("tvm_rate(10, -1000, 0, 2000)"), 1e-6);
try testing.expectApproxEqAbs(@as(f64, 10.244768), try testEval("tvm_n(7, -1000, 0, 2000)"), 1e-6);
try testing.expectApproxEqAbs(@as(f64, 1257.789254), try testEval("tvm_fv(10, 5, 0, -100)"), 1e-6);
try testing.expectApproxEqAbs(@as(f64, -1016.698584), try testEval("tvm_pv(10, 7, 0, 2000)"), 1e-6);
}
test "financial: amortization rows are reachable from an expression" {
try testing.expectEqual(@as(f64, 1199.10), try testEval("amort_payment(200000, 0.5, 360)"));
// Month one of a 6% loan on 200,000 is exactly 1000 of interest.
try testing.expectEqual(@as(f64, 1000.0), try testEval("amort_interest(200000, 0.5, 360, 1)"));
try testing.expectApproxEqAbs(@as(f64, 199.10), try testEval("amort_principal(200000, 0.5, 360, 1)"), 1e-9);
try testing.expectApproxEqAbs(@as(f64, 199800.90), try testEval("amort_balance(200000, 0.5, 360, 1)"), 1e-9);
try testing.expectEqual(@as(f64, 0.0), try testEval("amort_balance(200000, 0.5, 360, 360)"));
}
test "financial: amortization totals" {
const interest = try testEval("amort_total_interest(200000, 0.5, 360)");
const paid = try testEval("amort_total_paid(200000, 0.5, 360)");
try testing.expect(interest > 231000 and interest < 232000);
try testing.expectApproxEqAbs(paid - interest, 200000, 0.05);
}
test "financial: results are inexact, so they do not claim exactness" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const alloc = arena.allocator();
var env = Environment.init(alloc);
defer env.deinit();
const result = try evalString(&env, alloc, "cagr(1000, 2000, 10)");
try testing.expect(result != .exact);
}
test "financial: bad arguments are domain errors, not wrong answers" {
// Zero periods.
try testing.expectError(Error.DomainError, testEval("cagr(1000, 2000, 0)"));
// A fractional period count cannot index an amortization schedule.
try testing.expectError(Error.DomainError, testEval("amort_interest(200000, 0.5, 360.5, 1)"));
// Period past the end of the schedule.
try testing.expectError(Error.DomainError, testEval("amort_balance(200000, 0.5, 360, 361)"));
// Payments that never retire the loan.
try testing.expectError(Error.DomainError, testEval("amort_payment(0, 0.5, 360)"));
// Period counts outside the schedule bounds.
try testing.expectError(Error.DomainError, testEval("amort_payment(200000, 0.5, 0)"));
try testing.expectError(Error.DomainError, testEval("amort_payment(200000, 0.5, 20000)"));
try testing.expectError(Error.DomainError, testEval("amort_payment(200000, 0.5, 10^400)"));
}
test "a known function with the wrong argument count says so" {
// These used to be reported as "unknown function", which sent the user looking
// for a typo in a name that was spelled correctly. The name and the arity are
// separate checks now, and the arity comes from one table.
try testing.expectError(Error.WrongArgumentCount, testEval("cagr(10000, 25000)"));
try testing.expectError(Error.WrongArgumentCount, testEval("tvm_pmt(360, 0.5, 200000)"));
try testing.expectError(Error.WrongArgumentCount, testEval("amort_payment(200000, 0.5, 360, 1)"));
try testing.expectError(Error.WrongArgumentCount, testEval("sqrt(4, 9)"));
try testing.expectError(Error.WrongArgumentCount, testEval("max(3)"));
try testing.expectError(Error.WrongArgumentCount, testEval("sin()"));
try testing.expectError(Error.WrongArgumentCount, testEval("rand(1)"));
// log is the one built-in that takes either count, so both are fine and three
// is not.
try testing.expectEqual(@as(f64, 2), try testEval("log(100)"));
try testing.expectEqual(@as(f64, 2), try testEval("log(100, 10)"));
try testing.expectError(Error.WrongArgumentCount, testEval("log(100, 10, 1)"));
// A name that is not a function at all is still an unknown function, at any
// argument count.
try testing.expectError(Error.UnknownFunction, testEval("nope(1, 2, 3)"));
try testing.expectError(Error.UnknownFunction, testEval("nope(1, 2, 3, 4)"));
try testing.expectError(Error.UnknownFunction, testEval("nope()"));
}
test "every built-in is reachable by name at its own arity" {
// Walks the enum, so a built-in added without a `floatBuiltin` or
// `exactBuiltin` arm cannot pass unnoticed, and neither can one whose arity
// table entry disagrees with what the implementation reads.
inline for (@typeInfo(Builtin).@"enum".fields) |field| {
const builtin = @field(Builtin, field.name);
const arity = arityOf(builtin);
// A call with one argument too many is always an arity error, never an
// unknown function: proof the name resolved.
var source = std.ArrayList(u8).empty;
defer source.deinit(testing.allocator);
try source.appendSlice(testing.allocator, field.name);
try source.append(testing.allocator, '(');
for (0..arity.max + 1) |i| {
if (i > 0) try source.appendSlice(testing.allocator, ", ");
try source.append(testing.allocator, '1');
}
try source.append(testing.allocator, ')');
try testing.expectError(Error.WrongArgumentCount, testEval(source.items));
}
}
test "the help table's financial functions dispatch at the arity they advertise" {
// `financial.expression_functions` is what both frontends' help screens render.
// The names and signatures there are only correct if the evaluator agrees, and
// nothing else checks it: `Builtin` is private, so this is the one place the two
// can be compared.
for (financial.expression_functions) |doc| {
const builtin = std.meta.stringToEnum(Builtin, doc.name) orelse {
std.debug.print("help table lists '{s}', which no built-in answers\n", .{doc.name});
return error.UndocumentedFunction;
};
const arity = arityOf(builtin);
try testing.expectEqual(arity.min, doc.requiredArgs());
try testing.expectEqual(arity.max, doc.maxArgs());
}
}
// -- The AST is not the caller's problem --
//
// These use testing.allocator directly rather than testEval's arena, because an
// arena hides exactly the bug they guard against: evalStringInfo used to leak the
// whole parsed tree on every call, which an arena silently absorbs.
test "no leak: a successful evaluation releases the parsed tree" {
var env = Environment.init(testing.allocator);
defer env.deinit();
const sources = [_][]const u8{
"1 + 2 * 3",
"-(4 + 5)",
"max(1, min(2, 3))",
"sqrt(2) + factorial(10)",
"X = 7 * 6",
"X + 1",
"cagr(10000, 25000, 5) * 100",
"amort_interest(200000, 0.5, 360, 1)",
"0xFF and 0x0F",
};
for (sources) |source| {
var value = try evalString(&env, testing.allocator, source);
value.deinit();
}
}
test "no leak: a failed evaluation releases the parsed tree" {
var env = Environment.init(testing.allocator);
defer env.deinit();
const sources = [_][]const u8{
"2 +",
"(1 + 2",
"max(1, 2",
"1 / 0",
"unknownfn(1)",
"undefined_variable + 1",
"sqrt(-1)",
"cagr(1000, 2000, 0)",
};
for (sources) |source| {
if (evalString(&env, testing.allocator, source)) |value| {
var owned = value;
owned.deinit();
std.debug.print("expected an error for \"{s}\"\n", .{source});
return error.TestUnexpectedResult;
} else |_| {}
}
}
test "no leak: repeated evaluation does not accumulate" {
// A long interactive session is the case that made this visible: the TUI
// evaluates on every Enter and never frees anything itself.
var env = Environment.init(testing.allocator);
defer env.deinit();
var i: usize = 0;
while (i < 200) : (i += 1) {
var value = try evalString(&env, testing.allocator, "Ans + 1");
value.deinit();
}
}
// -- Function arguments versus grouped digits --
//
// The tokenizer used to treat any comma followed by a digit as a thousands
// separator, so a call written without spaces silently became a call with one
// merged argument. These are the end-to-end cases: what a user types, and what
// they get.
test "function arguments survive a comma with no space after it" {
// log(100, 10) is 2. The old rule lexed this as log(10010) and returned
// 4.0004, which is a wrong answer rather than an error.
try testing.expectApproxEqAbs(@as(f64, 2.0), try testEval("log(100,10)"), 1e-12);
try testing.expectEqual(@as(f64, 2.0), try testEval("max(1,2)"));
try testing.expectEqual(@as(f64, 1.0), try testEval("min(1,2)"));
try testing.expectApproxEqAbs(@as(f64, 0.2011244), try testEval("cagr(10000,25000,5)"), 1e-7);
try testing.expectApproxEqAbs(
@as(f64, -1199.10105),
try testEval("tvm_pmt(360,0.5,200000,0)"),
1e-5,
);
// Whitespace must not change the meaning.
try testing.expectEqual(try testEval("max(1, 2)"), try testEval("max(1,2)"));
try testing.expectEqual(try testEval("log(100, 10)"), try testEval("log(100,10)"));
}
test "grouped digits still work, inside and outside a call" {
try testing.expectEqual(@as(f64, 2000.0), try testEval("1,000 * 2"));
try testing.expectEqual(@as(f64, 1234568.0), try testEval("1,234,567 + 1"));
// A grouped argument is one argument.
try testing.expectEqual(@as(f64, 1000.0), try testEval("max(1,000, 500)"));
try testing.expectEqual(@as(f64, 1500.0), try testEval("min(1,500, 2,000)"));
}
test "a malformed group is an error, not a silently merged number" {
// Two digits after the comma is neither a group nor a valid argument list
// here, so it fails loudly instead of evaluating as 100.
try testing.expectError(Error.UnexpectedToken, testEval("1,00"));
try testing.expectError(Error.UnexpectedToken, testEval("1,0000"));
try testing.expectError(Error.UnexpectedToken, testEval("2+3,4"));
}
test "a grouped literal past 2^53 is still exact" {
// The separators have to reach the exact re-parse, not just the f64 channel.
var env = Environment.init(testing.allocator);
defer env.deinit();
var value = try evalString(&env, testing.allocator, "9,007,199,254,740,993");
defer value.deinit();
try testing.expect(value == .exact);
const shown = try value.render(testing.allocator, .{
.fraction_digits = 20,
.scientific_below_exponent = 15,
.max_integer_digits = null,
.significant_digits = 17,
.separators = false,
});
defer shown.deinit(testing.allocator);
try testing.expectEqualStrings("9007199254740993", shown.text);
}
// -- Operands that do not fit a machine word --
//
// The bitwise operators project through f64 and then convert to an integer. That
// conversion used to be unchecked, so ordinary input aborted the process:
// `2^64 and 1` and `~1e30` both died with "integer part of floating point value
// out of bounds", and a NaN operand produced a garbage number instead.
test "bitwise operands outside i64 report overflow instead of aborting" {
const cases = [_][]const u8{
"2^64 and 1",
"1e30 and 1",
"1e30 or 1",
"1e30 xor 1",
"~1e30",
"~(2^1000)",
"1e30 rol 1",
"1e30 ror 1",
"1e30 << 1",
"1e30 >> 1",
"1 << 1e30",
"1 rol 1e30",
"0 - 1e30 and 1",
};
for (cases) |source| {
const result = testEval(source);
try testing.expectError(Error.Overflow, result);
}
}
test "a non-finite bitwise operand is a domain error" {
// ln(-1) is NaN, and 1/0 raises before it can reach here, so NaN arrives via
// the transcendental fallback.
try testing.expectError(Error.DomainError, testEval("~ln(-1)"));
try testing.expectError(Error.DomainError, testEval("ln(-1) and 1"));
try testing.expectError(Error.DomainError, testEval("1 << ln(-1)"));
}
test "bitwise operators still work at the edges of the range" {
// Just inside i64: 2^63 - 1 as a float is 2^63, so use 2^62 for a clean case.
try testing.expectEqual(@as(f64, 0.0), try testEval("2^62 and 1"));
try testing.expectEqual(@as(f64, 15.0), try testEval("0xFF and 0x0F"));
try testing.expectEqual(@as(f64, 8.0), try testEval("1 << 3"));
try testing.expectEqual(@as(f64, -1.0), try testEval("~0"));
// Negative operands are fine; they are two's complement bit patterns.
try testing.expectEqual(@as(f64, -2.0), try testEval("~1"));
}
// -- Built-in names are not assignment targets --
test "assigning to a constant is an error, not a silent no-op" {
// `getVar` answers these before the variable map, so storing one wrote to a slot
// nothing would ever read: `pi = 3` returned 3 and left pi alone (open item 10).
for ([_][]const u8{ "pi = 3", "e = 1", "tau = 0", "Ans = 5", "ans = 5" }) |source| {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const alloc = arena.allocator();
var env = Environment.init(alloc);
defer env.deinit();
try testing.expectError(Error.AssignmentToConstant, evalString(&env, alloc, source));
}
}
test "a constant keeps its value, and a variable of another name still assigns" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const alloc = arena.allocator();
var env = Environment.init(alloc);
defer env.deinit();
try testing.expectError(Error.AssignmentToConstant, evalString(&env, alloc, "pi = 3"));
var still_pi = try evalString(&env, alloc, "pi");
defer still_pi.deinit();
try testing.expectApproxEqAbs(math.pi, still_pi.toFloat(alloc), 1e-15);
var assigned = try evalString(&env, alloc, "radius = 3");
defer assigned.deinit();
try testing.expectEqual(@as(f64, 3), assigned.toFloat(alloc));
}
test "isBuiltIn covers exactly the names getVar answers itself" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var env = Environment.init(arena.allocator());
defer env.deinit();
for ([_][]const u8{ "pi", "e", "tau", "Ans", "ans" }) |name| {
try testing.expect(Environment.isBuiltIn(name));
try testing.expect(env.getVar(name) != null);
}
for ([_][]const u8{ "x", "PI", "Pi", "answer", "tauon" }) |name| {
try testing.expect(!Environment.isBuiltIn(name));
try testing.expect(env.getVar(name) == null);
}
}
// -- The fixed-width operators keep exact operands exact --
//
// Both ends of the projection used to go through f64: an exact integer operand was
// rounded on the way in, and the i64 result was widened on the way out. Past 2^53
// that loses the answer outright.
test "a fixed-width result above 2^53 is exact, not a rounded float" {
// 2^62 or 1 = 4611686018427387905, which no f64 holds. It came out as
// 4.611686018427388e18.
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const alloc = arena.allocator();
var env = Environment.init(alloc);
defer env.deinit();
var result = try evalString(&env, alloc, "2^62 or 1");
defer result.deinit();
try testing.expect(result == .exact);
try testing.expectEqual(@as(?i128, 4611686018427387905), result.asExactInt(i128));
// The operand side of the same problem: 2^53 + 1 is not representable as an
// f64, so masking it with -1 used to answer 2^53.
var operand = try evalString(&env, alloc, "(2^53 + 1) and -1");
defer operand.deinit();
try testing.expect(operand == .exact);
try testing.expectEqual(@as(?i128, 9007199254740993), operand.asExactInt(i128));
}
test "an operand with no exact integer form keeps the result inexact" {
// Contagion: a rounded operand cannot produce an exact result, whatever the
// operator does with it.
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const alloc = arena.allocator();
var env = Environment.init(alloc);
defer env.deinit();
for ([_][]const u8{ "0.5 and 1", "pi and 1", "1.5 << 2", "~2.5" }) |source| {
var result = try evalString(&env, alloc, source);
defer result.deinit();
try testing.expect(result != .exact);
}
// And an exact fraction is not an exact integer, so it takes the float path too.
var third = try evalString(&env, alloc, "(1/3) or 0");
defer third.deinit();
try testing.expect(third != .exact);
}
test "the width bounds still hold on the exact path" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const alloc = arena.allocator();
var env = Environment.init(alloc);
defer env.deinit();
// 2^64 has an exact integer form, but not one that fits the 64-bit width, so it
// falls to the f64 path and is reported as an overflow rather than truncated.
try testing.expectError(Error.Overflow, evalString(&env, alloc, "2^64 and 1"));
try testing.expectError(Error.Overflow, evalString(&env, alloc, "~1e30"));
// The last value the width holds, and the first it does not.
var max = try evalString(&env, alloc, "(2^63 - 1) and -1");
defer max.deinit();
try testing.expectEqual(@as(?i128, 9223372036854775807), max.asExactInt(i128));
try testing.expectError(Error.Overflow, evalString(&env, alloc, "2^63 and -1"));
}