human review: evaluator.zig
This commit is contained in:
parent
f2b61f4da0
commit
c6505570e3
4 changed files with 637 additions and 262 deletions
|
|
@ -14,8 +14,9 @@ A calculator application with three frontends (CLI, TUI, Android) sharing a comm
|
|||
- **FR-1.2**: Support operators: `+`, `-`, `*`, `/`, `%` (modulo), `^` (power), unary `-`.
|
||||
- **FR-1.3**: Support parentheses for grouping.
|
||||
- **FR-1.4**: Support built-in functions: `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `log` (base-10), `ln` (natural), `sqrt`, `cbrt`, `abs`, `ceil`, `floor`, `round`, `factorial`. An argument outside a function's domain is reported as such, distinct from an unknown name: `asin(2)`, `ln(0)`, `log2(0)` and `factorial(-1)` report a domain error, `sqrt(-1)` reports "square root of a negative number", and only an unrecognized name reports an unknown function.
|
||||
- **FR-1.5**: Support constants: `pi`, `e`, `tau`.
|
||||
- **FR-1.6**: Support variable storage: `Ans` for the last result, plus any identifier as a named variable. (The original wording restricted this to `A-F, X, Y, Z`; the implementation accepts any name, which is a superset and the better behaviour, so the requirement follows the code.) Assignment to a constant name (`pi`, `e`, `tau`, `Ans`) is currently accepted and then ignored, which is a known defect rather than intended behaviour.
|
||||
- **FR-1.5**: Support constants: `pi`, `e`, `tau`. These are fixed values, readable and not assignable.
|
||||
- **FR-1.6**: Support variable storage: any identifier as a named variable. (The original wording restricted this to `A-F, X, Y, Z`; the implementation accepts any name, which is a superset and the better behaviour, so the requirement follows the code.) A constant is not an assignment target: `pi = 3` reports an error rather than accepting the assignment and ignoring it.
|
||||
- **FR-1.6.1**: The last answer is a **pseudovariable**, not a constant: `Ans` (also `ans`) reads the result of the previous evaluation, the engine updates it on every successful evaluation, and the user cannot assign to it. It is a distinct concept from FR-1.5's constants, which never change, and from FR-1.6's variables, which only the user writes. See tasks.md open item 16: the engine currently folds `Ans` in with the constants and reports `AssignmentToConstant` ("cannot assign to a built-in constant") for `Ans = 5`, which misnames it.
|
||||
- **FR-1.7**: Maintain calculation history with replay capability.
|
||||
- **FR-1.8**: Commas accepted as digit separators in input, but only in thousands groups: a comma must be followed by exactly three digits (`1,000 * 2` is 2000, `1,234,567` is one number). A comma followed by any other number of digits is an argument separator, which is what makes `log(100,10)` two arguments rather than the number 10010. The ambiguous case `max(1,234)` resolves in favour of the grouping and reads as `max(1234)`; write a space to mean two arguments. A malformed group such as `1,00` is an error rather than a silently merged number. Spaces and underscores group hex/octal/binary literals (`0xFF FF`, `0xFF_FF`); commas do not.
|
||||
- **FR-1.9**: When a standard-mode expression contains any non-decimal literal (hex `0x`, octal `0o`, or binary `0b`) and the result is a non-negative integer, enrich the result display with hex/octal/binary representations inline (without leaving standard mode). Uses the smallest standard bit width (8/16/32/64/128) that holds the value. This does not change the evaluation semantics (still f64 arithmetic, `^` is still power); it only augments the display. Fractional or negative results show decimal only.
|
||||
|
|
|
|||
|
|
@ -914,6 +914,102 @@ plans a `--raw` flag, but today it is exercised only by tests.
|
|||
100% line coverage, engine 99.44%. CLI output byte-identical across all five rows,
|
||||
both byte orders, ASCII packing and the multi-base standard-mode view.
|
||||
|
||||
### Task 5.21: One table of built-in functions
|
||||
|
||||
`evalFunction` dispatched through a chain of `if (mem.eql(u8, name, ...))` blocks
|
||||
grouped by argument count, spilling into `evalSingleArgFn` and `evalFinancialFn`,
|
||||
both of which returned `Error!?f64` where null meant "not my name". Four consequences:
|
||||
|
||||
- **A wrong argument count was reported as a wrong name.** `log(2)` matched nothing in
|
||||
the one-argument group, fell through to `evalSingleArgFn`, which does not know
|
||||
`log` either, and came out as "unknown function" about a function that exists. Same
|
||||
for `max(3)`, `sqrt(4, 9)`, `cagr(1, 2)` and every three-argument call to a
|
||||
four-argument TVM function. One test asserted this behaviour and was named after
|
||||
it.
|
||||
- Adding a function meant choosing between three places and knowing why.
|
||||
- Nothing could enumerate the built-ins, so `src/tui/help.zig`, `main.zig`'s help
|
||||
text and FR-5.7 each list them by hand with nothing checking that the three agree.
|
||||
- The dispatch and the domain checks were entangled: a caller could not ask whether a
|
||||
name existed without also running it.
|
||||
|
||||
There is now a `Builtin` enum whose members *are* the names, so
|
||||
`std.meta.stringToEnum` is the lookup and no string list exists to drift. `arityOf`
|
||||
gives each one its `{ min, max }`, checked before any argument is evaluated, so a
|
||||
misspelled name is `UnknownFunction` and a miscounted call is `WrongArgumentCount`
|
||||
("wrong number of arguments"). `log` is the one built-in whose min and max differ for
|
||||
overloading rather than an optional argument: `log(x)` is log10 and `log(x, base)` is
|
||||
the general form.
|
||||
|
||||
The implementation split follows the exactness boundary rather than the argument
|
||||
count: `exactBuiltin` handles the eight that keep an exact operand exact (`abs`,
|
||||
`floor`, `ceil`, `round`, `sqrt`, `factorial`, `max`, `min`) and takes `Number`
|
||||
operands; `floatBuiltin` handles the rest over operands collapsed once to f64, which
|
||||
is honest because every one of them escapes the rationals by definition (design.md
|
||||
2.7.4). `floatBuiltin`'s switch names the exact group in its `unreachable` arm, so a
|
||||
built-in added to the enum without being classified fails to compile.
|
||||
|
||||
**The table exposed three functions with no tests.** `tan`, `atan` and `cbrt` were
|
||||
never exercised, and the old shape hid it: `mem.eql(u8, name, "tan")` ran on every
|
||||
single-argument call, so coverage counted the line even though `@tan` never executed.
|
||||
One switch arm per function gave each its own line, the gap appeared, and the tests
|
||||
are now there.
|
||||
|
||||
A test walks the enum and calls every built-in with one argument too many, so a
|
||||
member whose arity entry disagrees with what its implementation reads cannot pass
|
||||
unnoticed.
|
||||
|
||||
### Task 5.20: Exact operands survive the fixed-width operators
|
||||
|
||||
|
||||
Three findings from the `evaluator.zig` review.
|
||||
|
||||
**`pub fn evaluate` was dead.** The last f64-returning expression API in the engine,
|
||||
superseded by `evalString` when Task 2.0c landed, and called by nothing once both
|
||||
frontends moved over. Its own doc comment said as much.
|
||||
|
||||
**A built-in name is no longer an assignment target.** `getVar` answers `pi`, `e`,
|
||||
`tau` and `Ans` before consulting the variable map, so `env.setVar("pi", 3)` wrote to
|
||||
a slot nothing would ever read again:
|
||||
|
||||
```
|
||||
$ tally 'pi = 3'
|
||||
3 <- and pi is still 3.14159...
|
||||
```
|
||||
|
||||
The constants are now one list that both `getVar` and a new `Environment.isBuiltIn`
|
||||
consult, so a name cannot be readable as a constant and writable as a variable at the
|
||||
same time, and `evalExact` rejects the assignment with `AssignmentToConstant`
|
||||
("cannot assign to a built-in constant"). Open item 10 is fully closed, and FR-1.6 no
|
||||
longer documents the defect as current behaviour.
|
||||
|
||||
`Ans` went into the same set, which closes the same hole for it but misnames it: it is
|
||||
a pseudovariable the engine rewrites on every evaluation, not a constant. Open item 16
|
||||
and FR-1.6.1 track separating the two concepts.
|
||||
|
||||
**The fixed-width operators no longer round exact operands.** Standard mode projects
|
||||
onto a 64-bit two's complement integer to run `& | xor << >> >>> rol ror ~`, and both
|
||||
ends of that projection went through f64:
|
||||
|
||||
```
|
||||
$ tally '2^62 or 1' 4.611686018427388e18 should be 4611686018427387905
|
||||
$ tally '(2^53 + 1) and -1' 9.007199254740992e15 should be 9007199254740993
|
||||
```
|
||||
|
||||
The first is the result side (an i64 answer widened to f64 loses everything past
|
||||
2^53), the second is the operand side (`2^53 + 1` has no f64 form). Both now take an
|
||||
exact path: `standardOperand` converts an operand that is already an exact integer
|
||||
directly with `Number.asExactInt`, and reports whether it managed it;
|
||||
`fromStandardInt` returns `Number.fromInt` when nothing was rounded and
|
||||
`Number.fromFloat` when something was.
|
||||
|
||||
That second half is what makes it correct rather than merely wider. `Number`'s
|
||||
contagion rule is that exact means no rounding anywhere in the value's history, so a
|
||||
result computed from an operand that had to go through f64 (`0.5 and 1`, `pi and 1`,
|
||||
`(1/3) or 0`) still comes back inexact. Returning `fromInt` unconditionally would have
|
||||
been an exactness claim the value had not earned. The width bounds are unchanged:
|
||||
`2^64 and 1` and `2^63 and -1` are overflows, not truncations, because an exact
|
||||
integer that does not fit the width falls to the f64 path and fails there.
|
||||
|
||||
### Task 5.19: One operator table; assignment is a statement; Parser.zig
|
||||
|
||||
`parser.zig` became `Parser.zig`, file-as-struct, since everything in it serves the
|
||||
|
|
@ -1307,7 +1403,7 @@ STILL OPEN, in the order I would take them:
|
|||
9. ~~Money formatting degrades to `?` and still exits 0 at large magnitudes.~~
|
||||
Fixed by the de-duplication pass above.
|
||||
10. ~~Assignment parses in prefix position (`1 + x = 2` mutates `x`)~~, fixed by Task
|
||||
5.19; assignment to a constant name is still silently discarded.
|
||||
5.19; ~~assignment to a constant name is silently discarded~~, fixed by Task 5.20.
|
||||
11. ~~Literals longer than 128 characters are rejected by a fixed tokenizer buffer,
|
||||
and non-decimal literals are capped at 64 bits, both below what the exact tier
|
||||
supports.~~ Fixed by Task 5.18.
|
||||
|
|
@ -1330,6 +1426,24 @@ STILL OPEN, in the order I would take them:
|
|||
caller passes, not a default the C layer invents, or Android inherits a budget
|
||||
chosen for an 80-column terminal (Task 5.17). Task 6.2 covers the bridge; this
|
||||
is the display half of it.
|
||||
16. **The last answer should be a pseudovariable in its own right** (FR-1.6.1), and
|
||||
the engine does not model it as one. Task 5.20 stopped `Ans = 5` from being
|
||||
silently discarded, but it did so by folding `Ans` in with `pi`, `e` and `tau`
|
||||
behind one `Environment.isBuiltIn`, and the error it raises is
|
||||
`AssignmentToConstant`, phrased "cannot assign to a built-in constant". `Ans` is
|
||||
not a constant: it changes on every evaluation. Three things follow from
|
||||
separating the concepts, none of them done:
|
||||
|
||||
- The name and the message. A pseudovariable that the engine writes and the user
|
||||
only reads wants its own error (or a shared one worded to cover both), so
|
||||
`Ans = 5` does not claim `Ans` is constant.
|
||||
- The read path. `getVar` special-cases the two spellings inline, ahead of the
|
||||
variable map, next to the constant table. A pseudovariable is a third kind of
|
||||
name and reads as one only if it is declared as one.
|
||||
- What else belongs in the set. If the last answer is a pseudovariable, earlier
|
||||
answers are the obvious next question, and FR-1.7's history is the thing that
|
||||
already holds them. Nothing has been decided about naming or depth, and this
|
||||
item is not a commitment to any of it.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -80,7 +80,9 @@ pub fn phrase(err: Error) []const u8 {
|
|||
|
||||
// Names
|
||||
error.UnknownFunction => "unknown function",
|
||||
error.WrongArgumentCount => "wrong number of arguments",
|
||||
error.UnknownVariable => "unknown variable",
|
||||
error.AssignmentToConstant => "cannot assign to a built-in constant",
|
||||
|
||||
// Arithmetic
|
||||
error.DivisionByZero => "division by zero",
|
||||
|
|
|
|||
|
|
@ -20,7 +20,12 @@ const Integer = @import("Integer.zig");
|
|||
/// 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;
|
||||
|
|
@ -30,6 +35,30 @@ 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
|
||||
|
|
@ -102,18 +131,19 @@ pub const Environment = struct {
|
|||
/// 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.
|
||||
///
|
||||
/// The constants are inexact by nature: pi, e and tau are irrational and
|
||||
/// have no rational representation.
|
||||
pub fn getVar(self: *const Environment, name: []const u8) ?Number {
|
||||
if (std.mem.eql(u8, name, "pi")) return Number.fromFloat(math.pi);
|
||||
if (std.mem.eql(u8, name, "e")) return Number.fromFloat(math.e);
|
||||
if (std.mem.eql(u8, name, "tau")) return Number.fromFloat(math.tau);
|
||||
if (std.mem.eql(u8, name, "Ans") or std.mem.eql(u8, name, "ans")) return self.ans;
|
||||
|
||||
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);
|
||||
|
|
@ -121,30 +151,14 @@ 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) Error!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).
|
||||
///
|
||||
/// 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),
|
||||
|
|
@ -165,8 +179,13 @@ fn evalExact(env: *Environment, scratch: Allocator, expr: *const Expr) Error!Num
|
|||
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.
|
||||
// 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;
|
||||
},
|
||||
|
|
@ -181,7 +200,12 @@ fn evalExact(env: *Environment, scratch: Allocator, expr: *const Expr) Error!Num
|
|||
// 255 in standard mode while the shifts alongside it ignored the
|
||||
// setting entirely.
|
||||
.bitwise_not => blk: {
|
||||
break :blk fromStandardInt(bitwise.not(try standardInt(operand.toFloat(scratch))));
|
||||
const projected = try standardOperand(scratch, operand);
|
||||
break :blk try fromStandardInt(
|
||||
scratch,
|
||||
bitwise.not(projected.value),
|
||||
projected.lossless,
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
|
@ -227,15 +251,27 @@ fn evalBinaryOp(scratch: Allocator, op: BinaryOp, left: Number, right: Number) E
|
|||
// 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 standardInt(left.toFloat(scratch));
|
||||
const r = try standardInt(right.toFloat(scratch));
|
||||
const result = try bitwise.apply(comptime bitwise.fromBinaryOp(fixed_op).?, l, r);
|
||||
break :blk fromStandardInt(result);
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// Project a float onto standard mode's integer type: 64-bit two's complement,
|
||||
/// 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
|
||||
|
|
@ -247,6 +283,16 @@ fn evalBinaryOp(scratch: Allocator, op: BinaryOp, left: Number, right: Number) E
|
|||
/// 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
|
||||
|
|
@ -258,101 +304,283 @@ fn standardInt(value: f64) Error!Integer {
|
|||
return .{ .raw = @as(u128, bits) };
|
||||
}
|
||||
|
||||
/// Read a result back as a number, signed, since standard mode is signed.
|
||||
fn fromStandardInt(value: Integer) Number {
|
||||
/// 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 {
|
||||
// Single-argument functions
|
||||
if (args.len == 1) {
|
||||
const x = try evalExact(env, scratch, args[0]);
|
||||
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;
|
||||
|
||||
// Functions with an exact implementation.
|
||||
if (std.mem.eql(u8, name, "abs")) {
|
||||
return try Number.abs(scratch, x);
|
||||
}
|
||||
if (std.mem.eql(u8, name, "floor")) {
|
||||
return try Number.floor(scratch, x);
|
||||
}
|
||||
if (std.mem.eql(u8, name, "ceil")) {
|
||||
return try Number.ceil(scratch, x);
|
||||
}
|
||||
if (std.mem.eql(u8, name, "round")) {
|
||||
return try Number.round(scratch, x);
|
||||
}
|
||||
if (std.mem.eql(u8, name, "sqrt")) {
|
||||
// The negative-input rule lives in Number.sqrt, which raises
|
||||
// NegativeRoot. That name now reaches the user instead of being
|
||||
// flattened into "domain error".
|
||||
return try Number.sqrt(scratch, x);
|
||||
}
|
||||
if (std.mem.eql(u8, name, "factorial")) {
|
||||
const result = try Number.factorial(scratch, x);
|
||||
// Null means the argument was negative or fractional, which is a domain
|
||||
// error, not an unknown function.
|
||||
return result orelse Error.DomainError;
|
||||
}
|
||||
|
||||
// Everything else escapes the rationals, so it falls back to f64.
|
||||
const f = try evalSingleArgFn(name, x.toFloat(scratch)) orelse
|
||||
return Error.UnknownFunction;
|
||||
return Number.fromFloat(f);
|
||||
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 => {},
|
||||
}
|
||||
|
||||
// Multi-argument functions
|
||||
if (args.len == 2) {
|
||||
const a = try evalExact(env, scratch, args[0]);
|
||||
const b = try evalExact(env, scratch, args[1]);
|
||||
// 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]));
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, name, "max")) {
|
||||
return try Number.max(scratch, a, b);
|
||||
}
|
||||
if (std.mem.eql(u8, name, "min")) {
|
||||
return try Number.min(scratch, a, b);
|
||||
}
|
||||
/// 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,
|
||||
};
|
||||
}
|
||||
|
||||
const x = a.toFloat(scratch);
|
||||
const y = b.toFloat(scratch);
|
||||
if (std.mem.eql(u8, name, "atan2")) return Number.fromFloat(math.atan2(x, y));
|
||||
/// 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.
|
||||
if (std.mem.eql(u8, name, "apy")) {
|
||||
return Number.fromFloat(try financial.effectiveAnnualRate(x, y));
|
||||
}
|
||||
if (std.mem.eql(u8, name, "log")) {
|
||||
// log(value, base)
|
||||
if (y <= 0 or y == 1 or x <= 0) return Error.DomainError;
|
||||
return Number.fromFloat(@log(x) / @log(y));
|
||||
}
|
||||
}
|
||||
.apy => financial.effectiveAnnualRate(a[0], a[1]),
|
||||
|
||||
// Financial functions take three or four arguments.
|
||||
//
|
||||
// These are inexact by construction: every financial formula needs a
|
||||
// non-integer power or a logarithm, so the exact tier has nothing to
|
||||
// preserve (see the header of financial.zig).
|
||||
if (args.len == 3 or args.len == 4) {
|
||||
var values: [4]f64 = undefined;
|
||||
for (args, 0..) |arg, i| {
|
||||
const value = try evalExact(env, scratch, arg);
|
||||
values[i] = value.toFloat(scratch);
|
||||
}
|
||||
if (try evalFinancialFn(name, values[0..args.len])) |result| {
|
||||
return Number.fromFloat(result);
|
||||
}
|
||||
}
|
||||
// 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),
|
||||
|
||||
// Zero-argument functions
|
||||
if (args.len == 0) {
|
||||
if (std.mem.eql(u8, name, "rand")) {
|
||||
// Not truly random in a pure engine, but useful as placeholder
|
||||
return Number.fromFloat(0.0);
|
||||
}
|
||||
}
|
||||
// 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,
|
||||
|
||||
return Error.UnknownFunction;
|
||||
// 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.
|
||||
|
|
@ -365,143 +593,6 @@ fn periodCount(value: f64) Error!usize {
|
|||
return @intFromFloat(value);
|
||||
}
|
||||
|
||||
/// Financial functions callable from a standard-mode expression.
|
||||
///
|
||||
/// Exposed as functions rather than only as a separate mode so that they compose
|
||||
/// with the rest of the language: `cagr(10000, 25000, 5) * 100` and
|
||||
/// `amort_interest(200000, 0.5, 360, 1) + 50` both work, the same way unit
|
||||
/// conversion is reachable from a bare expression.
|
||||
///
|
||||
/// Returns null when `name` is not a financial function, so the caller can carry
|
||||
/// on to report an unknown function.
|
||||
fn evalFinancialFn(name: []const u8, a: []const f64) Error!?f64 {
|
||||
if (a.len == 3) {
|
||||
// cagr(start, end, periods) -> growth rate as a fraction.
|
||||
if (std.mem.eql(u8, name, "cagr")) return try financial.cagr(a[0], a[1], a[2]);
|
||||
// fv(pv, annual_rate_percent, years) compounded annually.
|
||||
if (std.mem.eql(u8, name, "fv")) return try financial.compoundFutureValue(a[0], a[1], a[2], 1);
|
||||
// pv(fv, annual_rate_percent, years) compounded annually.
|
||||
if (std.mem.eql(u8, name, "pv")) return try financial.compoundPresentValue(a[0], a[1], a[2], 1);
|
||||
// The same relationship solved for its other two variables. The rate is
|
||||
// NOMINAL; use apy() to convert.
|
||||
if (std.mem.eql(u8, name, "compound_rate")) return try financial.compoundRate(a[0], a[1], a[2], 1);
|
||||
if (std.mem.eql(u8, name, "compound_years")) return try financial.compoundPeriods(a[0], a[1], a[2], 1);
|
||||
|
||||
// amort_payment(principal, rate_per_period_percent, periods)
|
||||
if (std.mem.eql(u8, name, "amort_payment")) {
|
||||
return try financial.amortizationPayment(.{
|
||||
.principal = a[0],
|
||||
.rate = a[1],
|
||||
.periods = try periodCount(a[2]),
|
||||
});
|
||||
}
|
||||
if (std.mem.eql(u8, name, "amort_total_interest")) {
|
||||
const totals = try financial.amortizationTotals(.{
|
||||
.principal = a[0],
|
||||
.rate = a[1],
|
||||
.periods = try periodCount(a[2]),
|
||||
});
|
||||
return totals.interest;
|
||||
}
|
||||
if (std.mem.eql(u8, name, "amort_total_paid")) {
|
||||
const totals = try financial.amortizationTotals(.{
|
||||
.principal = a[0],
|
||||
.rate = a[1],
|
||||
.periods = try periodCount(a[2]),
|
||||
});
|
||||
return totals.paid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// fv(pv, rate, years, compounds_per_year) and its inverse.
|
||||
if (std.mem.eql(u8, name, "fv")) return try financial.compoundFutureValue(a[0], a[1], a[2], a[3]);
|
||||
if (std.mem.eql(u8, name, "pv")) return try financial.compoundPresentValue(a[0], a[1], a[2], a[3]);
|
||||
// compound_rate(pv, fv, years, compounds_per_year) -> nominal annual rate.
|
||||
if (std.mem.eql(u8, name, "compound_rate")) return try financial.compoundRate(a[0], a[1], a[2], a[3]);
|
||||
// compound_years(pv, fv, rate, compounds_per_year)
|
||||
if (std.mem.eql(u8, name, "compound_years")) return try financial.compoundPeriods(a[0], a[1], a[2], a[3]);
|
||||
|
||||
// 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.
|
||||
if (std.mem.eql(u8, name, "tvm_fv")) {
|
||||
const s = try financial.solveTvm(.{ .periods = a[0], .rate = a[1], .present_value = a[2], .payment = a[3] });
|
||||
return s.value;
|
||||
}
|
||||
if (std.mem.eql(u8, name, "tvm_pv")) {
|
||||
const s = try financial.solveTvm(.{ .periods = a[0], .rate = a[1], .payment = a[2], .future_value = a[3] });
|
||||
return s.value;
|
||||
}
|
||||
if (std.mem.eql(u8, name, "tvm_pmt")) {
|
||||
const s = try financial.solveTvm(.{ .periods = a[0], .rate = a[1], .present_value = a[2], .future_value = a[3] });
|
||||
return s.value;
|
||||
}
|
||||
if (std.mem.eql(u8, name, "tvm_n")) {
|
||||
const s = try financial.solveTvm(.{ .rate = a[0], .present_value = a[1], .payment = a[2], .future_value = a[3] });
|
||||
return s.value;
|
||||
}
|
||||
if (std.mem.eql(u8, name, "tvm_rate")) {
|
||||
const s = try financial.solveTvm(.{ .periods = a[0], .present_value = a[1], .payment = a[2], .future_value = a[3] });
|
||||
return s.value;
|
||||
}
|
||||
|
||||
// Amortization rows: (principal, rate_per_period_percent, periods, period)
|
||||
const is_interest = std.mem.eql(u8, name, "amort_interest");
|
||||
const is_principal = std.mem.eql(u8, name, "amort_principal");
|
||||
const is_balance = std.mem.eql(u8, name, "amort_balance");
|
||||
if (is_interest or is_principal or is_balance) {
|
||||
const entry = try financial.amortizationEntry(.{
|
||||
.principal = a[0],
|
||||
.rate = a[1],
|
||||
.periods = try periodCount(a[2]),
|
||||
}, try periodCount(a[3]));
|
||||
if (is_interest) return entry.interest;
|
||||
if (is_principal) return entry.principal;
|
||||
return entry.balance;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Evaluate a single-argument built-in function that has no exact form.
|
||||
/// Evaluate a single-argument built-in that has no exact form.
|
||||
///
|
||||
/// Returns null when `name` is not one of these functions, and an error when the
|
||||
/// name is known but the argument is outside its domain. The two used to be the
|
||||
/// same answer (null), so the caller reported `asin(2)` as "unknown function".
|
||||
fn evalSingleArgFn(name: []const u8, x: f64) Error!?f64 {
|
||||
if (std.mem.eql(u8, name, "sin")) return @sin(x);
|
||||
if (std.mem.eql(u8, name, "cos")) return @cos(x);
|
||||
if (std.mem.eql(u8, name, "tan")) return @tan(x);
|
||||
if (std.mem.eql(u8, name, "asin")) {
|
||||
if (x < -1 or x > 1) return Error.DomainError;
|
||||
return math.asin(x);
|
||||
}
|
||||
if (std.mem.eql(u8, name, "acos")) {
|
||||
if (x < -1 or x > 1) return Error.DomainError;
|
||||
return math.acos(x);
|
||||
}
|
||||
if (std.mem.eql(u8, name, "atan")) return math.atan(x);
|
||||
// log/log10/ln/log2 of a non-positive value has no real result. The two-argument
|
||||
// log already reported this as a domain error; the one-argument forms returned
|
||||
// -inf or NaN.
|
||||
if (std.mem.eql(u8, name, "log") or std.mem.eql(u8, name, "log10")) {
|
||||
if (x <= 0) return Error.DomainError;
|
||||
return @log10(x);
|
||||
}
|
||||
if (std.mem.eql(u8, name, "ln")) {
|
||||
if (x <= 0) return Error.DomainError;
|
||||
return @log(x);
|
||||
}
|
||||
if (std.mem.eql(u8, name, "log2")) {
|
||||
if (x <= 0) return Error.DomainError;
|
||||
return @log2(x);
|
||||
}
|
||||
if (std.mem.eql(u8, name, "cbrt")) return math.cbrt(x);
|
||||
if (std.mem.eql(u8, name, "exp")) return @exp(x);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Result of evaluation with metadata for display decisions.
|
||||
pub const EvalInfo = struct {
|
||||
/// The computed value. Allocated with the allocator passed to
|
||||
|
|
@ -985,7 +1076,9 @@ test "domain errors are domain errors, not unknown functions" {
|
|||
|
||||
// A genuinely unknown name still reports one.
|
||||
try testing.expectError(Error.UnknownFunction, testEval("nope(1)"));
|
||||
try testing.expectError(Error.UnknownFunction, testEval("asin(1, 2)"));
|
||||
// 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" {
|
||||
|
|
@ -998,6 +1091,19 @@ test "the functions themselves still work inside their domains" {
|
|||
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);
|
||||
|
|
@ -1485,13 +1591,50 @@ test "financial: bad arguments are domain errors, not wrong answers" {
|
|||
try testing.expectError(Error.DomainError, testEval("amort_payment(200000, 0.5, 10^400)"));
|
||||
}
|
||||
|
||||
test "financial: wrong argument counts are unknown functions, not silent defaults" {
|
||||
try testing.expectError(Error.UnknownFunction, testEval("cagr(10000, 25000)"));
|
||||
try testing.expectError(Error.UnknownFunction, testEval("tvm_pmt(360, 0.5, 200000)"));
|
||||
try testing.expectError(Error.UnknownFunction, testEval("amort_payment(200000, 0.5, 360, 1)"));
|
||||
// A three or four argument call to something that is not a function at all.
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
// -- The AST is not the caller's problem --
|
||||
|
|
@ -1662,3 +1805,118 @@ test "bitwise operators still work at the edges of the range" {
|
|||
// 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"));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue