diff --git a/.kiro/specs/calculator/design.md b/.kiro/specs/calculator/design.md index ebed67b..b3f62de 100644 --- a/.kiro/specs/calculator/design.md +++ b/.kiro/specs/calculator/design.md @@ -61,10 +61,9 @@ build.zig (workspace root) | Module | Responsibility | |--------|---------------| -| `errors.zig` | `CalcError` and the one table of error wording | | `Integer.zig` | A fixed-width integer (file-as-struct), plus `IntType`, `BitWidth`, `Signedness` | -| `rational.zig` | Exact rationals over big integers | -| `number.zig` | The exact/inexact numeric model (section 2.7) | +| `Rational.zig` | Exact rationals over big integers (file-as-struct) | +| `number.zig` | The exact/inexact numeric model (section 2.7). Lowercase: `Number` is a tagged union, which a file-as-struct cannot express | | `tokenizer.zig` | Lexer, and `Base` for literals | | `ast.zig` | AST node definitions | | `parser.zig` | Pratt parser -> AST | @@ -75,14 +74,25 @@ build.zig (workspace root) | `float_interp.zig` | IEEE 754 bit-level interpretation | | `units.zig` | Unit conversion tables and resolver | | `financial.zig` | CAGR, TVM, compound interest, amortization | -| `engine.zig` | Public API surface (Zig-native). Imports and re-exports only; defines nothing | +| `message.zig` | Gone: the wording lives in `engine.zig` beside the `Error` union it words | +| `engine.zig` | Public API surface (Zig-native): the module re-exports, the `Error` union, and `phrase` | | `c_api.zig` | `extern "C"` wrappers for JNI/FFI consumers | -There is deliberately no `types.zig`. It existed, and being named after a language -feature rather than a concept, it accumulated four unrelated groups: the error -vocabulary, the fixed-width integer model, `Base` (used only by the lexer and the -AST), and `Mode` (which the engine stored and never read). Each has gone to the -module that owns it. `struct_layout.zig` is still unimplemented (Phase 4). +Every module declares its own error set; there is no shared one (section 10). + +There is deliberately no `types.zig` and no `errors.zig`. `types.zig` existed, and +being named after a language feature rather than a concept, it accumulated four +unrelated groups: the error vocabulary, the fixed-width integer model, `Base` (used +only by the lexer and the AST), and `Mode` (which the engine stored and never read). +`errors.zig` was where the error vocabulary landed, until the same question showed that +what needed splitting was the type inside it. Each piece has gone to the module that +owns it. `struct_layout.zig` is still unimplemented (Phase 4). + +A file is named TitleCase when the file *is* the type, with its fields at container +level: `Integer.zig` and `Rational.zig`. `number.zig` stays lowercase because `Number` +is a tagged union and a Zig file is always a struct, so the file cannot be that type +without wrapping the union in a field, which would add a hop at every use of the tag +that is the type's whole identity. ### 2.2 Core Data Types @@ -1969,58 +1979,74 @@ The JNI bridge sends expression strings down and receives JSON results back. Thi ## 10. Error Handling Strategy +Each module declares what it can fail with, and the sets compose: + ```zig -pub const CalcError = error{ - // Parser errors - UnexpectedToken, - UnmatchedParen, - InvalidNumber, - UnknownFunction, - UnknownVariable, - - // Evaluation errors - DivisionByZero, - Overflow, - InvalidOperandType, - DomainError, // e.g., sqrt(-1) - - // Struct layout errors - InvalidType, - InvalidFieldName, - DuplicateFieldName, - StructTooLarge, - - // Financial errors - InsufficientParameters, - ConvergenceFailure, // TVM Newton-Raphson didn't converge - - // System - OutOfMemory, +// parser.zig +pub const Error = error{ + UnexpectedToken, UnmatchedParen, UnexpectedEnd, + InvalidExpression, InvalidNumber, OutOfMemory, }; -pub const ErrorInfo = struct { - err: CalcError, - message: []const u8, - position: ?usize, // character position in input where error occurred - context: []const u8, // snippet of input around error +// Rational.zig, inherited by number.zig +pub const Error = error{ + OutOfMemory, DivisionByZero, InvalidNumber, + ExponentTooLarge, NegativeRoot, }; + +// bitwise.zig +pub const Error = error{DomainError}; + +// units.zig: its own two, plus whatever the exact path raises +pub const Error = error{ UnknownUnit, IncompatibleUnits, OutOfMemory } || number.Error; + +// financial.zig +pub const Error = error{ + InsufficientParameters, ConvergenceFailure, + DomainError, DivisionByZero, OutOfMemory, +}; + +// evaluator.zig: its own, plus every dependency's +pub const Error = error{ UnknownFunction, UnknownVariable, DomainError, Overflow } || + parser.Error || number.Error || bitwise.Error || financial.Error; + +// engine.zig: what any entry point can return, for frontends to switch over +pub const Error = evaluator.Error || programmer.Error || units.Error || financial.Error; ``` -NOT IMPLEMENTED, and removed: nothing ever constructed an `ErrorInfo`, and the -parser's `error_pos`/`had_error` fields that would have fed it were written on every -error path and never read. Adding position reporting means threading it through the -`CalcError` returns, which is worth doing deliberately rather than leaving a -half-built shape in the code. +This replaced one hand-written `CalcError` with 20 members that every engine function +claimed to return. That signature was false in both directions: `parser.parse` said it +might return `ConvergenceFailure` and `UnknownUnit`, so no caller could switch on what +a parse can actually produce, and four members (`InvalidType`, `InvalidFieldName`, +`DuplicateFieldName`, `StructTooLarge`) belonged to a struct layout module that does +not exist, so nothing could return them while `errorPhrase` still gave them wording. -The phrase for each error lives in exactly one place, `types.errorPhrase`, whose -switch has no `else`, so a new member of `CalcError` fails to compile until it is -given a phrase. Frontends decorate that phrase at comptime (`switch (err) { inline -else => ... }`): the CLI adds `error: ` and a newline, the TUI adds `error: `, and a -view with better context can override individual cases, as the financial form does -for `DomainError`. Each frontend used to carry its own copy of the whole table, and -the TUI's had drifted three errors behind. +Error members unify by name in Zig, so the per-module sets compose with no +coordination: `parser.Error.OutOfMemory` and `units.Error.OutOfMemory` are the same +value. The unions are written with `||` rather than enumerated, so the compiler +maintains them. -All engine functions return `CalcError!Result`. Frontends translate these into user-facing messages. +Deleting the single set also deleted the translation between tiers. `number.toCalcError` +existed to map the numeric model's errors into the engine-wide set, and what it mapped +away were the more useful names: `ExponentTooLarge` became `Overflow` and `NegativeRoot` +became `DomainError`. Now `sqrt(-1)` reports "square root of a negative number" and +`2^3000000` reports "the exponent is too large to compute". + +Errors carry no position or context. An `ErrorInfo` with a source position was +specified here and declared in the code, but nothing ever constructed one, and the +parser fields that would have fed it were written on every error path and never read. +Adding position reporting means threading it through the returns, which is worth doing +deliberately rather than leaving a half-built shape in place. + +The wording lives in exactly one place, `engine.phrase`, in the engine rather than a +frontend because every frontend needs the same words, including the Android app across +the C ABI. Its switch has no `else`, so an error added to any module's set fails to +compile until it is given a phrase, and a comptime block checks the other direction, so +it cannot carry wording for an error the engine is incapable of producing. Frontends +decorate at comptime (`switch (err) { inline else => ... }`): the CLI adds `error: ` and +a newline, the TUI adds `error: `, and a view with better context can override +individual cases, as the financial form does for `DomainError`. Each frontend used to +carry its own copy of the whole table, and the TUI's had drifted three errors behind. --- diff --git a/.kiro/specs/calculator/requirements.md b/.kiro/specs/calculator/requirements.md index dd5c2aa..77478bf 100644 --- a/.kiro/specs/calculator/requirements.md +++ b/.kiro/specs/calculator/requirements.md @@ -13,7 +13,7 @@ A calculator application with three frontends (CLI, TUI, Android) sharing a comm - **FR-1.1**: Parse and evaluate infix mathematical expressions with correct operator precedence (PEMDAS). - **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 a domain error, distinct from an unknown name: `sqrt(-1)`, `asin(2)`, `ln(0)`, `log2(0)` and `factorial(-1)` all report a domain error, and only an unrecognized name reports an unknown function. +- **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.7**: Maintain calculation history with replay capability. diff --git a/.kiro/specs/calculator/tasks.md b/.kiro/specs/calculator/tasks.md index 47c2d26..268ec93 100644 --- a/.kiro/specs/calculator/tasks.md +++ b/.kiro/specs/calculator/tasks.md @@ -23,7 +23,7 @@ NOTE: Actual structure diverged from spec - single binary at `src/main.zig` (CLI + TUI combined), engine as static lib + shared lib. No separate cli/ or tui/ build files. kcov-based coverage wired in via `build/Coverage.zig`. -### Task 1.2: Implement core types module [DONE] +### Task 1.2: Implement core types module [DONE, later dismantled] - Create `engine/src/types.zig` - Define `Value` union, `Integer` struct, `BitWidth`, `Signedness`, `Endianness` enums - Define `MultiBaseResult` struct @@ -31,6 +31,12 @@ tui/ build files. kcov-based coverage wired in via `build/Coverage.zig`. - Define `Mode` enum (standard, programmer, financial) - Verify: compiles, types are importable from other engine modules +SUPERSEDED by Tasks 5.12 and 5.13. `types.zig` no longer exists: a module named after +a language feature collected unrelated things. `Value`, `ErrorInfo` and `Mode` are gone +entirely (unused, never constructed, and stored-but-never-read respectively), `Integer` +and its enums are `Integer.zig`, `Endianness` is `std.builtin.Endian`, and `CalcError` +is replaced by a per-module error set in each module that can fail. + ### Task 1.3: Implement tokenizer [DONE] - Create `engine/src/tokenizer.zig` - Token types: numbers (dec, hex `0x`, oct `0o`, bin `0b`), operators, parens, identifiers, comma, semicolon, EOF @@ -147,7 +153,9 @@ behavioral change (rationale in design.md 2.7.9), plus 2.0e for the unit factors - Added to `rational.zig`: `floor`, `ceil`, `round`, `mod` (the `a - b*floor(a/b)` definition), and unbounded exact `factorial`. - Added to `number.zig`: the matching wrappers plus `max`/`min`. -- `rational.Error.ExponentTooLarge` maps to `CalcError.Overflow`. +- `rational.Error.ExponentTooLarge` reached the caller as `CalcError.Overflow`. Both + the mapping and the shared set are gone as of Task 5.13: the name now reaches the + user, as "the exponent is too large to compute". - ALL 489 pre-existing tests pass unchanged. 522 total now (+33). - Verified through the unchanged f64 API: `0.1 + 0.2` = `0.3`, `1.1 + 2.2` = `3.3`, `0.1 * 3` = `0.3`, `0.1+0.2+0.3` = `0.6`, `(0.1+0.2)*10-3` = `0`, @@ -756,6 +764,62 @@ Remaining subcommands deferred until their engine modules exist. - NOT DONE: mouse wheel scrolling for history - Verify: help overlay works, mouse interactions work, looks reasonable in 80x24 terminal +### Task 5.13: One error set per module, not one for the engine + +Found by asking the same question of `errors.zig` that killed `types.zig`. The file +was a defensible home; the type inside it was not. `CalcError` had 20 members and +every engine function returned it, so `parser.parse` claimed it might return +`ConvergenceFailure`, `UnknownUnit` and `StructTooLarge`, and no caller could switch +on what a parse actually produces. + +- Each module now declares what it can fail with: `parser.Error`, `bitwise.Error`, + `units.Error`, `financial.Error`, `evaluator.Error`, `programmer.Error`, with + `rational.Error` (inherited by `number.zig`) already in place. +- The unions are `||` expressions, not lists: `evaluator.Error` is its own four members + plus its dependencies', and `message.Error` is the union of the entry points. The + compiler maintains them, so adding an error to one module propagates with no edit. +- Verified against the compiler before relying on it: error members unify by name + across sets, a set can be derived from a function's return type via `@typeInfo`, and + recursion with an inferred error set resolves (so `evalExact` needed no annotation). +- `number.toCalcError` is gone, and with it `units.mapNumberError` and + `evaluator.mapError`, which were aliases of it. Those call sites are plain `try`. +- Four members went away rather than staying unused: `InvalidType`, + `InvalidFieldName`, `DuplicateFieldName` and `StructTooLarge` belonged to the struct + layout module Phase 4 has not written, and `errorPhrase` was giving all four wording + nothing could produce. +- `errors.zig` is gone. The wording lives in `engine.zig`, next to the `Error` union + it words, and it stays in the engine rather than a frontend because the Android app + will receive these strings across the C ABI. It briefly lived in a `message.zig` of + its own; that file was folded in during review, since nothing in the engine calls it + and a separate file for one table did not pay for itself. `phrase`'s switch has no + `else` (a new error fails to compile until worded), and it cannot word an error the + engine is incapable of returning, because a prong naming one is a type error against + `Error`. That second property is the direction the hand-written set got wrong, and it + needs no assertion: a comptime block asserting it was written, found to be redundant + when tested against the compiler, and removed. + +Two user-visible improvements fell out, because the translation being deleted was +flattening the better names: `sqrt(-1)` now says "square root of a negative number" +instead of "domain error", and `2^3000000` says "the exponent is too large to compute" +instead of "overflow". Two tests updated to match. + +- Verify: 944 tests pass, fmt and zlint clean, CLI checked across parse, name, + arithmetic, unit and financial errors. `engine.zig` at 95.6% (the two uncovered lines + are a diagnostic branch inside a passing test). + +### Task 5.14: File-as-struct for the types that are types + +`Integer.zig` and `Rational.zig` are named TitleCase and the file *is* the type: the +fields sit at container level, `@This()` names it, and the auxiliary declarations +(`IntType`, `BitWidth`, `Signedness`; `Error`, `DecimalResult`) are nested inside. +`git mv` kept the history. + +`number.zig` stays lowercase. `Number` is a tagged union and a Zig file is always a +struct container, so the file can only be that type by wrapping the union in a field. +That would put a `.value` hop in front of 53 tag tests, and the exact/inexact tag is +the type's whole identity, so the wrapper would cost more than the naming consistency +buys. Recorded here so the asymmetry reads as a decision rather than an oversight. + ### Task 5.12: Break up types.zig `types.zig` was named after a language feature rather than a concept, so it diff --git a/engine/src/rational.zig b/engine/src/Rational.zig similarity index 61% rename from engine/src/rational.zig rename to engine/src/Rational.zig index 7fe174e..c3eed96 100644 --- a/engine/src/rational.zig +++ b/engine/src/Rational.zig @@ -13,14 +13,17 @@ //! they never round (`1/3` is exact, and `(1/3) * 3` is exactly 1). See //! design.md 2.7.3. //! -//! API style: operations return a fresh `Rational` and take an allocator, which -//! suits the evaluator's arena. Callers not using an arena must `deinit`. +//! API style: the file is the type, so `num` and `den` are its fields. Operations +//! return a fresh `Rational` and take an allocator, which suits the evaluator's +//! arena. Callers not using an arena must `deinit`. const std = @import("std"); const Allocator = std.mem.Allocator; const Managed = std.math.big.int.Managed; const Order = std.math.Order; +const Rational = @This(); + pub const Error = error{ OutOfMemory, DivisionByZero, @@ -32,756 +35,754 @@ pub const Error = error{ NegativeRoot, }; -pub const Rational = struct { - /// Numerator. Carries the sign of the value. - num: Managed, - /// Denominator. Always strictly positive. - den: Managed, +/// Numerator. Carries the sign of the value. +num: Managed, +/// Denominator. Always strictly positive. +den: Managed, - // -- Construction -- +// -- Construction -- - /// Zero (`0/1`). - pub fn initZero(allocator: Allocator) Error!Rational { +/// Zero (`0/1`). +pub fn initZero(allocator: Allocator) Error!Rational { + return initInt(allocator, 0); +} + +/// An integer value (`value/1`). +pub fn initInt(allocator: Allocator, value: anytype) Error!Rational { + // Built separately rather than inside a struct literal: if the second + // allocation fails, the first must still be released. + var num = try Managed.initSet(allocator, value); + errdefer num.deinit(); + const den = try Managed.initSet(allocator, 1); + return .{ .num = num, .den = den }; +} + +/// A ratio, reduced on construction. Errors if `den` is zero. +pub fn initRatio(allocator: Allocator, num: anytype, den: anytype) Error!Rational { + // Built separately rather than inside a struct literal: if the second + // allocation fails, the first must still be released, and an errdefer + // cannot cover a value that does not exist yet. + var n = try Managed.initSet(allocator, num); + errdefer n.deinit(); + var d = try Managed.initSet(allocator, den); + errdefer d.deinit(); + return finish(allocator, &n, &d); +} + +/// Assemble a reduced `Rational` from a numerator and denominator. +/// +/// Ownership transfers ONLY on success. Both parts are taken by pointer and +/// normalized in place, so on failure the caller's `errdefer` still sees +/// live, current values and frees them exactly once. +/// +/// Taking them by value would be unsound twice over: the caller's errdefer +/// and this function would both free them (a double free), and normalization +/// can reallocate limbs, which would leave the caller's copy dangling. +fn finish(allocator: Allocator, num: *Managed, den: *Managed) Error!Rational { + if (den.eqlZero()) return Error.DivisionByZero; + try normalizeParts(allocator, num, den); + return .{ .num = num.*, .den = den.* }; +} + +pub fn deinit(self: *Rational) void { + self.num.deinit(); + self.den.deinit(); +} + +pub fn clone(self: Rational) Error!Rational { + return self.cloneWith(self.num.allocator); +} + +/// Copy into a different allocator. +/// +/// Needed because a `Rational` carries its allocator inside its limbs: a +/// value stored in the environment must be copied into an evaluation's +/// scratch arena (and vice versa) rather than shared, or one side will free +/// memory the other still refers to. +pub fn cloneWith(self: Rational, allocator: Allocator) Error!Rational { + var num = try self.num.cloneWithDifferentAllocator(allocator); + errdefer num.deinit(); + const den = try self.den.cloneWithDifferentAllocator(allocator); + return .{ .num = num, .den = den }; +} + +/// Reduce by the GCD and force the sign onto the numerator, in place. +fn normalizeParts(allocator: Allocator, num: *Managed, den: *Managed) Error!void { + if (num.eqlZero()) { + try den.set(1); + num.setSign(true); + return; + } + + // Move the sign to the numerator. + if (!den.isPositive()) { + num.negate(); + den.negate(); + } + + var g = try Managed.init(allocator); + defer g.deinit(); + try g.gcd(num, den); + + // Nothing to do when already coprime. + if (g.toConst().orderAgainstScalar(1) == .eq) return; + + var q = try Managed.init(allocator); + defer q.deinit(); + var r = try Managed.init(allocator); + defer r.deinit(); + + try q.divFloor(&r, num, &g); + try num.copy(q.toConst()); + try q.divFloor(&r, den, &g); + try den.copy(q.toConst()); +} + +// -- Parsing -- + +/// Parse exact numeric text, accepting either a decimal literal or a +/// fraction: `"0.0254"`, `"-1.25e3"`, `"5/9"`, `"463/900"`. +/// +/// The fraction form exists because several exact conversion factors have no +/// terminating decimal expansion: Fahrenheit's is exactly 5/9 and a knot's is +/// exactly 463/900. Writing those as rounded decimals would defeat the point. +pub fn parse(allocator: Allocator, text: []const u8) Error!Rational { + const slash = std.mem.indexOfScalar(u8, text, '/') orelse + return parseDecimal(allocator, text); + + var numerator = try parseDecimal(allocator, text[0..slash]); + defer numerator.deinit(); + var denominator = try parseDecimal(allocator, text[slash + 1 ..]); + defer denominator.deinit(); + return div(allocator, numerator, denominator); +} + +/// Parse a decimal numeric literal exactly. +/// +/// Accepts an optional sign, digits with an optional fractional part, and an +/// optional decimal exponent: `42`, `-1.25`, `1e5`, `1.5e-3`. Underscores, +/// commas and spaces are ignored as digit separators, matching the +/// tokenizer's accepted forms. +/// +/// The result is exact: `0.1` becomes `1/10`, not the nearest binary float. +pub fn parseDecimal(allocator: Allocator, text: []const u8) Error!Rational { + var buf: [512]u8 = undefined; + var len: usize = 0; + for (text) |c| { + if (c == '_' or c == ',' or c == ' ') continue; + if (len >= buf.len) return Error.InvalidNumber; + buf[len] = c; + len += 1; + } + var s = buf[0..len]; + if (s.len == 0) return Error.InvalidNumber; + + var negative = false; + if (s[0] == '+' or s[0] == '-') { + negative = s[0] == '-'; + s = s[1..]; + if (s.len == 0) return Error.InvalidNumber; + } + + // Split off the exponent. + var exponent: i64 = 0; + var mantissa = s; + if (std.mem.indexOfAny(u8, s, "eE")) |e_idx| { + mantissa = s[0..e_idx]; + const exp_text = s[e_idx + 1 ..]; + if (exp_text.len == 0) return Error.InvalidNumber; + exponent = std.fmt.parseInt(i64, exp_text, 10) catch return Error.InvalidNumber; + if (exponent > 100_000 or exponent < -100_000) return Error.ExponentTooLarge; + } + if (mantissa.len == 0) return Error.InvalidNumber; + + // Split the mantissa on the decimal point. + var digits_buf: [512]u8 = undefined; + var digits_len: usize = 0; + var frac_digits: usize = 0; + var seen_dot = false; + for (mantissa) |c| { + if (c == '.') { + if (seen_dot) return Error.InvalidNumber; + seen_dot = true; + continue; + } + if (c < '0' or c > '9') return Error.InvalidNumber; + if (digits_len >= digits_buf.len) return Error.InvalidNumber; + digits_buf[digits_len] = c; + digits_len += 1; + if (seen_dot) frac_digits += 1; + } + if (digits_len == 0) return Error.InvalidNumber; + + var num = try Managed.init(allocator); + errdefer num.deinit(); + num.setString(10, digits_buf[0..digits_len]) catch return Error.InvalidNumber; + + var den = try Managed.initSet(allocator, 1); + errdefer den.deinit(); + + // value = digits / 10^frac_digits * 10^exponent + const net_exp: i64 = exponent - @as(i64, @intCast(frac_digits)); + if (net_exp != 0) { + const magnitude: u64 = @intCast(@abs(net_exp)); + if (magnitude > std.math.maxInt(u32)) return Error.ExponentTooLarge; + var scale = try Managed.initSet(allocator, 10); + defer scale.deinit(); + try scale.pow(&scale, @intCast(magnitude)); + if (net_exp > 0) { + try num.mul(&num, &scale); + } else { + try den.mul(&den, &scale); + } + } + + if (negative) num.negate(); + return finish(allocator, &num, &den); +} + +// -- Predicates -- + +pub fn isZero(self: Rational) bool { + return self.num.eqlZero(); +} + +pub fn isNegative(self: Rational) bool { + return !self.num.isPositive() and !self.num.eqlZero(); +} + +/// True when the denominator is 1, i.e. the value is a whole number. +pub fn isInteger(self: Rational) bool { + return self.den.toConst().orderAgainstScalar(1) == .eq; +} + +/// Size of the denominator in bits, used for the demotion policy. +pub fn denBitCount(self: Rational) usize { + return self.den.bitCountAbs(); +} + +/// True when the value has a terminating decimal expansion, i.e. the +/// denominator's only prime factors are 2 and 5. Such values print exactly. +pub fn isTerminating(self: Rational, allocator: Allocator) Error!bool { + var d = try self.den.clone(); + defer d.deinit(); + + var q = try Managed.init(allocator); + defer q.deinit(); + var r = try Managed.init(allocator); + defer r.deinit(); + + for ([_]u8{ 2, 5 }) |factor| { + var f = try Managed.initSet(allocator, factor); + defer f.deinit(); + while (true) { + try q.divFloor(&r, &d, &f); + if (!r.eqlZero()) break; + try d.copy(q.toConst()); + } + } + return d.toConst().orderAgainstScalar(1) == .eq; +} + +// -- Comparison -- + +/// Compare two rationals: `a <=> b`. +pub fn order(allocator: Allocator, a: Rational, b: Rational) Error!Order { + // a/b vs c/d -> a*d vs c*b (denominators are positive, so the + // inequality direction is preserved) + var left = try Managed.init(allocator); + defer left.deinit(); + var right = try Managed.init(allocator); + defer right.deinit(); + try left.mul(&a.num, &b.den); + try right.mul(&b.num, &a.den); + return left.order(right); +} + +pub fn eql(allocator: Allocator, a: Rational, b: Rational) Error!bool { + return (try order(allocator, a, b)) == .eq; +} + +// -- Arithmetic -- + +pub fn add(allocator: Allocator, a: Rational, b: Rational) Error!Rational { + // a/b + c/d = (a*d + c*b) / (b*d) + var left = try Managed.init(allocator); + defer left.deinit(); + var right = try Managed.init(allocator); + defer right.deinit(); + try left.mul(&a.num, &b.den); + try right.mul(&b.num, &a.den); + + var num = try Managed.init(allocator); + errdefer num.deinit(); + try num.add(&left, &right); + + var den = try Managed.init(allocator); + errdefer den.deinit(); + try den.mul(&a.den, &b.den); + + return finish(allocator, &num, &den); +} + +pub fn sub(allocator: Allocator, a: Rational, b: Rational) Error!Rational { + var left = try Managed.init(allocator); + defer left.deinit(); + var right = try Managed.init(allocator); + defer right.deinit(); + try left.mul(&a.num, &b.den); + try right.mul(&b.num, &a.den); + + var num = try Managed.init(allocator); + errdefer num.deinit(); + try num.sub(&left, &right); + + var den = try Managed.init(allocator); + errdefer den.deinit(); + try den.mul(&a.den, &b.den); + + return finish(allocator, &num, &den); +} + +pub fn mul(allocator: Allocator, a: Rational, b: Rational) Error!Rational { + var num = try Managed.init(allocator); + errdefer num.deinit(); + try num.mul(&a.num, &b.num); + + var den = try Managed.init(allocator); + errdefer den.deinit(); + try den.mul(&a.den, &b.den); + + return finish(allocator, &num, &den); +} + +pub fn div(allocator: Allocator, a: Rational, b: Rational) Error!Rational { + if (b.isZero()) return Error.DivisionByZero; + + var num = try Managed.init(allocator); + errdefer num.deinit(); + try num.mul(&a.num, &b.den); + + var den = try Managed.init(allocator); + errdefer den.deinit(); + try den.mul(&a.den, &b.num); + + return finish(allocator, &num, &den); +} + +pub fn negate(allocator: Allocator, a: Rational) Error!Rational { + var result = try a.clone(); + errdefer result.deinit(); + result.num.negate(); + _ = allocator; + return result; +} + +pub fn abs(allocator: Allocator, a: Rational) Error!Rational { + var result = try a.clone(); + errdefer result.deinit(); + result.num.abs(); + _ = allocator; + return result; +} + +/// Raise to an integer power. A negative exponent takes the reciprocal, +/// which is exact for rationals. +pub fn powInt(allocator: Allocator, a: Rational, exponent: i64) Error!Rational { + if (exponent == 0) return initInt(allocator, 1); + + const magnitude: u64 = @intCast(@abs(exponent)); + // Guard against absurd exponents that would exhaust memory. 2^(2^20) + // is already a 128 KiB integer. + if (magnitude > 1_000_000) return Error.ExponentTooLarge; + const e: u32 = @intCast(magnitude); + + if (a.isZero()) { + if (exponent < 0) return Error.DivisionByZero; return initInt(allocator, 0); } - /// An integer value (`value/1`). - pub fn initInt(allocator: Allocator, value: anytype) Error!Rational { - // Built separately rather than inside a struct literal: if the second - // allocation fails, the first must still be released. - var num = try Managed.initSet(allocator, value); - errdefer num.deinit(); - const den = try Managed.initSet(allocator, 1); - return .{ .num = num, .den = den }; + var num = try a.num.clone(); + errdefer num.deinit(); + var den = try a.den.clone(); + errdefer den.deinit(); + try num.pow(&num, e); + try den.pow(&den, e); + + if (exponent < 0) { + // Reciprocal: swap, then let normalize fix the sign. + return finish(allocator, &den, &num); } - - /// A ratio, reduced on construction. Errors if `den` is zero. - pub fn initRatio(allocator: Allocator, num: anytype, den: anytype) Error!Rational { - // Built separately rather than inside a struct literal: if the second - // allocation fails, the first must still be released, and an errdefer - // cannot cover a value that does not exist yet. - var n = try Managed.initSet(allocator, num); - errdefer n.deinit(); - var d = try Managed.initSet(allocator, den); - errdefer d.deinit(); - return finish(allocator, &n, &d); - } - - /// Assemble a reduced `Rational` from a numerator and denominator. - /// - /// Ownership transfers ONLY on success. Both parts are taken by pointer and - /// normalized in place, so on failure the caller's `errdefer` still sees - /// live, current values and frees them exactly once. - /// - /// Taking them by value would be unsound twice over: the caller's errdefer - /// and this function would both free them (a double free), and normalization - /// can reallocate limbs, which would leave the caller's copy dangling. - fn finish(allocator: Allocator, num: *Managed, den: *Managed) Error!Rational { - if (den.eqlZero()) return Error.DivisionByZero; - try normalizeParts(allocator, num, den); - return .{ .num = num.*, .den = den.* }; - } - - pub fn deinit(self: *Rational) void { - self.num.deinit(); - self.den.deinit(); - } - - pub fn clone(self: Rational) Error!Rational { - return self.cloneWith(self.num.allocator); - } - - /// Copy into a different allocator. - /// - /// Needed because a `Rational` carries its allocator inside its limbs: a - /// value stored in the environment must be copied into an evaluation's - /// scratch arena (and vice versa) rather than shared, or one side will free - /// memory the other still refers to. - pub fn cloneWith(self: Rational, allocator: Allocator) Error!Rational { - var num = try self.num.cloneWithDifferentAllocator(allocator); - errdefer num.deinit(); - const den = try self.den.cloneWithDifferentAllocator(allocator); - return .{ .num = num, .den = den }; - } - - /// Reduce by the GCD and force the sign onto the numerator, in place. - fn normalizeParts(allocator: Allocator, num: *Managed, den: *Managed) Error!void { - if (num.eqlZero()) { - try den.set(1); - num.setSign(true); - return; - } - - // Move the sign to the numerator. - if (!den.isPositive()) { - num.negate(); - den.negate(); - } - - var g = try Managed.init(allocator); - defer g.deinit(); - try g.gcd(num, den); - - // Nothing to do when already coprime. - if (g.toConst().orderAgainstScalar(1) == .eq) return; - - var q = try Managed.init(allocator); - defer q.deinit(); - var r = try Managed.init(allocator); - defer r.deinit(); - - try q.divFloor(&r, num, &g); - try num.copy(q.toConst()); - try q.divFloor(&r, den, &g); - try den.copy(q.toConst()); - } - - // -- Parsing -- - - /// Parse exact numeric text, accepting either a decimal literal or a - /// fraction: `"0.0254"`, `"-1.25e3"`, `"5/9"`, `"463/900"`. - /// - /// The fraction form exists because several exact conversion factors have no - /// terminating decimal expansion: Fahrenheit's is exactly 5/9 and a knot's is - /// exactly 463/900. Writing those as rounded decimals would defeat the point. - pub fn parse(allocator: Allocator, text: []const u8) Error!Rational { - const slash = std.mem.indexOfScalar(u8, text, '/') orelse - return parseDecimal(allocator, text); - - var numerator = try parseDecimal(allocator, text[0..slash]); - defer numerator.deinit(); - var denominator = try parseDecimal(allocator, text[slash + 1 ..]); - defer denominator.deinit(); - return div(allocator, numerator, denominator); - } - - /// Parse a decimal numeric literal exactly. - /// - /// Accepts an optional sign, digits with an optional fractional part, and an - /// optional decimal exponent: `42`, `-1.25`, `1e5`, `1.5e-3`. Underscores, - /// commas and spaces are ignored as digit separators, matching the - /// tokenizer's accepted forms. - /// - /// The result is exact: `0.1` becomes `1/10`, not the nearest binary float. - pub fn parseDecimal(allocator: Allocator, text: []const u8) Error!Rational { - var buf: [512]u8 = undefined; - var len: usize = 0; - for (text) |c| { - if (c == '_' or c == ',' or c == ' ') continue; - if (len >= buf.len) return Error.InvalidNumber; - buf[len] = c; - len += 1; - } - var s = buf[0..len]; - if (s.len == 0) return Error.InvalidNumber; - - var negative = false; - if (s[0] == '+' or s[0] == '-') { - negative = s[0] == '-'; - s = s[1..]; - if (s.len == 0) return Error.InvalidNumber; - } - - // Split off the exponent. - var exponent: i64 = 0; - var mantissa = s; - if (std.mem.indexOfAny(u8, s, "eE")) |e_idx| { - mantissa = s[0..e_idx]; - const exp_text = s[e_idx + 1 ..]; - if (exp_text.len == 0) return Error.InvalidNumber; - exponent = std.fmt.parseInt(i64, exp_text, 10) catch return Error.InvalidNumber; - if (exponent > 100_000 or exponent < -100_000) return Error.ExponentTooLarge; - } - if (mantissa.len == 0) return Error.InvalidNumber; - - // Split the mantissa on the decimal point. - var digits_buf: [512]u8 = undefined; - var digits_len: usize = 0; - var frac_digits: usize = 0; - var seen_dot = false; - for (mantissa) |c| { - if (c == '.') { - if (seen_dot) return Error.InvalidNumber; - seen_dot = true; - continue; - } - if (c < '0' or c > '9') return Error.InvalidNumber; - if (digits_len >= digits_buf.len) return Error.InvalidNumber; - digits_buf[digits_len] = c; - digits_len += 1; - if (seen_dot) frac_digits += 1; - } - if (digits_len == 0) return Error.InvalidNumber; - - var num = try Managed.init(allocator); - errdefer num.deinit(); - num.setString(10, digits_buf[0..digits_len]) catch return Error.InvalidNumber; - - var den = try Managed.initSet(allocator, 1); - errdefer den.deinit(); - - // value = digits / 10^frac_digits * 10^exponent - const net_exp: i64 = exponent - @as(i64, @intCast(frac_digits)); - if (net_exp != 0) { - const magnitude: u64 = @intCast(@abs(net_exp)); - if (magnitude > std.math.maxInt(u32)) return Error.ExponentTooLarge; - var scale = try Managed.initSet(allocator, 10); - defer scale.deinit(); - try scale.pow(&scale, @intCast(magnitude)); - if (net_exp > 0) { - try num.mul(&num, &scale); - } else { - try den.mul(&den, &scale); - } - } - - if (negative) num.negate(); - return finish(allocator, &num, &den); - } - - // -- Predicates -- - - pub fn isZero(self: Rational) bool { - return self.num.eqlZero(); - } - - pub fn isNegative(self: Rational) bool { - return !self.num.isPositive() and !self.num.eqlZero(); - } - - /// True when the denominator is 1, i.e. the value is a whole number. - pub fn isInteger(self: Rational) bool { - return self.den.toConst().orderAgainstScalar(1) == .eq; - } - - /// Size of the denominator in bits, used for the demotion policy. - pub fn denBitCount(self: Rational) usize { - return self.den.bitCountAbs(); - } - - /// True when the value has a terminating decimal expansion, i.e. the - /// denominator's only prime factors are 2 and 5. Such values print exactly. - pub fn isTerminating(self: Rational, allocator: Allocator) Error!bool { - var d = try self.den.clone(); - defer d.deinit(); - - var q = try Managed.init(allocator); - defer q.deinit(); - var r = try Managed.init(allocator); - defer r.deinit(); - - for ([_]u8{ 2, 5 }) |factor| { - var f = try Managed.initSet(allocator, factor); - defer f.deinit(); - while (true) { - try q.divFloor(&r, &d, &f); - if (!r.eqlZero()) break; - try d.copy(q.toConst()); - } - } - return d.toConst().orderAgainstScalar(1) == .eq; - } - - // -- Comparison -- - - /// Compare two rationals: `a <=> b`. - pub fn order(allocator: Allocator, a: Rational, b: Rational) Error!Order { - // a/b vs c/d -> a*d vs c*b (denominators are positive, so the - // inequality direction is preserved) - var left = try Managed.init(allocator); - defer left.deinit(); - var right = try Managed.init(allocator); - defer right.deinit(); - try left.mul(&a.num, &b.den); - try right.mul(&b.num, &a.den); - return left.order(right); - } - - pub fn eql(allocator: Allocator, a: Rational, b: Rational) Error!bool { - return (try order(allocator, a, b)) == .eq; - } - - // -- Arithmetic -- - - pub fn add(allocator: Allocator, a: Rational, b: Rational) Error!Rational { - // a/b + c/d = (a*d + c*b) / (b*d) - var left = try Managed.init(allocator); - defer left.deinit(); - var right = try Managed.init(allocator); - defer right.deinit(); - try left.mul(&a.num, &b.den); - try right.mul(&b.num, &a.den); - - var num = try Managed.init(allocator); - errdefer num.deinit(); - try num.add(&left, &right); - - var den = try Managed.init(allocator); - errdefer den.deinit(); - try den.mul(&a.den, &b.den); - - return finish(allocator, &num, &den); - } - - pub fn sub(allocator: Allocator, a: Rational, b: Rational) Error!Rational { - var left = try Managed.init(allocator); - defer left.deinit(); - var right = try Managed.init(allocator); - defer right.deinit(); - try left.mul(&a.num, &b.den); - try right.mul(&b.num, &a.den); - - var num = try Managed.init(allocator); - errdefer num.deinit(); - try num.sub(&left, &right); - - var den = try Managed.init(allocator); - errdefer den.deinit(); - try den.mul(&a.den, &b.den); - - return finish(allocator, &num, &den); - } - - pub fn mul(allocator: Allocator, a: Rational, b: Rational) Error!Rational { - var num = try Managed.init(allocator); - errdefer num.deinit(); - try num.mul(&a.num, &b.num); - - var den = try Managed.init(allocator); - errdefer den.deinit(); - try den.mul(&a.den, &b.den); - - return finish(allocator, &num, &den); - } - - pub fn div(allocator: Allocator, a: Rational, b: Rational) Error!Rational { - if (b.isZero()) return Error.DivisionByZero; - - var num = try Managed.init(allocator); - errdefer num.deinit(); - try num.mul(&a.num, &b.den); - - var den = try Managed.init(allocator); - errdefer den.deinit(); - try den.mul(&a.den, &b.num); - - return finish(allocator, &num, &den); - } - - pub fn negate(allocator: Allocator, a: Rational) Error!Rational { - var result = try a.clone(); - errdefer result.deinit(); - result.num.negate(); - _ = allocator; - return result; - } - - pub fn abs(allocator: Allocator, a: Rational) Error!Rational { - var result = try a.clone(); - errdefer result.deinit(); - result.num.abs(); - _ = allocator; - return result; - } - - /// Raise to an integer power. A negative exponent takes the reciprocal, - /// which is exact for rationals. - pub fn powInt(allocator: Allocator, a: Rational, exponent: i64) Error!Rational { - if (exponent == 0) return initInt(allocator, 1); - - const magnitude: u64 = @intCast(@abs(exponent)); - // Guard against absurd exponents that would exhaust memory. 2^(2^20) - // is already a 128 KiB integer. - if (magnitude > 1_000_000) return Error.ExponentTooLarge; - const e: u32 = @intCast(magnitude); - - if (a.isZero()) { - if (exponent < 0) return Error.DivisionByZero; - return initInt(allocator, 0); - } - - var num = try a.num.clone(); - errdefer num.deinit(); - var den = try a.den.clone(); - errdefer den.deinit(); - try num.pow(&num, e); - try den.pow(&den, e); - - if (exponent < 0) { - // Reciprocal: swap, then let normalize fix the sign. - return finish(allocator, &den, &num); - } - return finish(allocator, &num, &den); - } - - /// Exact square root, or null when the value is not a perfect square of a - /// rational. Used to keep `sqrt(4)` exact while `sqrt(2)` falls back. - pub fn sqrtExact(allocator: Allocator, a: Rational) Error!?Rational { - if (a.isNegative()) return null; - if (a.isZero()) return try initInt(allocator, 0); - - var num_root = try Managed.init(allocator); - errdefer num_root.deinit(); - var den_root = try Managed.init(allocator); - errdefer den_root.deinit(); - - // Guarded by the isNegative check above, so a negative input is impossible. - num_root.sqrt(&a.num) catch |err| switch (err) { - error.SqrtOfNegativeNumber => unreachable, - else => |e| return e, - }; - den_root.sqrt(&a.den) catch |err| switch (err) { - error.SqrtOfNegativeNumber => unreachable, - else => |e| return e, - }; - - // Verify: big.int sqrt truncates, so square the roots and compare. - var check = try Managed.init(allocator); - defer check.deinit(); - try check.mul(&num_root, &num_root); - if (!check.eql(a.num)) { - num_root.deinit(); - den_root.deinit(); - return null; - } - try check.mul(&den_root, &den_root); - if (!check.eql(a.den)) { - num_root.deinit(); - den_root.deinit(); - return null; - } - - return try finish(allocator, &num_root, &den_root); - } - - /// Largest integer not greater than the value. - pub fn floor(allocator: Allocator, a: Rational) Error!Rational { - if (a.isInteger()) return a.clone(); - - var q = try Managed.init(allocator); - errdefer q.deinit(); - var r = try Managed.init(allocator); - defer r.deinit(); - // divFloor rounds the quotient toward negative infinity, which is - // exactly floor for a positive denominator (an invariant here). - try q.divFloor(&r, &a.num, &a.den); - - var den = try Managed.initSet(allocator, 1); - errdefer den.deinit(); - return finish(allocator, &q, &den); - } - - /// Smallest integer not less than the value. - pub fn ceil(allocator: Allocator, a: Rational) Error!Rational { - if (a.isInteger()) return a.clone(); - var f = try floor(allocator, a); - errdefer f.deinit(); - // Not an integer, so ceil is always floor + 1. - try f.num.addScalar(&f.num, 1); - return f; - } - - /// Round to the nearest integer, halves away from zero (matching `@round`). - pub fn round(allocator: Allocator, a: Rational) Error!Rational { - if (a.isInteger()) return a.clone(); - - var half = try initRatio(allocator, 1, 2); - defer half.deinit(); - - if (a.isNegative()) { - var shifted = try sub(allocator, a, half); - defer shifted.deinit(); - return ceil(allocator, shifted); - } - var shifted = try add(allocator, a, half); - defer shifted.deinit(); - return floor(allocator, shifted); - } - - /// Exact integer remainder matching `@mod`: the result takes the sign of - /// the divisor, and equals `a - b * floor(a / b)`. - pub fn mod(allocator: Allocator, a: Rational, b: Rational) Error!Rational { - if (b.isZero()) return Error.DivisionByZero; - - var quotient = try div(allocator, a, b); - defer quotient.deinit(); - var floored = try floor(allocator, quotient); - defer floored.deinit(); - var scaled = try mul(allocator, floored, b); - defer scaled.deinit(); - return sub(allocator, a, scaled); - } - - /// Exact factorial. Unbounded, unlike the f64 version which overflows past - /// 170. - pub fn factorial(allocator: Allocator, n: u64) Error!Rational { - // A guard against absurd inputs that would take effectively forever; - // 20000! is already a ~78000-digit number. - if (n > 20_000) return Error.ExponentTooLarge; - - var acc = try Managed.initSet(allocator, 1); - errdefer acc.deinit(); - var i: u64 = 2; - while (i <= n) : (i += 1) { - var factor = try Managed.initSet(allocator, i); - defer factor.deinit(); - try acc.mul(&acc, &factor); - } - var den = try Managed.initSet(allocator, 1); - errdefer den.deinit(); - return finish(allocator, &acc, &den); - } - - // -- Conversion -- - - /// Convert to the nearest f64. - /// - /// Scales the numerator so the quotient carries enough bits to round - /// correctly, rather than dividing two separately-rounded floats (which - /// would lose precision twice and overflow for large values). - pub fn toFloat(self: Rational, allocator: Allocator) f64 { - if (self.isZero()) return 0; - - const negative = self.isNegative(); - - var num = self.num.clone() catch return std.math.nan(f64); - defer { - var n = num; - n.deinit(); - } - num.abs(); - - const num_bits: i64 = @intCast(num.bitCountAbs()); - const den_bits: i64 = @intCast(self.den.bitCountAbs()); - - // Aim for ~72 significant bits in the quotient, comfortably more than - // f64's 53, so a single final rounding is correct. - const target: i64 = 72; - const shift: i64 = target - (num_bits - den_bits); - - var scaled = Managed.init(allocator) catch return std.math.nan(f64); - defer scaled.deinit(); - - if (shift > 0) { - if (shift > 1 << 20) return if (negative) -0.0 else 0.0; - scaled.shiftLeft(&num, @intCast(shift)) catch return std.math.nan(f64); - } else { - scaled.shiftRight(&num, @intCast(-shift)) catch return std.math.nan(f64); - } - - var q = Managed.init(allocator) catch return std.math.nan(f64); - defer q.deinit(); - var r = Managed.init(allocator) catch return std.math.nan(f64); - defer r.deinit(); - q.divFloor(&r, &scaled, &self.den) catch return std.math.nan(f64); - - const quotient, _ = q.toFloat(f64, .nearest_even); - const exponent: i32 = @intCast(-shift); - const result = std.math.ldexp(quotient, exponent); - return if (negative) -result else result; - } - - /// Render as an exact fraction, e.g. "781250/12573" or "7" for integers. - /// Caller owns the returned memory. - pub fn toFractionString(self: Rational, allocator: Allocator) Error![]u8 { - const num_str = try decimalDigits(self.num, allocator); - defer allocator.free(num_str); - if (self.isInteger()) return allocator.dupe(u8, num_str); - const den_str = try decimalDigits(self.den, allocator); - defer allocator.free(den_str); - return std.fmt.allocPrint(allocator, "{s}/{s}", .{ num_str, den_str }) catch - Error.OutOfMemory; - } - - pub const DecimalResult = struct { - /// Decimal text. Caller owns the memory. - text: []u8, - /// False when the expansion was truncated at `max_digits` and rounded, - /// i.e. the text is an approximation of the exact value. - exact: bool, + return finish(allocator, &num, &den); +} + +/// Exact square root, or null when the value is not a perfect square of a +/// rational. Used to keep `sqrt(4)` exact while `sqrt(2)` falls back. +pub fn sqrtExact(allocator: Allocator, a: Rational) Error!?Rational { + if (a.isNegative()) return null; + if (a.isZero()) return try initInt(allocator, 0); + + var num_root = try Managed.init(allocator); + errdefer num_root.deinit(); + var den_root = try Managed.init(allocator); + errdefer den_root.deinit(); + + // Guarded by the isNegative check above, so a negative input is impossible. + num_root.sqrt(&a.num) catch |err| switch (err) { + error.SqrtOfNegativeNumber => unreachable, + else => |e| return e, + }; + den_root.sqrt(&a.den) catch |err| switch (err) { + error.SqrtOfNegativeNumber => unreachable, + else => |e| return e, }; - /// Render as decimal text by long division. - /// - /// Terminating expansions print exactly and report `exact = true`. Repeating - /// expansions are rounded half-up at `max_digits` fractional digits and - /// report `exact = false`, so a frontend can mark them as approximate. - pub fn toDecimalString(self: Rational, allocator: Allocator, max_digits: usize) Error!DecimalResult { + // Verify: big.int sqrt truncates, so square the roots and compare. + var check = try Managed.init(allocator); + defer check.deinit(); + try check.mul(&num_root, &num_root); + if (!check.eql(a.num)) { + num_root.deinit(); + den_root.deinit(); + return null; + } + try check.mul(&den_root, &den_root); + if (!check.eql(a.den)) { + num_root.deinit(); + den_root.deinit(); + return null; + } + + return try finish(allocator, &num_root, &den_root); +} + +/// Largest integer not greater than the value. +pub fn floor(allocator: Allocator, a: Rational) Error!Rational { + if (a.isInteger()) return a.clone(); + + var q = try Managed.init(allocator); + errdefer q.deinit(); + var r = try Managed.init(allocator); + defer r.deinit(); + // divFloor rounds the quotient toward negative infinity, which is + // exactly floor for a positive denominator (an invariant here). + try q.divFloor(&r, &a.num, &a.den); + + var den = try Managed.initSet(allocator, 1); + errdefer den.deinit(); + return finish(allocator, &q, &den); +} + +/// Smallest integer not less than the value. +pub fn ceil(allocator: Allocator, a: Rational) Error!Rational { + if (a.isInteger()) return a.clone(); + var f = try floor(allocator, a); + errdefer f.deinit(); + // Not an integer, so ceil is always floor + 1. + try f.num.addScalar(&f.num, 1); + return f; +} + +/// Round to the nearest integer, halves away from zero (matching `@round`). +pub fn round(allocator: Allocator, a: Rational) Error!Rational { + if (a.isInteger()) return a.clone(); + + var half = try initRatio(allocator, 1, 2); + defer half.deinit(); + + if (a.isNegative()) { + var shifted = try sub(allocator, a, half); + defer shifted.deinit(); + return ceil(allocator, shifted); + } + var shifted = try add(allocator, a, half); + defer shifted.deinit(); + return floor(allocator, shifted); +} + +/// Exact integer remainder matching `@mod`: the result takes the sign of +/// the divisor, and equals `a - b * floor(a / b)`. +pub fn mod(allocator: Allocator, a: Rational, b: Rational) Error!Rational { + if (b.isZero()) return Error.DivisionByZero; + + var quotient = try div(allocator, a, b); + defer quotient.deinit(); + var floored = try floor(allocator, quotient); + defer floored.deinit(); + var scaled = try mul(allocator, floored, b); + defer scaled.deinit(); + return sub(allocator, a, scaled); +} + +/// Exact factorial. Unbounded, unlike the f64 version which overflows past +/// 170. +pub fn factorial(allocator: Allocator, n: u64) Error!Rational { + // A guard against absurd inputs that would take effectively forever; + // 20000! is already a ~78000-digit number. + if (n > 20_000) return Error.ExponentTooLarge; + + var acc = try Managed.initSet(allocator, 1); + errdefer acc.deinit(); + var i: u64 = 2; + while (i <= n) : (i += 1) { + var factor = try Managed.initSet(allocator, i); + defer factor.deinit(); + try acc.mul(&acc, &factor); + } + var den = try Managed.initSet(allocator, 1); + errdefer den.deinit(); + return finish(allocator, &acc, &den); +} + +// -- Conversion -- + +/// Convert to the nearest f64. +/// +/// Scales the numerator so the quotient carries enough bits to round +/// correctly, rather than dividing two separately-rounded floats (which +/// would lose precision twice and overflow for large values). +pub fn toFloat(self: Rational, allocator: Allocator) f64 { + if (self.isZero()) return 0; + + const negative = self.isNegative(); + + var num = self.num.clone() catch return std.math.nan(f64); + defer { + var n = num; + n.deinit(); + } + num.abs(); + + const num_bits: i64 = @intCast(num.bitCountAbs()); + const den_bits: i64 = @intCast(self.den.bitCountAbs()); + + // Aim for ~72 significant bits in the quotient, comfortably more than + // f64's 53, so a single final rounding is correct. + const target: i64 = 72; + const shift: i64 = target - (num_bits - den_bits); + + var scaled = Managed.init(allocator) catch return std.math.nan(f64); + defer scaled.deinit(); + + if (shift > 0) { + if (shift > 1 << 20) return if (negative) -0.0 else 0.0; + scaled.shiftLeft(&num, @intCast(shift)) catch return std.math.nan(f64); + } else { + scaled.shiftRight(&num, @intCast(-shift)) catch return std.math.nan(f64); + } + + var q = Managed.init(allocator) catch return std.math.nan(f64); + defer q.deinit(); + var r = Managed.init(allocator) catch return std.math.nan(f64); + defer r.deinit(); + q.divFloor(&r, &scaled, &self.den) catch return std.math.nan(f64); + + const quotient, _ = q.toFloat(f64, .nearest_even); + const exponent: i32 = @intCast(-shift); + const result = std.math.ldexp(quotient, exponent); + return if (negative) -result else result; +} + +/// Render as an exact fraction, e.g. "781250/12573" or "7" for integers. +/// Caller owns the returned memory. +pub fn toFractionString(self: Rational, allocator: Allocator) Error![]u8 { + const num_str = try decimalDigits(self.num, allocator); + defer allocator.free(num_str); + if (self.isInteger()) return allocator.dupe(u8, num_str); + const den_str = try decimalDigits(self.den, allocator); + defer allocator.free(den_str); + return std.fmt.allocPrint(allocator, "{s}/{s}", .{ num_str, den_str }) catch + Error.OutOfMemory; +} + +pub const DecimalResult = struct { + /// Decimal text. Caller owns the memory. + text: []u8, + /// False when the expansion was truncated at `max_digits` and rounded, + /// i.e. the text is an approximation of the exact value. + exact: bool, +}; + +/// Render as decimal text by long division. +/// +/// Terminating expansions print exactly and report `exact = true`. Repeating +/// expansions are rounded half-up at `max_digits` fractional digits and +/// report `exact = false`, so a frontend can mark them as approximate. +pub fn toDecimalString(self: Rational, allocator: Allocator, max_digits: usize) Error!DecimalResult { + var out = std.ArrayList(u8).empty; + errdefer out.deinit(allocator); + + if (self.isNegative()) try out.append(allocator, '-'); + + var work = try self.num.clone(); + defer work.deinit(); + work.abs(); + + var q = try Managed.init(allocator); + defer q.deinit(); + var rem = try Managed.init(allocator); + defer rem.deinit(); + + // Integer part. + try q.divFloor(&rem, &work, &self.den); + const int_str = try decimalDigits(q, allocator); + defer allocator.free(int_str); + try out.appendSlice(allocator, int_str); + + if (rem.eqlZero()) { + return .{ .text = try out.toOwnedSlice(allocator), .exact = true }; + } + + try out.append(allocator, '.'); + + var ten = try Managed.initSet(allocator, 10); + defer ten.deinit(); + + var digits: usize = 0; + var exact = true; + while (digits < max_digits) { + try rem.mul(&rem, &ten); + try q.divFloor(&rem, &rem, &self.den); + const digit = q.toInt(u8) catch 0; + try out.append(allocator, '0' + digit); + digits += 1; + if (rem.eqlZero()) break; + } else { + // Ran out of digits with a remainder left: the text is truncated. + exact = false; + } + + if (!exact) { + // Round half-up: compare 2*remainder against the denominator. + var doubled = try Managed.init(allocator); + defer doubled.deinit(); + var two = try Managed.initSet(allocator, 2); + defer two.deinit(); + try doubled.mul(&rem, &two); + if (doubled.order(self.den) != .lt) { + if (roundUpDecimal(out.items)) { + // The carry ran off the front, so the integer part was all + // nines and gained a digit: 9.99... becomes 10.00..., and + // 9.99... just under 10 must not print as 0.00... + const insert_at: usize = if (out.items.len > 0 and out.items[0] == '-') 1 else 0; + try out.insert(allocator, insert_at, '1'); + } + } + } + + // Trim any trailing zeros produced by an exact expansion. + if (exact) { + var end = out.items.len; + while (end > 0 and out.items[end - 1] == '0') end -= 1; + if (end > 0 and out.items[end - 1] == '.') end -= 1; + out.shrinkRetainingCapacity(end); + } + + return .{ .text = try out.toOwnedSlice(allocator), .exact = exact }; +} + +/// Render in scientific notation with `significant_digits` mantissa digits, +/// rounded half-up. +/// +/// This exists for values the fixed-point renderer cannot show at all. An +/// exact `2^-70` is 8.47e-22, and `toDecimalString` with a 20-digit budget +/// renders it as `0.000...0`, destroying the value in the display and in the +/// clipboard alike. Working from the rational rather than from already +/// rendered text means the exponent can be arbitrarily negative without +/// materialising the leading zeros. +pub fn toScientificString( + self: Rational, + allocator: Allocator, + significant_digits: usize, +) Error![]u8 { + std.debug.assert(significant_digits >= 1); + if (self.isZero()) return allocator.dupe(u8, "0"); + + var magnitude = try self.num.clone(); + defer magnitude.deinit(); + magnitude.abs(); + + // First guess from the bit lengths: log10(x) is about bits(x) * log10(2). + // It can be off by one either way, which the loop below corrects. + const num_bits: i64 = @intCast(magnitude.bitCountAbs()); + const den_bits: i64 = @intCast(self.den.bitCountAbs()); + const log10_of_2 = 0.30102999566398120; + var exponent: i64 = @intFromFloat( + @floor(@as(f64, @floatFromInt(num_bits - den_bits)) * log10_of_2), + ); + + const wanted: i64 = @intCast(significant_digits); + // The exponent has to be settled on the TRUNCATED scaling, not the rounded + // one: for 2/3 with one significant digit, rounding 0.667 gives "1", which + // looks like a correct one-digit mantissa while actually meaning 1e0 + // instead of 7e-1. Floor first, fix the exponent, then round. + var attempt: usize = 0; + while (attempt < 4) : (attempt += 1) { + var scaled = try scaleFloor(allocator, magnitude, self.den, wanted - 1 - exponent); + defer scaled.quotient.deinit(); + + var digits = try decimalDigits(scaled.quotient, allocator); + defer allocator.free(digits); + + // A zero quotient means the exponent guess was too high. Its digit + // string is "0", one character, so the length check below would accept + // it for a one-digit mantissa and then round it to "1". + if (scaled.quotient.eqlZero()) { + exponent -= 1; + continue; + } + + if (digits.len != significant_digits) { + exponent += @as(i64, @intCast(digits.len)) - wanted; + continue; + } + + // Half-up on the digit string, which is where a carry can add a digit: + // 9.99 with three digits becomes 1e1, not 10.0e0. + if (scaled.round_up) { + var i = digits.len; + var carried = true; + while (i > 0 and carried) { + i -= 1; + if (digits[i] == '9') { + digits[i] = '0'; + } else { + digits[i] += 1; + carried = false; + } + } + if (carried) { + // Every digit was a nine: the mantissa is 1 and the exponent + // moves up. + digits[0] = '1'; + exponent += 1; + } + } + var out = std.ArrayList(u8).empty; errdefer out.deinit(allocator); - if (self.isNegative()) try out.append(allocator, '-'); + try out.append(allocator, digits[0]); - var work = try self.num.clone(); - defer work.deinit(); - work.abs(); - - var q = try Managed.init(allocator); - defer q.deinit(); - var rem = try Managed.init(allocator); - defer rem.deinit(); - - // Integer part. - try q.divFloor(&rem, &work, &self.den); - const int_str = try decimalDigits(q, allocator); - defer allocator.free(int_str); - try out.appendSlice(allocator, int_str); - - if (rem.eqlZero()) { - return .{ .text = try out.toOwnedSlice(allocator), .exact = true }; + // Trailing zeros in the mantissa carry no information here. + var end = digits.len; + while (end > 1 and digits[end - 1] == '0') end -= 1; + if (end > 1) { + try out.append(allocator, '.'); + try out.appendSlice(allocator, digits[1..end]); } - - try out.append(allocator, '.'); - - var ten = try Managed.initSet(allocator, 10); - defer ten.deinit(); - - var digits: usize = 0; - var exact = true; - while (digits < max_digits) { - try rem.mul(&rem, &ten); - try q.divFloor(&rem, &rem, &self.den); - const digit = q.toInt(u8) catch 0; - try out.append(allocator, '0' + digit); - digits += 1; - if (rem.eqlZero()) break; - } else { - // Ran out of digits with a remainder left: the text is truncated. - exact = false; - } - - if (!exact) { - // Round half-up: compare 2*remainder against the denominator. - var doubled = try Managed.init(allocator); - defer doubled.deinit(); - var two = try Managed.initSet(allocator, 2); - defer two.deinit(); - try doubled.mul(&rem, &two); - if (doubled.order(self.den) != .lt) { - if (roundUpDecimal(out.items)) { - // The carry ran off the front, so the integer part was all - // nines and gained a digit: 9.99... becomes 10.00..., and - // 9.99... just under 10 must not print as 0.00... - const insert_at: usize = if (out.items.len > 0 and out.items[0] == '-') 1 else 0; - try out.insert(allocator, insert_at, '1'); - } - } - } - - // Trim any trailing zeros produced by an exact expansion. - if (exact) { - var end = out.items.len; - while (end > 0 and out.items[end - 1] == '0') end -= 1; - if (end > 0 and out.items[end - 1] == '.') end -= 1; - out.shrinkRetainingCapacity(end); - } - - return .{ .text = try out.toOwnedSlice(allocator), .exact = exact }; + var exponent_buf: [24]u8 = undefined; + // 24 bytes holds "e-9223372036854775808", the widest an i64 exponent + // can be, so the only way this fails is a bug in this buffer size. + const exponent_text = std.fmt.bufPrint(&exponent_buf, "e{d}", .{exponent}) catch + @panic("exponent buffer too small"); + try out.appendSlice(allocator, exponent_text); + return out.toOwnedSlice(allocator); } - - /// Render in scientific notation with `significant_digits` mantissa digits, - /// rounded half-up. - /// - /// This exists for values the fixed-point renderer cannot show at all. An - /// exact `2^-70` is 8.47e-22, and `toDecimalString` with a 20-digit budget - /// renders it as `0.000...0`, destroying the value in the display and in the - /// clipboard alike. Working from the rational rather than from already - /// rendered text means the exponent can be arbitrarily negative without - /// materialising the leading zeros. - pub fn toScientificString( - self: Rational, - allocator: Allocator, - significant_digits: usize, - ) Error![]u8 { - std.debug.assert(significant_digits >= 1); - if (self.isZero()) return allocator.dupe(u8, "0"); - - var magnitude = try self.num.clone(); - defer magnitude.deinit(); - magnitude.abs(); - - // First guess from the bit lengths: log10(x) is about bits(x) * log10(2). - // It can be off by one either way, which the loop below corrects. - const num_bits: i64 = @intCast(magnitude.bitCountAbs()); - const den_bits: i64 = @intCast(self.den.bitCountAbs()); - const log10_of_2 = 0.30102999566398120; - var exponent: i64 = @intFromFloat( - @floor(@as(f64, @floatFromInt(num_bits - den_bits)) * log10_of_2), - ); - - const wanted: i64 = @intCast(significant_digits); - // The exponent has to be settled on the TRUNCATED scaling, not the rounded - // one: for 2/3 with one significant digit, rounding 0.667 gives "1", which - // looks like a correct one-digit mantissa while actually meaning 1e0 - // instead of 7e-1. Floor first, fix the exponent, then round. - var attempt: usize = 0; - while (attempt < 4) : (attempt += 1) { - var scaled = try scaleFloor(allocator, magnitude, self.den, wanted - 1 - exponent); - defer scaled.quotient.deinit(); - - var digits = try decimalDigits(scaled.quotient, allocator); - defer allocator.free(digits); - - // A zero quotient means the exponent guess was too high. Its digit - // string is "0", one character, so the length check below would accept - // it for a one-digit mantissa and then round it to "1". - if (scaled.quotient.eqlZero()) { - exponent -= 1; - continue; - } - - if (digits.len != significant_digits) { - exponent += @as(i64, @intCast(digits.len)) - wanted; - continue; - } - - // Half-up on the digit string, which is where a carry can add a digit: - // 9.99 with three digits becomes 1e1, not 10.0e0. - if (scaled.round_up) { - var i = digits.len; - var carried = true; - while (i > 0 and carried) { - i -= 1; - if (digits[i] == '9') { - digits[i] = '0'; - } else { - digits[i] += 1; - carried = false; - } - } - if (carried) { - // Every digit was a nine: the mantissa is 1 and the exponent - // moves up. - digits[0] = '1'; - exponent += 1; - } - } - - var out = std.ArrayList(u8).empty; - errdefer out.deinit(allocator); - if (self.isNegative()) try out.append(allocator, '-'); - try out.append(allocator, digits[0]); - - // Trailing zeros in the mantissa carry no information here. - var end = digits.len; - while (end > 1 and digits[end - 1] == '0') end -= 1; - if (end > 1) { - try out.append(allocator, '.'); - try out.appendSlice(allocator, digits[1..end]); - } - var exponent_buf: [24]u8 = undefined; - // 24 bytes holds "e-9223372036854775808", the widest an i64 exponent - // can be, so the only way this fails is a bug in this buffer size. - const exponent_text = std.fmt.bufPrint(&exponent_buf, "e{d}", .{exponent}) catch - @panic("exponent buffer too small"); - try out.appendSlice(allocator, exponent_text); - return out.toOwnedSlice(allocator); - } - // The estimate failed to settle, which should not happen. Rather than spin, - // report it instead of returning something wrong. - return Error.ExponentTooLarge; - } -}; + // The estimate failed to settle, which should not happen. Rather than spin, + // report it instead of returning something wrong. + return Error.ExponentTooLarge; +} /// `floor(magnitude * 10^shift / denominator)` plus whether a half-up rounding /// step would increment it. A negative `shift` scales the denominator instead. diff --git a/engine/src/bitwise.zig b/engine/src/bitwise.zig index db7a36d..8313ede 100644 --- a/engine/src/bitwise.zig +++ b/engine/src/bitwise.zig @@ -29,7 +29,10 @@ const Integer = @import("Integer.zig"); const BitWidth = Integer.BitWidth; const Signedness = Integer.Signedness; const IntType = Integer.IntType; -const CalcError = @import("errors.zig").CalcError; + +/// The one way a fixed-width operation can fail: a shift or rotate distance that is +/// negative in the operand's type. Everything else about these operators is total. +pub const Error = error{DomainError}; /// The operators this module implements. /// @@ -79,8 +82,8 @@ const Distance = union(enum) { /// /// A negative distance is a domain error rather than a very large one. Standard /// mode used to reduce it modulo 64, so `8 >> -1` quietly became `8 >> 63`. -fn distance(int_type: IntType, right: u128) CalcError!Distance { - if (int_type.isNegative(right)) return CalcError.DomainError; +fn distance(int_type: IntType, right: u128) Error!Distance { + if (int_type.isNegative(right)) return Error.DomainError; const value = right & int_type.mask(); if (value >= int_type.bits()) return .past_width; return .{ .within = @intCast(value) }; @@ -90,13 +93,13 @@ fn distance(int_type: IntType, right: u128) CalcError!Distance { /// /// Rotation is cyclic, so a distance beyond the width is reduced rather than /// saturated: rotating a 64-bit value by 65 is rotating it by 1. -fn rotation(int_type: IntType, right: u128) CalcError!u7 { - if (int_type.isNegative(right)) return CalcError.DomainError; +fn rotation(int_type: IntType, right: u128) Error!u7 { + if (int_type.isNegative(right)) return Error.DomainError; return @intCast((right & int_type.mask()) % int_type.bits()); } /// Apply a fixed-width operation. Operands and result are masked bit patterns. -pub fn apply(int_type: IntType, op: Op, left_in: u128, right_in: u128) CalcError!u128 { +pub fn apply(int_type: IntType, op: Op, left_in: u128, right_in: u128) Error!u128 { const mask = int_type.mask(); const left = left_in & mask; const right = right_in & mask; @@ -234,11 +237,11 @@ test "shifting by one less than the width still keeps a bit" { test "a negative shift distance is a domain error, not a huge one" { const neg_one: u128 = 0xFF; // -1 in 8-bit signed - try testing.expectError(CalcError.DomainError, apply(i8_type, .shift_left, 1, neg_one)); - try testing.expectError(CalcError.DomainError, apply(i8_type, .shift_right, 1, neg_one)); - try testing.expectError(CalcError.DomainError, apply(i8_type, .shift_right_logical, 1, neg_one)); - try testing.expectError(CalcError.DomainError, apply(i8_type, .rotate_left, 1, neg_one)); - try testing.expectError(CalcError.DomainError, apply(i8_type, .rotate_right, 1, neg_one)); + try testing.expectError(Error.DomainError, apply(i8_type, .shift_left, 1, neg_one)); + try testing.expectError(Error.DomainError, apply(i8_type, .shift_right, 1, neg_one)); + try testing.expectError(Error.DomainError, apply(i8_type, .shift_right_logical, 1, neg_one)); + try testing.expectError(Error.DomainError, apply(i8_type, .rotate_left, 1, neg_one)); + try testing.expectError(Error.DomainError, apply(i8_type, .rotate_right, 1, neg_one)); // The same pattern in an unsigned domain is 255, a distance past the width. try testing.expectEqual(@as(u128, 0), try apply(u8_type, .shift_left, 1, neg_one)); } diff --git a/engine/src/engine.zig b/engine/src/engine.zig index 1e6f3e8..d67fe30 100644 --- a/engine/src/engine.zig +++ b/engine/src/engine.zig @@ -2,16 +2,13 @@ //! //! Pure computation library with no I/O. Provides expression parsing, evaluation, //! programmer-mode bit manipulation, unit conversion, and financial calculations. -//! -//! This file is a facade: it imports and re-exports, and defines nothing. Anything -//! defined here would have to be imported back by the modules below, which is how a -//! root file becomes a dependency of its own leaves. + +const std = @import("std"); // Vocabulary, lowest first. -pub const errors = @import("errors.zig"); pub const Integer = @import("Integer.zig"); // Exact numeric model (design.md 2.7). The evaluator computes in these. -pub const rational = @import("rational.zig"); +pub const Rational = @import("Rational.zig"); pub const number = @import("number.zig"); // Language layer. pub const tokenizer = @import("tokenizer.zig"); @@ -33,7 +30,6 @@ pub const financial = @import("financial.zig"); // curated re-export of nearly every public declaration, which drifted: two thirds // of it had no callers, and `Value` was re-exported after the type it named had // stopped being the engine's result type. -pub const CalcError = errors.CalcError; pub const BitWidth = Integer.BitWidth; pub const Environment = evaluator.Environment; pub const evalString = evaluator.evalString; @@ -44,8 +40,140 @@ pub const UnitCategory = units.UnitCategory; pub const UnitDef = units.UnitDef; pub const Number = number.Number; -test { - std.testing.refAllDecls(@This()); +/// Every error an engine entry point can return. +/// +/// Derived, not enumerated: each module declares what it can fail with +/// (`parser.Error`, `units.Error`, `financial.Error`, and so on), and adding an error +/// to any of those adds it here with no list to keep in step. There used to be one +/// hand-written `CalcError` with 20 members that every engine function claimed to +/// return, four of which nothing could produce. +pub const Error = evaluator.Error || + programmer.Error || + units.Error || + financial.Error; + +/// The human-readable phrase for an error, with no prefix and no newline. +/// +/// One table for every frontend, because they all need the same words: the CLI and +/// the TUI call this, and the Android app will receive these strings across the C +/// ABI. Each frontend adds its own decoration ("error: " and a newline for the CLI, +/// "error: " for the TUI) and a view with better context can override individual +/// cases, as the financial form does. +/// +/// The switch has no `else`, so an error added to any module's set fails to compile +/// here rather than falling back to something vague. It also cannot word an error the +/// engine is incapable of returning: a prong naming one is a type error, since the +/// switch is over `Error`. That second property is what the hand-written `CalcError` +/// got wrong, and it needs no assertion of its own to hold. It carried `InvalidType`, +/// `InvalidFieldName`, `DuplicateFieldName` and `StructTooLarge` for a struct layout +/// module that does not exist yet, and gave all four a phrase. +pub fn phrase(err: Error) []const u8 { + return switch (err) { + // Parsing + error.UnexpectedToken => "unexpected token", + error.UnmatchedParen => "unmatched parenthesis", + error.UnexpectedEnd => "unexpected end of expression", + error.InvalidExpression => "invalid expression", + error.InvalidNumber => "invalid number", + + // Names + error.UnknownFunction => "unknown function", + error.UnknownVariable => "unknown variable", + + // Arithmetic + error.DivisionByZero => "division by zero", + error.DomainError => "domain error", + error.Overflow => "overflow", + error.InvalidOperandType => "invalid operand type", + // These two used to be flattened into Overflow and DomainError by a mapping + // between error sets. The specific wording is the whole reason the numeric + // tier bothered to distinguish them. + error.ExponentTooLarge => "the exponent is too large to compute", + error.NegativeRoot => "square root of a negative number", + + // Units + error.UnknownUnit => "unknown unit", + error.IncompatibleUnits => "incompatible units (different categories)", + + // Financial + error.InsufficientParameters => "these values do not determine an answer", + error.ConvergenceFailure => "no solution found", + + // System + error.OutOfMemory => "out of memory", + }; } -const std = @import("std"); +// -- Tests -- + +const testing = std.testing; + +test { + testing.refAllDecls(@This()); +} + +test "every error the engine can return has its own phrase" { + // Exhaustive by construction; this checks the qualities the switch cannot state: + // non-empty, undecorated, single-line, and mutually distinct. + const fields = @typeInfo(Error).error_set.?; + var seen: [fields.len][]const u8 = undefined; + inline for (fields, 0..) |field, i| { + const text = phrase(@field(Error, field.name)); + try testing.expect(text.len > 0); + // No prefix and no newline: decoration belongs to the caller. + try testing.expect(!std.mem.startsWith(u8, text, "error")); + try testing.expect(std.mem.indexOfScalar(u8, text, '\n') == null); + seen[i] = text; + } + + for (seen, 0..) |text, i| { + for (seen[i + 1 ..]) |other| { + if (std.mem.eql(u8, text, other)) { + std.debug.print("two errors share the phrase \"{s}\"\n", .{text}); + return error.TestUnexpectedResult; + } + } + } +} + +test "the error set is derived from the modules, not enumerated here" { + // A module's errors reach `Error` without this file naming them, which is what + // makes the per-module sets safe to extend. + inline for (@typeInfo(financial.Error).error_set.?) |field| { + const promoted: Error = @field(Error, field.name); + try testing.expect(phrase(promoted).len > 0); + } + inline for (@typeInfo(units.Error).error_set.?) |field| { + const promoted: Error = @field(Error, field.name); + try testing.expect(phrase(promoted).len > 0); + } + inline for (@typeInfo(parser.Error).error_set.?) |field| { + const promoted: Error = @field(Error, field.name); + try testing.expect(phrase(promoted).len > 0); + } +} + +test "the struct-layout errors are gone, not merely unused" { + // They were members of the old set with phrases nothing could produce. Absence + // is the assertion: naming one in `phrase` would not compile. + inline for (@typeInfo(Error).error_set.?) |field| { + try testing.expect(!std.mem.eql(u8, field.name, "StructTooLarge")); + try testing.expect(!std.mem.eql(u8, field.name, "InvalidFieldName")); + try testing.expect(!std.mem.eql(u8, field.name, "DuplicateFieldName")); + try testing.expect(!std.mem.eql(u8, field.name, "InvalidType")); + } +} + +test "phrase is usable at comptime, which is how frontends decorate it" { + const decorated = comptime "error: " ++ phrase(error.DivisionByZero); + try testing.expectEqualStrings("error: division by zero", decorated); +} + +test "the cases the TUI table used to lose" { + try testing.expectEqualStrings("invalid expression", phrase(error.InvalidExpression)); + try testing.expectEqualStrings("no solution found", phrase(error.ConvergenceFailure)); + try testing.expectEqualStrings( + "these values do not determine an answer", + phrase(error.InsufficientParameters), + ); +} diff --git a/engine/src/errors.zig b/engine/src/errors.zig deleted file mode 100644 index 62f4a44..0000000 --- a/engine/src/errors.zig +++ /dev/null @@ -1,136 +0,0 @@ -//! The engine's error vocabulary, and the one place its wording lives. -//! -//! Every engine function returns `CalcError!T`. Errors carry no position or -//! context: an `ErrorInfo` with a source position was once specified and declared, -//! but nothing ever constructed one, and the parser fields that would have fed it -//! were written and never read. Adding position reporting means threading it -//! through the returns, which is a change to make deliberately. - -const std = @import("std"); - -/// All possible engine errors. -pub const CalcError = error{ - // Parser errors - UnexpectedToken, - UnmatchedParen, - InvalidNumber, - UnknownFunction, - UnknownVariable, - UnexpectedEnd, - InvalidExpression, - - // Evaluation errors - DivisionByZero, - Overflow, - InvalidOperandType, - DomainError, - - // Struct layout errors - InvalidType, - InvalidFieldName, - DuplicateFieldName, - StructTooLarge, - - // Financial errors - InsufficientParameters, - ConvergenceFailure, - - // Unit conversion errors - UnknownUnit, - IncompatibleUnits, - - // System - OutOfMemory, -}; - -/// The human-readable phrase for an error, with no prefix and no newline. -/// -/// The single source of these strings. The CLI and the TUI each had their own -/// switch over the same error set, differing only in punctuation and in what they -/// had forgotten: the TUI was missing `InsufficientParameters`, `ConvergenceFailure` -/// and `InvalidExpression` and rendered all three as "evaluation error". Callers add -/// their own decoration ("error: " and a newline for the CLI, "error: " for the -/// TUI), and a view with better context can still override individual cases, as the -/// financial form does. -/// -/// The switch has no `else`, so an error added to the set is a compile error here -/// rather than a silent fallback to a vague phrase. -pub fn errorPhrase(err: CalcError) []const u8 { - return switch (err) { - // Parser - CalcError.UnexpectedToken => "unexpected token", - CalcError.UnmatchedParen => "unmatched parenthesis", - CalcError.InvalidNumber => "invalid number", - CalcError.UnknownFunction => "unknown function", - CalcError.UnknownVariable => "unknown variable", - CalcError.UnexpectedEnd => "unexpected end of expression", - CalcError.InvalidExpression => "invalid expression", - - // Evaluation - CalcError.DivisionByZero => "division by zero", - CalcError.Overflow => "overflow", - CalcError.InvalidOperandType => "invalid operand type", - CalcError.DomainError => "domain error", - - // Struct layout - CalcError.InvalidType => "invalid type", - CalcError.InvalidFieldName => "invalid field name", - CalcError.DuplicateFieldName => "duplicate field name", - CalcError.StructTooLarge => "struct too large", - - // Financial - CalcError.InsufficientParameters => "these values do not determine an answer", - CalcError.ConvergenceFailure => "no solution found", - - // Units - CalcError.UnknownUnit => "unknown unit", - CalcError.IncompatibleUnits => "incompatible units (different categories)", - - // System - CalcError.OutOfMemory => "out of memory", - }; -} - -// -- Tests -- - -const testing = std.testing; - -test "errorPhrase: every error in the set has its own phrase" { - // Exhaustive by construction: the switch in errorPhrase has no else branch, so - // adding an error to CalcError without a phrase is a compile error rather than a - // silent fallback. This walks the set to prove the phrases are distinct and - // non-empty. - const fields = @typeInfo(CalcError).error_set.?; - var seen: [fields.len][]const u8 = undefined; - inline for (fields, 0..) |field, i| { - const phrase = errorPhrase(@field(CalcError, field.name)); - try testing.expect(phrase.len > 0); - // No prefix and no newline: decoration belongs to the caller. - try testing.expect(!std.mem.startsWith(u8, phrase, "error")); - try testing.expect(std.mem.indexOfScalar(u8, phrase, '\n') == null); - seen[i] = phrase; - } - - for (seen, 0..) |phrase, i| { - for (seen[i + 1 ..]) |other| { - if (std.mem.eql(u8, phrase, other)) { - std.debug.print("two errors share the phrase \"{s}\"\n", .{phrase}); - return error.TestUnexpectedResult; - } - } - } -} - -test "errorPhrase: usable at comptime, which is how frontends decorate it" { - const decorated = comptime "error: " ++ errorPhrase(CalcError.DivisionByZero); - try testing.expectEqualStrings("error: division by zero", decorated); -} - -test "errorPhrase: the cases the TUI table used to lose" { - try testing.expectEqualStrings("invalid expression", errorPhrase(CalcError.InvalidExpression)); - try testing.expectEqualStrings("no solution found", errorPhrase(CalcError.ConvergenceFailure)); - try testing.expectEqualStrings( - "these values do not determine an answer", - errorPhrase(CalcError.InsufficientParameters), - ); -} diff --git a/engine/src/evaluator.zig b/engine/src/evaluator.zig index 17cf63f..3dc3521 100644 --- a/engine/src/evaluator.zig +++ b/engine/src/evaluator.zig @@ -13,7 +13,18 @@ const Expr = ast.Expr; const BinaryOp = ast.BinaryOp; const Integer = @import("Integer.zig"); const IntType = Integer.IntType; -const CalcError = @import("errors.zig").CalcError; +/// 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, + UnknownVariable, + DomainError, + Overflow, +} || parser_mod.Error || number_mod.Error || bitwise.Error || financial.Error; const parser_mod = @import("parser.zig"); const Parser = parser_mod.Parser; const number_mod = @import("number.zig"); @@ -122,7 +133,7 @@ pub const Environment = struct { /// /// Task 2.0c replaces this boundary with a `Number`-returning API, which is what /// the remaining integer-precision cases need. -pub fn evaluate(env: *Environment, expr: *const Expr) CalcError!f64 { +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. @@ -136,35 +147,35 @@ pub fn evaluate(env: *Environment, expr: *const Expr) CalcError!f64 { /// The exact evaluation core. Produces a `Number`, staying exact until an /// operation forces the float fallback (see design.md 2.7.4). -fn evalExact(env: *Environment, scratch: Allocator, expr: *const Expr) CalcError!Number { +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 CalcError.InvalidNumber; + if (byte > 0x7F) return Error.InvalidNumber; packed_value = (packed_value << 8) | byte; } - return Number.fromInt(scratch, packed_value) catch |err| return mapError(err); + 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 CalcError.UnknownVariable; - return value.cloneWith(scratch) catch |err| mapError(err); + const value = env.getVar(name) orelse return Error.UnknownVariable; + return try value.cloneWith(scratch); }, .assignment => |a| { const val = try evalExact(env, scratch, a.value); // setVar copies, so storing an arena-allocated value is safe. - env.setVar(a.name, val) catch return CalcError.OutOfMemory; + 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 => Number.negate(scratch, operand) catch |err| mapError(err), + .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 @@ -193,7 +204,7 @@ fn evalExact(env: *Environment, scratch: Allocator, expr: *const Expr) CalcError /// Decimal literals are re-parsed from their source text rather than taken from /// `float_value`, because `float_value` has already rounded: `0.1` cannot be /// recovered from its binary approximation. -fn literalToNumber(scratch: Allocator, n: ast.Expr.Number) CalcError!Number { +fn literalToNumber(scratch: Allocator, n: ast.Expr.Number) Error!Number { if (n.base == .decimal and n.text.len > 0) { if (Number.parse(scratch, n.text)) |value| return value else |_| { // Fall through to the approximations below rather than failing: the @@ -204,25 +215,20 @@ fn literalToNumber(scratch: Allocator, n: ast.Expr.Number) CalcError!Number { // Non-decimal literals are integers; use the exact integer the tokenizer // recovered when it fits, otherwise accept the float approximation. if (n.int_value) |int_val| { - return Number.fromInt(scratch, int_val) catch |err| return mapError(err); + return try Number.fromInt(scratch, int_val); } return Number.fromFloat(n.float_value); } -/// Map the numeric model's errors onto the engine's error set. -/// -/// One mapping, in `number.zig`; this alias keeps the call sites short. -const mapError = number_mod.toCalcError; - /// Evaluate a binary operation. -fn evalBinaryOp(scratch: Allocator, op: BinaryOp, left: Number, right: Number) CalcError!Number { +fn evalBinaryOp(scratch: Allocator, op: BinaryOp, left: Number, right: Number) Error!Number { return switch (op) { - .add => Number.add(scratch, left, right) catch |err| mapError(err), - .sub => Number.sub(scratch, left, right) catch |err| mapError(err), - .mul => Number.mul(scratch, left, right) catch |err| mapError(err), - .div => Number.div(scratch, left, right) catch |err| mapError(err), - .mod => Number.mod(scratch, left, right) catch |err| mapError(err), - .pow => Number.pow(scratch, left, right) catch |err| mapError(err), + .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` @@ -257,12 +263,12 @@ const standard_int_type: IntType = .{}; /// 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 toFixedWidthBits(value: f64) CalcError!u128 { - if (!math.isFinite(value)) return CalcError.DomainError; +fn toFixedWidthBits(value: f64) Error!u128 { + 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 CalcError.Overflow; + return Error.Overflow; } const bits: u64 = @bitCast(@as(i64, @intFromFloat(value))); return @as(u128, bits); @@ -274,39 +280,40 @@ fn fromFixedWidthBits(bits: u128) Number { } /// Evaluate a built-in function call. -fn evalFunction(env: *Environment, scratch: Allocator, name: []const u8, args: []const *Expr) CalcError!Number { +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]); // Functions with an exact implementation. if (std.mem.eql(u8, name, "abs")) { - return Number.abs(scratch, x) catch |err| mapError(err); + return try Number.abs(scratch, x); } if (std.mem.eql(u8, name, "floor")) { - return Number.floor(scratch, x) catch |err| mapError(err); + return try Number.floor(scratch, x); } if (std.mem.eql(u8, name, "ceil")) { - return Number.ceil(scratch, x) catch |err| mapError(err); + return try Number.ceil(scratch, x); } if (std.mem.eql(u8, name, "round")) { - return Number.round(scratch, x) catch |err| mapError(err); + return try Number.round(scratch, x); } if (std.mem.eql(u8, name, "sqrt")) { // The negative-input rule lives in Number.sqrt, which raises - // NegativeRoot; mapError turns that into a domain error. - return Number.sqrt(scratch, x) catch |err| mapError(err); + // 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 = Number.factorial(scratch, x) catch |err| return mapError(err); + 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 CalcError.DomainError; + 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 CalcError.UnknownFunction; + return Error.UnknownFunction; return Number.fromFloat(f); } @@ -316,10 +323,10 @@ fn evalFunction(env: *Environment, scratch: Allocator, name: []const u8, args: [ const b = try evalExact(env, scratch, args[1]); if (std.mem.eql(u8, name, "max")) { - return Number.max(scratch, a, b) catch |err| mapError(err); + return try Number.max(scratch, a, b); } if (std.mem.eql(u8, name, "min")) { - return Number.min(scratch, a, b) catch |err| mapError(err); + return try Number.min(scratch, a, b); } const x = a.toFloat(scratch); @@ -332,7 +339,7 @@ fn evalFunction(env: *Environment, scratch: Allocator, name: []const u8, args: [ } if (std.mem.eql(u8, name, "log")) { // log(value, base) - if (y <= 0 or y == 1 or x <= 0) return CalcError.DomainError; + if (y <= 0 or y == 1 or x <= 0) return Error.DomainError; return Number.fromFloat(@log(x) / @log(y)); } } @@ -361,15 +368,15 @@ fn evalFunction(env: *Environment, scratch: Allocator, name: []const u8, args: [ } } - return CalcError.UnknownFunction; + return Error.UnknownFunction; } /// A whole period count or 1-based period index, validated. -fn periodCount(value: f64) CalcError!usize { - if (!math.isFinite(value)) return CalcError.DomainError; - if (@floor(value) != value) return CalcError.DomainError; +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 CalcError.DomainError; + return Error.DomainError; } return @intFromFloat(value); } @@ -383,7 +390,7 @@ fn periodCount(value: f64) CalcError!usize { /// /// 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) CalcError!?f64 { +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]); @@ -478,16 +485,16 @@ fn evalFinancialFn(name: []const u8, a: []const f64) CalcError!?f64 { /// 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) CalcError!?f64 { +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 CalcError.DomainError; + 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 CalcError.DomainError; + if (x < -1 or x > 1) return Error.DomainError; return math.acos(x); } if (std.mem.eql(u8, name, "atan")) return math.atan(x); @@ -495,15 +502,15 @@ fn evalSingleArgFn(name: []const u8, x: f64) CalcError!?f64 { // 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 CalcError.DomainError; + if (x <= 0) return Error.DomainError; return @log10(x); } if (std.mem.eql(u8, name, "ln")) { - if (x <= 0) return CalcError.DomainError; + if (x <= 0) return Error.DomainError; return @log(x); } if (std.mem.eql(u8, name, "log2")) { - if (x <= 0) return CalcError.DomainError; + if (x <= 0) return Error.DomainError; return @log2(x); } if (std.mem.eql(u8, name, "cbrt")) return math.cbrt(x); @@ -522,14 +529,14 @@ pub const EvalInfo = struct { /// 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) CalcError!Number { +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) CalcError!EvalInfo { +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 @@ -546,9 +553,9 @@ pub fn evalStringInfo(env: *Environment, allocator: Allocator, source: []const u const scratch = arena.allocator(); const raw = try evalExact(env, scratch, expr); - const result = raw.cloneWith(allocator) catch |err| return mapError(err); + const result = try raw.cloneWith(allocator); - env.setAns(result) catch return CalcError.OutOfMemory; + env.setAns(result) catch return Error.OutOfMemory; return .{ .value = result, .has_nondecimal_literal = hasNonDecimalLiteral(expr), @@ -629,7 +636,7 @@ test "eval division" { test "eval division by zero" { const result = testEval("1 / 0"); - try testing.expectError(CalcError.DivisionByZero, result); + try testing.expectError(Error.DivisionByZero, result); } test "eval modulo" { @@ -754,12 +761,12 @@ test "eval min" { test "eval unknown function" { const result = testEval("bogus(1)"); - try testing.expectError(CalcError.UnknownFunction, result); + try testing.expectError(Error.UnknownFunction, result); } test "eval unknown variable" { const result = testEval("xyz"); - try testing.expectError(CalcError.UnknownVariable, result); + try testing.expectError(Error.UnknownVariable, result); } test "eval variable assignment and use" { @@ -906,10 +913,10 @@ test "standard mode: a shift runs to completion instead of wrapping the distance 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(CalcError.DomainError, testEval("8 >> 0 - 1")); - try testing.expectError(CalcError.DomainError, testEval("8 << 0 - 1")); - try testing.expectError(CalcError.DomainError, testEval("8 >>> 0 - 1")); - try testing.expectError(CalcError.DomainError, testEval("8 rol 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 >>> 0 - 1")); + try testing.expectError(Error.DomainError, testEval("8 rol 0 - 1")); } test "standard mode: rotation is cyclic, not clamped" { @@ -965,30 +972,34 @@ test "eval log with base" { test "eval log domain error" { const result = testEval("log(-1, 10)"); - try testing.expectError(CalcError.DomainError, result); + 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(CalcError.DomainError, testEval("asin(2)")); - try testing.expectError(CalcError.DomainError, testEval("asin(-2)")); - try testing.expectError(CalcError.DomainError, testEval("acos(2)")); - try testing.expectError(CalcError.DomainError, testEval("sqrt(-1)")); - try testing.expectError(CalcError.DomainError, testEval("factorial(-1)")); - try testing.expectError(CalcError.DomainError, testEval("factorial(2.5)")); + 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(CalcError.DomainError, testEval("ln(0)")); - try testing.expectError(CalcError.DomainError, testEval("ln(0 - 1)")); - try testing.expectError(CalcError.DomainError, testEval("log(0)")); - try testing.expectError(CalcError.DomainError, testEval("log10(0 - 5)")); - try testing.expectError(CalcError.DomainError, testEval("log2(0)")); - try testing.expectError(CalcError.DomainError, testEval("log(100, 1)")); + 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(CalcError.UnknownFunction, testEval("nope(1)")); - try testing.expectError(CalcError.UnknownFunction, testEval("asin(1, 2)")); + try testing.expectError(Error.UnknownFunction, testEval("nope(1)")); + try testing.expectError(Error.UnknownFunction, testEval("asin(1, 2)")); } test "the functions themselves still work inside their domains" { @@ -1053,7 +1064,7 @@ test "eval string literal multi-char in standard mode" { test "eval string literal with non-ASCII byte errors" { // byte > 0x7F is rejected const result = testEval("'\x80'"); - try testing.expectError(CalcError.InvalidNumber, result); + try testing.expectError(Error.InvalidNumber, result); } test "eval rand zero-arg function returns 0" { @@ -1063,17 +1074,17 @@ test "eval rand zero-arg function returns 0" { test "eval unknown zero-arg function" { const result = testEval("bogus()"); - try testing.expectError(CalcError.UnknownFunction, result); + try testing.expectError(Error.UnknownFunction, result); } test "eval unknown three-arg function" { const result = testEval("bogus(1, 2, 3)"); - try testing.expectError(CalcError.UnknownFunction, result); + try testing.expectError(Error.UnknownFunction, result); } test "eval unknown two-arg function" { const result = testEval("bogus(1, 2)"); - try testing.expectError(CalcError.UnknownFunction, result); + try testing.expectError(Error.UnknownFunction, result); } // -- Exact arithmetic (Task 2.0b) -- @@ -1191,10 +1202,10 @@ test "exact: a transcendental contaminates the rest of the expression" { try testing.expectApproxEqAbs(@as(f64, 0.3), result, 1e-15); } -test "exact: overflow from an absurd exponent is reported as overflow" { - // The exponent guard in the rational layer surfaces as Overflow rather than - // silently producing infinity or exhausting memory. - try testing.expectError(CalcError.Overflow, testEval("2 ^ 3000000")); +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) -- @@ -1430,7 +1441,7 @@ test "financial: apy converts a nominal rate to an effective one" { try testEval("apy(compound_rate(1000, fv(1000, 18, 5, 12), 5, 12), 12)"), 1e-4, ); - try testing.expectError(CalcError.DomainError, testEval("apy(5, 0)")); + try testing.expectError(Error.DomainError, testEval("apy(5, 0)")); } test "financial: the tvm solvers are reachable as four-argument functions" { @@ -1475,26 +1486,26 @@ test "financial: results are inexact, so they do not claim exactness" { test "financial: bad arguments are domain errors, not wrong answers" { // Zero periods. - try testing.expectError(CalcError.DomainError, testEval("cagr(1000, 2000, 0)")); + try testing.expectError(Error.DomainError, testEval("cagr(1000, 2000, 0)")); // A fractional period count cannot index an amortization schedule. - try testing.expectError(CalcError.DomainError, testEval("amort_interest(200000, 0.5, 360.5, 1)")); + try testing.expectError(Error.DomainError, testEval("amort_interest(200000, 0.5, 360.5, 1)")); // Period past the end of the schedule. - try testing.expectError(CalcError.DomainError, testEval("amort_balance(200000, 0.5, 360, 361)")); + try testing.expectError(Error.DomainError, testEval("amort_balance(200000, 0.5, 360, 361)")); // Payments that never retire the loan. - try testing.expectError(CalcError.DomainError, testEval("amort_payment(0, 0.5, 360)")); + try testing.expectError(Error.DomainError, testEval("amort_payment(0, 0.5, 360)")); // Period counts outside the schedule bounds. - try testing.expectError(CalcError.DomainError, testEval("amort_payment(200000, 0.5, 0)")); - try testing.expectError(CalcError.DomainError, testEval("amort_payment(200000, 0.5, 20000)")); - try testing.expectError(CalcError.DomainError, testEval("amort_payment(200000, 0.5, 10^400)")); + 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 "financial: wrong argument counts are unknown functions, not silent defaults" { - try testing.expectError(CalcError.UnknownFunction, testEval("cagr(10000, 25000)")); - try testing.expectError(CalcError.UnknownFunction, testEval("tvm_pmt(360, 0.5, 200000)")); - try testing.expectError(CalcError.UnknownFunction, testEval("amort_payment(200000, 0.5, 360, 1)")); + 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. - try testing.expectError(CalcError.UnknownFunction, testEval("nope(1, 2, 3)")); - try testing.expectError(CalcError.UnknownFunction, testEval("nope(1, 2, 3, 4)")); + try testing.expectError(Error.UnknownFunction, testEval("nope(1, 2, 3)")); + try testing.expectError(Error.UnknownFunction, testEval("nope(1, 2, 3, 4)")); } // -- The AST is not the caller's problem -- @@ -1596,9 +1607,9 @@ test "grouped digits still work, inside and outside a call" { 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(CalcError.UnexpectedToken, testEval("1,00")); - try testing.expectError(CalcError.UnexpectedToken, testEval("1,0000")); - try testing.expectError(CalcError.UnexpectedToken, testEval("2+3,4")); + 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" { @@ -1638,16 +1649,16 @@ test "bitwise operands outside i64 report overflow instead of aborting" { }; for (cases) |source| { const result = testEval(source); - try testing.expectError(CalcError.Overflow, result); + 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(CalcError.DomainError, testEval("~ln(-1)")); - try testing.expectError(CalcError.DomainError, testEval("ln(-1) and 1")); - try testing.expectError(CalcError.DomainError, testEval("1 << ln(-1)")); + 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" { diff --git a/engine/src/financial.zig b/engine/src/financial.zig index 521e37a..b193b0c 100644 --- a/engine/src/financial.zig +++ b/engine/src/financial.zig @@ -27,7 +27,18 @@ const std = @import("std"); const math = std.math; -const CalcError = @import("errors.zig").CalcError; +/// What the financial calculations can fail with. +/// +/// `InsufficientParameters` and `ConvergenceFailure` are theirs alone: no other part +/// of the engine can produce either, and under the old single error set every +/// function in the engine claimed both. +pub const Error = error{ + InsufficientParameters, + ConvergenceFailure, + DomainError, + DivisionByZero, + OutOfMemory, +}; /// Iteration cap for the rate solver. pub const max_iterations: usize = 1000; @@ -40,11 +51,11 @@ pub const tolerance: f64 = 1e-10; /// Compound annual growth rate, as a decimal fraction (0.2011 means 20.11%). /// /// cagr = (end / start)^(1/periods) - 1 -pub fn cagr(start_value: f64, end_value: f64, periods: f64) CalcError!f64 { - if (periods <= 0) return CalcError.DomainError; +pub fn cagr(start_value: f64, end_value: f64, periods: f64) Error!f64 { + if (periods <= 0) return Error.DomainError; // A zero or negative starting value has no meaningful growth rate, and a // negative ending value would need a complex root. - if (start_value <= 0 or end_value < 0) return CalcError.DomainError; + if (start_value <= 0 or end_value < 0) return Error.DomainError; return math.pow(f64, end_value / start_value, 1.0 / periods) - 1.0; } @@ -59,11 +70,11 @@ pub fn compoundFutureValue( annual_rate: f64, years: f64, compounds_per_year: f64, -) CalcError!f64 { - if (compounds_per_year <= 0) return CalcError.DomainError; - if (years < 0) return CalcError.DomainError; +) Error!f64 { + if (compounds_per_year <= 0) return Error.DomainError; + if (years < 0) return Error.DomainError; const periodic = annual_rate / 100.0 / compounds_per_year; - if (periodic <= -1.0) return CalcError.DomainError; + if (periodic <= -1.0) return Error.DomainError; return present_value * math.pow(f64, 1.0 + periodic, compounds_per_year * years); } @@ -74,13 +85,13 @@ pub fn compoundPresentValue( annual_rate: f64, years: f64, compounds_per_year: f64, -) CalcError!f64 { - if (compounds_per_year <= 0) return CalcError.DomainError; - if (years < 0) return CalcError.DomainError; +) Error!f64 { + if (compounds_per_year <= 0) return Error.DomainError; + if (years < 0) return Error.DomainError; const periodic = annual_rate / 100.0 / compounds_per_year; - if (periodic <= -1.0) return CalcError.DomainError; + if (periodic <= -1.0) return Error.DomainError; const factor = math.pow(f64, 1.0 + periodic, compounds_per_year * years); - if (factor == 0) return CalcError.DivisionByZero; + if (factor == 0) return Error.DivisionByZero; return future_value / factor; } @@ -101,21 +112,21 @@ pub fn compoundRate( future_value: f64, years: f64, compounds_per_year: f64, -) CalcError!f64 { - if (compounds_per_year <= 0) return CalcError.DomainError; +) Error!f64 { + if (compounds_per_year <= 0) return Error.DomainError; // With no time elapsed, any rate satisfies pv == fv and none satisfies // pv != fv, so there is no answer to give. - if (years <= 0) return CalcError.DomainError; - if (present_value == 0) return CalcError.DomainError; + if (years <= 0) return Error.DomainError; + if (present_value == 0) return Error.DomainError; const ratio = future_value / present_value; // A sign change has no real root: no rate turns 1000 into -500. - if (!(ratio > 0)) return CalcError.DomainError; + if (!(ratio > 0)) return Error.DomainError; const periods = compounds_per_year * years; const periodic = math.pow(f64, ratio, 1.0 / periods) - 1.0; const rate = periodic * compounds_per_year * 100.0; - if (!math.isFinite(rate)) return CalcError.DomainError; + if (!math.isFinite(rate)) return Error.DomainError; return rate; } @@ -128,23 +139,23 @@ pub fn compoundPeriods( future_value: f64, annual_rate: f64, compounds_per_year: f64, -) CalcError!f64 { - if (compounds_per_year <= 0) return CalcError.DomainError; - if (present_value == 0) return CalcError.DomainError; +) Error!f64 { + if (compounds_per_year <= 0) return Error.DomainError; + if (present_value == 0) return Error.DomainError; const ratio = future_value / present_value; - if (!(ratio > 0)) return CalcError.DomainError; + if (!(ratio > 0)) return Error.DomainError; // Already there, whatever the rate. if (ratio == 1) return 0; const periodic = annual_rate / 100.0 / compounds_per_year; - if (periodic <= -1.0) return CalcError.DomainError; + if (periodic <= -1.0) return Error.DomainError; // A zero rate never moves the balance, so no amount of time reaches a // different future value. - if (periodic == 0) return CalcError.DomainError; + if (periodic == 0) return Error.DomainError; const years = @log(ratio) / @log(1.0 + periodic) / compounds_per_year; - if (!math.isFinite(years)) return CalcError.DomainError; + if (!math.isFinite(years)) return Error.DomainError; return years; } @@ -155,12 +166,12 @@ pub fn compoundPeriods( /// /// 18% compounded monthly is 19.56% effective. Reporting a solved nominal rate /// without this is how rate comparisons go wrong. -pub fn effectiveAnnualRate(annual_rate: f64, compounds_per_year: f64) CalcError!f64 { - if (compounds_per_year <= 0) return CalcError.DomainError; +pub fn effectiveAnnualRate(annual_rate: f64, compounds_per_year: f64) Error!f64 { + if (compounds_per_year <= 0) return Error.DomainError; const periodic = annual_rate / 100.0 / compounds_per_year; - if (periodic <= -1.0) return CalcError.DomainError; + if (periodic <= -1.0) return Error.DomainError; const grown = math.pow(f64, 1.0 + periodic, compounds_per_year); - if (!math.isFinite(grown)) return CalcError.DomainError; + if (!math.isFinite(grown)) return Error.DomainError; return (grown - 1.0) * 100.0; } @@ -226,7 +237,7 @@ fn tvmResidual(rate: f64, periods: f64, pv: f64, pmt: f64, fv: f64, due: bool) f } /// Solve for whichever variable is null. -pub fn solveTvm(params: TvmParams) CalcError!TvmSolution { +pub fn solveTvm(params: TvmParams) Error!TvmSolution { // Exactly one unknown. var unknowns: usize = 0; var which: TvmVariable = .future_value; @@ -250,7 +261,7 @@ pub fn solveTvm(params: TvmParams) CalcError!TvmSolution { unknowns += 1; which = .future_value; } - if (unknowns != 1) return CalcError.InsufficientParameters; + if (unknowns != 1) return Error.InsufficientParameters; return switch (which) { .future_value => .{ .variable = which, .value = try solveFutureValue(params) }, @@ -261,41 +272,41 @@ pub fn solveTvm(params: TvmParams) CalcError!TvmSolution { }; } -fn solveFutureValue(p: TvmParams) CalcError!f64 { +fn solveFutureValue(p: TvmParams) Error!f64 { const r = p.rate.? / 100.0; const n = p.periods.?; - if (r <= -1.0) return CalcError.DomainError; + if (r <= -1.0) return Error.DomainError; return -(p.present_value.? * growth(r, n) + p.payment.? * annuityFactor(r, n, p.due)); } -fn solvePresentValue(p: TvmParams) CalcError!f64 { +fn solvePresentValue(p: TvmParams) Error!f64 { const r = p.rate.? / 100.0; const n = p.periods.?; - if (r <= -1.0) return CalcError.DomainError; + if (r <= -1.0) return Error.DomainError; const g = growth(r, n); - if (g == 0) return CalcError.DivisionByZero; + if (g == 0) return Error.DivisionByZero; return -(p.future_value.? + p.payment.? * annuityFactor(r, n, p.due)) / g; } -fn solvePayment(p: TvmParams) CalcError!f64 { +fn solvePayment(p: TvmParams) Error!f64 { const r = p.rate.? / 100.0; const n = p.periods.?; - if (r <= -1.0) return CalcError.DomainError; + if (r <= -1.0) return Error.DomainError; const af = annuityFactor(r, n, p.due); - if (af == 0) return CalcError.DivisionByZero; + if (af == 0) return Error.DivisionByZero; return -(p.present_value.? * growth(r, n) + p.future_value.?) / af; } -fn solvePeriods(p: TvmParams) CalcError!f64 { +fn solvePeriods(p: TvmParams) Error!f64 { const r = p.rate.? / 100.0; const pv = p.present_value.?; const pmt = p.payment.?; const fv = p.future_value.?; - if (r <= -1.0) return CalcError.DomainError; + if (r <= -1.0) return Error.DomainError; // With no interest the equation is linear: pv + pmt*n + fv = 0. if (r == 0) { - if (pmt == 0) return CalcError.InsufficientParameters; + if (pmt == 0) return Error.InsufficientParameters; return -(pv + fv) / pmt; } @@ -304,14 +315,14 @@ fn solvePeriods(p: TvmParams) CalcError!f64 { const d: f64 = if (p.due) 1.0 + r else 1.0; const a = pmt * d / r; const denominator = pv + a; - if (denominator == 0) return CalcError.DivisionByZero; + if (denominator == 0) return Error.DivisionByZero; const g = (a - fv) / denominator; // A non-positive growth factor has no real logarithm: the cash flows cannot // reach the requested future value at this rate. - if (g <= 0) return CalcError.DomainError; + if (g <= 0) return Error.DomainError; const base = 1.0 + r; - if (base <= 0) return CalcError.DomainError; + if (base <= 0) return Error.DomainError; return @log(g) / @log(base); } @@ -343,15 +354,15 @@ fn solvePeriods(p: TvmParams) CalcError!f64 { /// input that does have an answer (n=360 with r near 6% and a matching payment /// is one). Fixing that means scaling by the computed terms rather than by the /// inputs, which changes acceptance for every case and needs its own testing. -fn solveRate(p: TvmParams) CalcError!TvmSolution { +fn solveRate(p: TvmParams) Error!TvmSolution { const n = p.periods.?; const pv = p.present_value.?; const pmt = p.payment.?; const fv = p.future_value.?; - if (n <= 0) return CalcError.DomainError; + if (n <= 0) return Error.DomainError; // A sign change in the cash flows is necessary for a solution to exist. - if (pv == 0 and pmt == 0 and fv == 0) return CalcError.InsufficientParameters; + if (pv == 0 and pmt == 0 and fv == 0) return Error.InsufficientParameters; // Residuals are proportional to the size of the cash flows, so the // acceptance threshold has to be too. @@ -396,7 +407,7 @@ fn solveRate(p: TvmParams) CalcError!TvmSolution { r = next; } } - return CalcError.ConvergenceFailure; + return Error.ConvergenceFailure; } // -- Money rounding -- @@ -500,15 +511,15 @@ pub const AmortizationTotals = struct { }; /// The level payment implied by a loan, as a positive amount. -pub fn amortizationPayment(p: AmortizationParams) CalcError!f64 { - if (p.principal <= 0) return CalcError.DomainError; - if (p.periods == 0 or p.periods > max_schedule_periods) return CalcError.DomainError; +pub fn amortizationPayment(p: AmortizationParams) Error!f64 { + if (p.principal <= 0) return Error.DomainError; + if (p.periods == 0 or p.periods > max_schedule_periods) return Error.DomainError; // A negative rate would mean the balance shrinks on its own, which is not // something an amortization table describes. - if (p.rate < 0) return CalcError.DomainError; + if (p.rate < 0) return Error.DomainError; if (p.payment) |given| { - if (given <= 0) return CalcError.DomainError; + if (given <= 0) return Error.DomainError; return if (p.round_cents) roundToCents(given) else given; } @@ -521,7 +532,7 @@ pub fn amortizationPayment(p: AmortizationParams) CalcError!f64 { // solveTvm returns the payment as a cash outflow; a schedule wants the // magnitude. const amount = -solution.value; - if (!math.isFinite(amount) or amount <= 0) return CalcError.DomainError; + if (!math.isFinite(amount) or amount <= 0) return Error.DomainError; return if (p.round_cents) roundToCents(amount) else amount; } @@ -537,7 +548,7 @@ const AmortizationCursor = struct { balance: f64, period: usize = 0, - fn init(p: AmortizationParams) CalcError!AmortizationCursor { + fn init(p: AmortizationParams) Error!AmortizationCursor { const payment = try amortizationPayment(p); const rate = p.rate / 100.0; const balance = if (p.round_cents) roundToCents(p.principal) else p.principal; @@ -546,7 +557,7 @@ const AmortizationCursor = struct { // reduces the balance: the loan grows forever, and there is no schedule // to print. const first_interest = balance * rate; - if (payment <= first_interest) return CalcError.DomainError; + if (payment <= first_interest) return Error.DomainError; return .{ .params = p, .payment = payment, .rate = rate, .balance = balance }; } @@ -584,21 +595,21 @@ const AmortizationCursor = struct { }; /// A single period of a schedule, without building the whole table. -pub fn amortizationEntry(p: AmortizationParams, period: usize) CalcError!AmortizationEntry { - if (period == 0) return CalcError.DomainError; +pub fn amortizationEntry(p: AmortizationParams, period: usize) Error!AmortizationEntry { + if (period == 0) return Error.DomainError; var cursor = try AmortizationCursor.init(p); while (cursor.next()) |entry| { if (entry.period == period) return entry; } // The loan was retired before this period, so the period does not exist. - return CalcError.DomainError; + return Error.DomainError; } /// The full schedule. Caller owns the returned slice. pub fn amortizationSchedule( allocator: std.mem.Allocator, p: AmortizationParams, -) CalcError![]AmortizationEntry { +) Error![]AmortizationEntry { var cursor = try AmortizationCursor.init(p); var rows: std.ArrayList(AmortizationEntry) = .empty; errdefer rows.deinit(allocator); @@ -609,7 +620,7 @@ pub fn amortizationSchedule( } /// Schedule totals, computed without allocating a table. -pub fn amortizationTotals(p: AmortizationParams) CalcError!AmortizationTotals { +pub fn amortizationTotals(p: AmortizationParams) Error!AmortizationTotals { var cursor = try AmortizationCursor.init(p); var totals: AmortizationTotals = .{ .periods = 0, .paid = 0, .interest = 0, .principal = 0 }; while (cursor.next()) |entry| { @@ -661,11 +672,11 @@ test "cagr: single period is the simple return" { } test "cagr: domain errors" { - try testing.expectError(CalcError.DomainError, cagr(1000, 2000, 0)); - try testing.expectError(CalcError.DomainError, cagr(1000, 2000, -5)); - try testing.expectError(CalcError.DomainError, cagr(0, 2000, 5)); - try testing.expectError(CalcError.DomainError, cagr(-1000, 2000, 5)); - try testing.expectError(CalcError.DomainError, cagr(1000, -1, 5)); + try testing.expectError(Error.DomainError, cagr(1000, 2000, 0)); + try testing.expectError(Error.DomainError, cagr(1000, 2000, -5)); + try testing.expectError(Error.DomainError, cagr(0, 2000, 5)); + try testing.expectError(Error.DomainError, cagr(-1000, 2000, 5)); + try testing.expectError(Error.DomainError, cagr(1000, -1, 5)); } test "compound interest: annual compounding" { @@ -707,11 +718,11 @@ test "compound interest: present value textbook figure" { } test "compound interest: domain errors" { - try testing.expectError(CalcError.DomainError, compoundFutureValue(1000, 5, 10, 0)); - try testing.expectError(CalcError.DomainError, compoundFutureValue(1000, 5, -1, 12)); - try testing.expectError(CalcError.DomainError, compoundPresentValue(1000, 5, 10, 0)); + try testing.expectError(Error.DomainError, compoundFutureValue(1000, 5, 10, 0)); + try testing.expectError(Error.DomainError, compoundFutureValue(1000, 5, -1, 12)); + try testing.expectError(Error.DomainError, compoundPresentValue(1000, 5, 10, 0)); // A rate of -100% per period wipes the base out entirely. - try testing.expectError(CalcError.DomainError, compoundFutureValue(1000, -1200, 10, 12)); + try testing.expectError(Error.DomainError, compoundFutureValue(1000, -1200, 10, 12)); } test "tvm: solve payment for a classic 30-year mortgage" { @@ -880,7 +891,7 @@ test "tvm: annuity due round-trips too" { test "tvm: requires exactly one unknown" { // All five supplied. - try testing.expectError(CalcError.InsufficientParameters, solveTvm(.{ + try testing.expectError(Error.InsufficientParameters, solveTvm(.{ .periods = 10, .rate = 5, .present_value = 100, @@ -888,13 +899,13 @@ test "tvm: requires exactly one unknown" { .future_value = 0, })); // Two unknowns. - try testing.expectError(CalcError.InsufficientParameters, solveTvm(.{ + try testing.expectError(Error.InsufficientParameters, solveTvm(.{ .periods = 10, .rate = 5, .present_value = 100, })); // Nothing supplied at all. - try testing.expectError(CalcError.InsufficientParameters, solveTvm(.{})); + try testing.expectError(Error.InsufficientParameters, solveTvm(.{})); } test "tvm: unsolvable cash flows report convergence failure, not a wrong answer" { @@ -905,17 +916,17 @@ test "tvm: unsolvable cash flows report convergence failure, not a wrong answer" .payment = 100, .future_value = 5000, }); - try testing.expectError(CalcError.ConvergenceFailure, result); + try testing.expectError(Error.ConvergenceFailure, result); } test "tvm: rate solver rejects degenerate input" { - try testing.expectError(CalcError.InsufficientParameters, solveTvm(.{ + try testing.expectError(Error.InsufficientParameters, solveTvm(.{ .periods = 10, .present_value = 0, .payment = 0, .future_value = 0, })); - try testing.expectError(CalcError.DomainError, solveTvm(.{ + try testing.expectError(Error.DomainError, solveTvm(.{ .periods = 0, .present_value = -100, .payment = 0, @@ -925,7 +936,7 @@ test "tvm: rate solver rejects degenerate input" { test "tvm: unreachable future value has no real period count" { // Paying nothing can never grow 1000 into 5000. - try testing.expectError(CalcError.DomainError, solveTvm(.{ + try testing.expectError(Error.DomainError, solveTvm(.{ .rate = 5, .present_value = 1000, .payment = 0, @@ -934,7 +945,7 @@ test "tvm: unreachable future value has no real period count" { } test "tvm: periods with zero rate and zero payment is unsolvable" { - try testing.expectError(CalcError.InsufficientParameters, solveTvm(.{ + try testing.expectError(Error.InsufficientParameters, solveTvm(.{ .rate = 0, .present_value = 1000, .payment = 0, @@ -1181,13 +1192,13 @@ test "amortization: an underfunded term ends in a balloon payment" { test "amortization: a payment below the first interest charge is rejected" { // 1000 of interest in month one, so 500 never touches principal. - try testing.expectError(CalcError.DomainError, amortizationPayment(.{ + try testing.expectError(Error.DomainError, amortizationPayment(.{ .principal = 200000, .rate = 0.5, .periods = 360, .payment = -1, })); - try testing.expectError(CalcError.DomainError, amortizationEntry(.{ + try testing.expectError(Error.DomainError, amortizationEntry(.{ .principal = 200000, .rate = 0.5, .periods = 360, @@ -1196,22 +1207,22 @@ test "amortization: a payment below the first interest charge is rejected" { } test "amortization: rejects nonsense loan terms" { - try testing.expectError(CalcError.DomainError, amortizationPayment(.{ + try testing.expectError(Error.DomainError, amortizationPayment(.{ .principal = 0, .rate = 0.5, .periods = 12, })); - try testing.expectError(CalcError.DomainError, amortizationPayment(.{ + try testing.expectError(Error.DomainError, amortizationPayment(.{ .principal = 1000, .rate = 0.5, .periods = 0, })); - try testing.expectError(CalcError.DomainError, amortizationPayment(.{ + try testing.expectError(Error.DomainError, amortizationPayment(.{ .principal = 1000, .rate = -1, .periods = 12, })); - try testing.expectError(CalcError.DomainError, amortizationPayment(.{ + try testing.expectError(Error.DomainError, amortizationPayment(.{ .principal = 1000, .rate = 0.5, .periods = max_schedule_periods + 1, @@ -1220,8 +1231,8 @@ test "amortization: rejects nonsense loan terms" { test "amortization: periods outside the schedule are an error" { const params: AmortizationParams = .{ .principal = 1200, .rate = 0, .periods = 12 }; - try testing.expectError(CalcError.DomainError, amortizationEntry(params, 0)); - try testing.expectError(CalcError.DomainError, amortizationEntry(params, 13)); + try testing.expectError(Error.DomainError, amortizationEntry(params, 0)); + try testing.expectError(Error.DomainError, amortizationEntry(params, 13)); } test "amortization: unrounded mode keeps full precision" { @@ -1252,7 +1263,7 @@ test "amortization: schedule allocation failure frees the partial table" { allocator.free(rows); return; } else |err| { - try testing.expectEqual(CalcError.OutOfMemory, err); + try testing.expectEqual(Error.OutOfMemory, err); } } return error.AllocationSweepNeverCompleted; @@ -1300,18 +1311,18 @@ test "compoundRate: unchanged value is a zero rate" { test "compoundRate: domain errors" { // No time elapsed: nothing to solve. - try testing.expectError(CalcError.DomainError, compoundRate(1000, 2000, 0, 1)); - try testing.expectError(CalcError.DomainError, compoundRate(1000, 2000, -5, 1)); + try testing.expectError(Error.DomainError, compoundRate(1000, 2000, 0, 1)); + try testing.expectError(Error.DomainError, compoundRate(1000, 2000, -5, 1)); // No compounding frequency. - try testing.expectError(CalcError.DomainError, compoundRate(1000, 2000, 10, 0)); - try testing.expectError(CalcError.DomainError, compoundRate(1000, 2000, 10, -12)); + try testing.expectError(Error.DomainError, compoundRate(1000, 2000, 10, 0)); + try testing.expectError(Error.DomainError, compoundRate(1000, 2000, 10, -12)); // Nothing to grow from. - try testing.expectError(CalcError.DomainError, compoundRate(0, 2000, 10, 1)); + try testing.expectError(Error.DomainError, compoundRate(0, 2000, 10, 1)); // A sign change has no real root. - try testing.expectError(CalcError.DomainError, compoundRate(1000, -500, 10, 1)); - try testing.expectError(CalcError.DomainError, compoundRate(-1000, 500, 10, 1)); + try testing.expectError(Error.DomainError, compoundRate(1000, -500, 10, 1)); + try testing.expectError(Error.DomainError, compoundRate(-1000, 500, 10, 1)); // Reaching exactly zero would need a rate of -100%, which is a limit. - try testing.expectError(CalcError.DomainError, compoundRate(1000, 0, 10, 1)); + try testing.expectError(Error.DomainError, compoundRate(1000, 0, 10, 1)); } test "compoundPeriods: inverts compoundFutureValue" { @@ -1350,13 +1361,13 @@ test "compoundPeriods: already there takes no time at all" { test "compoundPeriods: domain errors" { // A zero rate never reaches a different value. - try testing.expectError(CalcError.DomainError, compoundPeriods(1000, 2000, 0, 1)); + try testing.expectError(Error.DomainError, compoundPeriods(1000, 2000, 0, 1)); // -100% or worse is not a rate. - try testing.expectError(CalcError.DomainError, compoundPeriods(1000, 2000, -100, 1)); - try testing.expectError(CalcError.DomainError, compoundPeriods(1000, 2000, -150, 1)); - try testing.expectError(CalcError.DomainError, compoundPeriods(1000, 2000, 5, 0)); - try testing.expectError(CalcError.DomainError, compoundPeriods(0, 2000, 5, 1)); - try testing.expectError(CalcError.DomainError, compoundPeriods(1000, -2000, 5, 1)); + try testing.expectError(Error.DomainError, compoundPeriods(1000, 2000, -100, 1)); + try testing.expectError(Error.DomainError, compoundPeriods(1000, 2000, -150, 1)); + try testing.expectError(Error.DomainError, compoundPeriods(1000, 2000, 5, 0)); + try testing.expectError(Error.DomainError, compoundPeriods(0, 2000, 5, 1)); + try testing.expectError(Error.DomainError, compoundPeriods(1000, -2000, 5, 1)); } test "effectiveAnnualRate: monthly compounding beats its nominal rate" { @@ -1376,8 +1387,8 @@ test "effectiveAnnualRate: a negative nominal rate stays negative" { } test "effectiveAnnualRate: domain errors" { - try testing.expectError(CalcError.DomainError, effectiveAnnualRate(5, 0)); - try testing.expectError(CalcError.DomainError, effectiveAnnualRate(-100, 1)); + try testing.expectError(Error.DomainError, effectiveAnnualRate(5, 0)); + try testing.expectError(Error.DomainError, effectiveAnnualRate(-100, 1)); } test "compound interest: the four variables round-trip through each other" { diff --git a/engine/src/number.zig b/engine/src/number.zig index 5b0e764..6984b18 100644 --- a/engine/src/number.zig +++ b/engine/src/number.zig @@ -19,29 +19,17 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const rational = @import("rational.zig"); -const Rational = rational.Rational; -const CalcError = @import("errors.zig").CalcError; +const Rational = @import("Rational.zig"); -/// Map a numeric-model error onto the engine's error set. +/// The errors arithmetic on `Number` can produce, which are `Rational`'s: this tier +/// adds no failure of its own. /// -/// Lives here so there is one mapping. The evaluator and the unit converter each -/// had their own copy, which is how they came to disagree: one turned -/// `ExponentTooLarge` into `Overflow` and neither knew what to do with a newly -/// added member until the compiler complained in two places. -pub fn toCalcError(err: Error) CalcError { - return switch (err) { - error.OutOfMemory => CalcError.OutOfMemory, - error.DivisionByZero => CalcError.DivisionByZero, - error.InvalidNumber => CalcError.InvalidNumber, - // An exponent too large to compute is an overflow from the caller's view. - error.ExponentTooLarge => CalcError.Overflow, - // The square root of a negative value is outside the domain. - error.NegativeRoot => CalcError.DomainError, - }; -} - -pub const Error = rational.Error; +/// There used to be a `toCalcError` here translating these into a single engine-wide +/// error set, because every module returned that one set. The names it translated +/// away were the more useful ones: `ExponentTooLarge` became `Overflow` and +/// `NegativeRoot` became `DomainError`, so `sqrt(-1)` reported "domain error" when +/// the engine knew exactly what was wrong. +pub const Error = Rational.Error; /// Denominator size at which an exact result is demoted to inexact. /// @@ -617,31 +605,12 @@ test "sqrt: a negative input is a domain error, not a silent NaN" { try testing.expect(root_zero.isExact()); } -test "sqrt: the domain error reaches the engine error set as a domain error" { +test "sqrt: a negative input surfaces as NegativeRoot, not a vaguer error" { var neg = try Number.fromInt(alloc, -4); defer neg.deinit(); try testing.expectError(Error.NegativeRoot, Number.sqrt(alloc, neg)); } -test "toCalcError maps every numeric error, with no default" { - const CalcErr = @import("errors.zig").CalcError; - // One mapping for the evaluator and the unit converter, which used to have a - // copy each. Walking the whole set keeps the two tiers of error vocabulary - // lined up: a member added to Error has to be given a CalcError here. - try testing.expectEqual(CalcErr.OutOfMemory, toCalcError(Error.OutOfMemory)); - try testing.expectEqual(CalcErr.DivisionByZero, toCalcError(Error.DivisionByZero)); - try testing.expectEqual(CalcErr.InvalidNumber, toCalcError(Error.InvalidNumber)); - try testing.expectEqual(CalcErr.Overflow, toCalcError(Error.ExponentTooLarge)); - try testing.expectEqual(CalcErr.DomainError, toCalcError(Error.NegativeRoot)); - - inline for (@typeInfo(Error).error_set.?) |field| { - // Every member is handled: this would not compile past an unhandled one, - // and every mapping lands in the engine's error set. - const mapped = toCalcError(@field(Error, field.name)); - try testing.expect(@TypeOf(mapped) == CalcErr); - } -} - test "asExactInt" { var a = try Number.fromInt(alloc, 42); defer a.deinit(); @@ -911,7 +880,7 @@ test "max and min with an inexact operand return that operand as-is" { // -- Allocation-failure safety -- // -// Mirrors the sweep in rational.zig: fail the Nth allocation for every N, and +// Mirrors the sweep in Rational.zig: fail the Nth allocation for every N, and // let testing.allocator's leak detection verify that partially-built values are // released. This is what actually validates the cleanup paths; merely executing // them proves nothing. diff --git a/engine/src/parser.zig b/engine/src/parser.zig index 7f8bc45..37a76a9 100644 --- a/engine/src/parser.zig +++ b/engine/src/parser.zig @@ -20,7 +20,21 @@ const Tokenizer = tokenizer_mod.Tokenizer; const TokenKind = tokenizer_mod.TokenKind; const Token = tokenizer_mod.Token; const parseNumber = tokenizer_mod.parseNumber; -const CalcError = @import("errors.zig").CalcError; + +/// What parsing can fail with. +/// +/// Declared here rather than shared: the parser used to return a single engine-wide +/// error set, so its signature claimed it might return `ConvergenceFailure`, +/// `UnknownUnit` and `StructTooLarge`. No caller could switch on what a parse can +/// actually produce. +pub const Error = error{ + UnexpectedToken, + UnmatchedParen, + UnexpectedEnd, + InvalidExpression, + InvalidNumber, + OutOfMemory, +}; /// Precedence levels (higher = tighter binding). /// @@ -92,19 +106,19 @@ pub const Parser = struct { /// path below frees what it built. Without that, a single typo in an /// interactive session leaks the partial tree, which is exactly what the TUI /// does on every keystroke-completed expression. - pub fn parse(self: *Parser) CalcError!*Expr { + pub fn parse(self: *Parser) Error!*Expr { const expr = try self.parseExpr(.none); if (self.current.kind != .eof) { // Trailing tokens: the tree parsed so far is unreachable. freeExpr(self.allocator, expr); - return CalcError.UnexpectedToken; + return Error.UnexpectedToken; } return expr; } /// Parse an expression with the given minimum precedence. - fn parseExpr(self: *Parser, min_prec: Prec) CalcError!*Expr { - if (self.nest_depth >= max_nest_depth) return CalcError.InvalidExpression; + fn parseExpr(self: *Parser, min_prec: Prec) Error!*Expr { + if (self.nest_depth >= max_nest_depth) return Error.InvalidExpression; self.nest_depth += 1; defer self.nest_depth -= 1; @@ -124,13 +138,13 @@ pub const Parser = struct { } /// Parse a prefix expression (number, identifier, unary op, parenthesized). - fn parsePrefix(self: *Parser) CalcError!*Expr { + fn parsePrefix(self: *Parser) Error!*Expr { const tok = self.current; switch (tok.kind) { .number => { self.advance(); const text = tok.text(self.source); - const num = parseNumber(text) catch return CalcError.InvalidNumber; + const num = parseNumber(text) catch return Error.InvalidNumber; return self.makeNode(.{ .number = .{ .float_value = num.float, .int_value = num.int_value, @@ -142,7 +156,7 @@ pub const Parser = struct { self.advance(); const text = tok.text(self.source); // Strip quotes: 'abc' -> abc - if (text.len < 2) return CalcError.InvalidNumber; + if (text.len < 2) return Error.InvalidNumber; const content = text[1 .. text.len - 1]; return self.makeNode(.{ .string_literal = content }); }, @@ -183,7 +197,7 @@ pub const Parser = struct { const first_arg = try self.parseExpr(.none); args.append(self.allocator, first_arg) catch { freeExpr(self.allocator, first_arg); - return CalcError.OutOfMemory; + return Error.OutOfMemory; }; while (self.current.kind == .comma) { @@ -191,18 +205,18 @@ pub const Parser = struct { const arg = try self.parseExpr(.none); args.append(self.allocator, arg) catch { freeExpr(self.allocator, arg); - return CalcError.OutOfMemory; + return Error.OutOfMemory; }; } } if (self.current.kind != .right_paren) { - return CalcError.UnmatchedParen; + return Error.UnmatchedParen; } self.advance(); // consume ) const args_slice = self.allocator.dupe(*Expr, args.items) catch - return CalcError.OutOfMemory; + return Error.OutOfMemory; errdefer self.allocator.free(args_slice); return self.makeNode(.{ .call = .{ @@ -221,7 +235,7 @@ pub const Parser = struct { const inner = try self.parseExpr(.none); if (self.current.kind != .right_paren) { freeExpr(self.allocator, inner); - return CalcError.UnmatchedParen; + return Error.UnmatchedParen; } self.advance(); // consume ) return inner; @@ -245,16 +259,16 @@ pub const Parser = struct { } }); }, .eof => { - return CalcError.UnexpectedEnd; + return Error.UnexpectedEnd; }, else => { - return CalcError.UnexpectedToken; + return Error.UnexpectedToken; }, } } /// Parse an infix expression given the left-hand side and precedence. - fn parseInfix(self: *Parser, left: *Expr, prec: Prec) CalcError!*Expr { + fn parseInfix(self: *Parser, left: *Expr, prec: Prec) Error!*Expr { const tok = self.current; // Handle keyword operators (rol, ror, and, or, xor) @@ -276,7 +290,7 @@ pub const Parser = struct { self.advance(); const op = self.tokenToBinaryOp(tok.kind) orelse { - return CalcError.UnexpectedToken; + return Error.UnexpectedToken; }; // Right-associative for power @@ -352,13 +366,13 @@ pub const Parser = struct { self.current = self.tokenizer.next(); } - fn makeNode(self: *Parser, expr: Expr) CalcError!*Expr { + fn makeNode(self: *Parser, expr: Expr) Error!*Expr { // Budget checked here so every construction site is covered by one test. if (self.node_count >= max_nodes) { - return CalcError.InvalidExpression; + return Error.InvalidExpression; } self.node_count += 1; - const node = self.allocator.create(Expr) catch return CalcError.OutOfMemory; + const node = self.allocator.create(Expr) catch return Error.OutOfMemory; node.* = expr; return node; } @@ -378,7 +392,7 @@ fn testParse(source: []const u8) !*Expr { return parser.parse(); } -fn testParseArena(source: []const u8) CalcError!*Expr { +fn testParseArena(source: []const u8) Error!*Expr { const alloc = test_arena_instance.allocator(); var p = Parser.init(alloc, source); return p.parse(); @@ -501,19 +515,19 @@ test "parse assignment" { test "parse adjacent number and identifier is an error (no implicit mul)" { defer _ = test_arena_instance.reset(.retain_capacity); const result = testParseArena("2pi"); - try testing.expectError(CalcError.UnexpectedToken, result); + try testing.expectError(Error.UnexpectedToken, result); } test "parse adjacent number and paren is an error (no implicit mul)" { defer _ = test_arena_instance.reset(.retain_capacity); const result = testParseArena("3(4+5)"); - try testing.expectError(CalcError.UnexpectedToken, result); + try testing.expectError(Error.UnexpectedToken, result); } test "parse adjacent paren paren is an error (no implicit mul)" { defer _ = test_arena_instance.reset(.retain_capacity); const result = testParseArena("(2)(3)"); - try testing.expectError(CalcError.UnexpectedToken, result); + try testing.expectError(Error.UnexpectedToken, result); } test "parse caret is power in programmer mode (not XOR)" { @@ -581,20 +595,20 @@ test "parse bitwise not" { test "parse error: unmatched paren" { defer _ = test_arena_instance.reset(.retain_capacity); const result = testParseArena("(2 + 3"); - try testing.expectError(CalcError.UnmatchedParen, result); + try testing.expectError(Error.UnmatchedParen, result); } test "parse error: unexpected token" { defer _ = test_arena_instance.reset(.retain_capacity); const result = testParseArena("+ +"); // + at start is not a valid prefix - try testing.expectError(CalcError.UnexpectedToken, result); + try testing.expectError(Error.UnexpectedToken, result); } test "parse error: empty expression" { defer _ = test_arena_instance.reset(.retain_capacity); const result = testParseArena(""); - try testing.expectError(CalcError.UnexpectedEnd, result); + try testing.expectError(Error.UnexpectedEnd, result); } test "parse complex expression" { @@ -617,7 +631,7 @@ test "parse nested function calls" { test "parse error: unmatched paren in function call args" { defer _ = test_arena_instance.reset(.retain_capacity); const result = testParseArena("max(1, 2"); - try testing.expectError(CalcError.UnmatchedParen, result); + try testing.expectError(Error.UnmatchedParen, result); } test "parse error: identifier in infix position (not a keyword op)" { @@ -626,7 +640,7 @@ test "parse error: identifier in infix position (not a keyword op)" { // parse() reports the leftover token as unexpected. defer _ = test_arena_instance.reset(.retain_capacity); const result = testParseArena("5 foo"); - try testing.expectError(CalcError.UnexpectedToken, result); + try testing.expectError(Error.UnexpectedToken, result); } // -- Ownership on the error paths -- @@ -725,7 +739,7 @@ test "an allocation failure mid-parse frees whatever was built" { freeExpr(allocator, expr); break; } else |err| { - try testing.expectEqual(CalcError.OutOfMemory, err); + try testing.expectEqual(Error.OutOfMemory, err); } } else { return error.AllocationSweepNeverCompleted; @@ -751,7 +765,7 @@ test "a tree larger than the node budget is rejected, not built" { } var parser = Parser.init(testing.allocator, over.items); - try testing.expectError(CalcError.InvalidExpression, parser.parse()); + try testing.expectError(Error.InvalidExpression, parser.parse()); // Nothing is left allocated: testing.allocator would report a leak otherwise. } @@ -777,7 +791,7 @@ test "nesting deeper than the depth limit is rejected" { for (0..Parser.max_nest_depth + 10) |_| try deep.append(testing.allocator, ')'); var parser = Parser.init(testing.allocator, deep.items); - try testing.expectError(CalcError.InvalidExpression, parser.parse()); + try testing.expectError(Error.InvalidExpression, parser.parse()); } test "nesting within the depth limit parses" { @@ -811,7 +825,7 @@ test "the depth limit also covers nested calls and unary operators" { for (0..Parser.max_nest_depth + 10) |_| try deep.append(testing.allocator, ')'); var parser = Parser.init(testing.allocator, deep.items); - try testing.expectError(CalcError.InvalidExpression, parser.parse()); + try testing.expectError(Error.InvalidExpression, parser.parse()); var unary = std.ArrayList(u8).empty; defer unary.deinit(testing.allocator); @@ -819,5 +833,5 @@ test "the depth limit also covers nested calls and unary operators" { try unary.append(testing.allocator, '1'); var unary_parser = Parser.init(testing.allocator, unary.items); - try testing.expectError(CalcError.InvalidExpression, unary_parser.parse()); + try testing.expectError(Error.InvalidExpression, unary_parser.parse()); } diff --git a/engine/src/programmer.zig b/engine/src/programmer.zig index 43b9375..6096ad4 100644 --- a/engine/src/programmer.zig +++ b/engine/src/programmer.zig @@ -13,7 +13,18 @@ const BinaryOp = ast.BinaryOp; const Integer = @import("Integer.zig"); const IntType = Integer.IntType; const Endianness = std.builtin.Endian; -const CalcError = @import("errors.zig").CalcError; + +/// What programmer-mode evaluation can fail with: the parse, the fixed-width +/// operators, and its own arithmetic and name errors. +pub const Error = error{ + DivisionByZero, + DomainError, + InvalidNumber, + InvalidOperandType, + Overflow, + UnknownFunction, + UnknownVariable, +} || parser_mod.Error || bitwise.Error; const parser_mod = @import("parser.zig"); const Parser = parser_mod.Parser; const bitwise = @import("bitwise.zig"); @@ -29,7 +40,7 @@ pub const Config = struct { }; /// Evaluate an AST in programmer mode, producing an exact integer result. -pub fn evalProgrammer(config: Config, expr: *const Expr) CalcError!Integer { +pub fn evalProgrammer(config: Config, expr: *const Expr) Error!Integer { return .{ .raw = try evalExpr(config, expr), .int_type = config.int_type, @@ -37,7 +48,7 @@ pub fn evalProgrammer(config: Config, expr: *const Expr) CalcError!Integer { } /// Recursively evaluate an expression to a raw u128. -fn evalExpr(config: Config, expr: *const Expr) CalcError!u128 { +fn evalExpr(config: Config, expr: *const Expr) Error!u128 { switch (expr.*) { .number => |n| { if (n.int_value) |int_val| { @@ -51,8 +62,8 @@ fn evalExpr(config: Config, expr: *const Expr) CalcError!u128 { // value is illegal behaviour, and `tally -p '1e40'` aborted the process // before this guard existed. const value = n.float_value; - if (!std.math.isFinite(value) or value < 0) return CalcError.DomainError; - if (value >= 340282366920938463463374607431768211456.0) return CalcError.Overflow; + if (!std.math.isFinite(value) or value < 0) return Error.DomainError; + if (value >= 340282366920938463463374607431768211456.0) return Error.Overflow; const val: u128 = @intFromFloat(value); return val & config.int_type.mask(); }, @@ -60,19 +71,19 @@ fn evalExpr(config: Config, expr: *const Expr) CalcError!u128 { // Pack ASCII bytes into integer. // Big-endian packing: first char -> most significant used byte. const max_bytes = @as(usize, config.int_type.bits()) / 8; - if (text.len > max_bytes) return CalcError.Overflow; + if (text.len > max_bytes) return Error.Overflow; var result: u128 = 0; for (text) |byte| { - if (byte > 0x7F) return CalcError.InvalidNumber; + if (byte > 0x7F) return Error.InvalidNumber; result = (result << 8) | byte; } return result & config.int_type.mask(); }, .variable => { - return CalcError.UnknownVariable; + return Error.UnknownVariable; }, .assignment => { - return CalcError.InvalidOperandType; + return Error.InvalidOperandType; }, .unary => |u| { const operand = try evalExpr(config, u.operand); @@ -89,7 +100,7 @@ fn evalExpr(config: Config, expr: *const Expr) CalcError!u128 { }, .call => { // No function calls in programmer mode - return CalcError.UnknownFunction; + return Error.UnknownFunction; }, } } @@ -100,7 +111,7 @@ fn evalExpr(config: Config, expr: *const Expr) CalcError!u128 { /// `bitwise.zig`, which standard mode uses too, so the two modes cannot drift /// apart again. What remains is the arithmetic, which genuinely differs between the /// modes: it wraps at the width here and is exact rational arithmetic there. -fn evalBinaryOp(config: Config, op: BinaryOp, left: u128, right: u128) CalcError!u128 { +fn evalBinaryOp(config: Config, op: BinaryOp, left: u128, right: u128) Error!u128 { const mask = config.int_type.mask(); const result: u128 = switch (op) { @@ -108,11 +119,11 @@ fn evalBinaryOp(config: Config, op: BinaryOp, left: u128, right: u128) CalcError .sub => (left -% right) & mask, .mul => (left *% right) & mask, .div => blk: { - if (right == 0) return CalcError.DivisionByZero; + if (right == 0) return Error.DivisionByZero; break :blk (left / right) & mask; }, .mod => blk: { - if (right == 0) return CalcError.DivisionByZero; + if (right == 0) return Error.DivisionByZero; break :blk (left % right) & mask; }, .pow => blk: { @@ -141,7 +152,7 @@ fn evalBinaryOp(config: Config, op: BinaryOp, left: u128, right: u128) CalcError } /// High-level: parse and evaluate a string in programmer mode. -pub fn evalProgrammerString(allocator: Allocator, source: []const u8, config: Config) CalcError!Integer { +pub fn evalProgrammerString(allocator: Allocator, source: []const u8, config: Config) Error!Integer { var p = Parser.init(allocator, source); const expr = try p.parse(); // Same ownership rule as evalStringInfo: the tree is ours to release, and the @@ -211,7 +222,7 @@ test "prog: division" { test "prog: division by zero" { const result = testProg("10 / 0"); - try testing.expectError(CalcError.DivisionByZero, result); + try testing.expectError(Error.DivisionByZero, result); } test "prog: modulo" { @@ -369,21 +380,21 @@ test "prog: variable reference errors" { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer _ = arena.deinit(); const result = evalProgrammerString(arena.allocator(), "x", .{}); - try testing.expectError(CalcError.UnknownVariable, result); + try testing.expectError(Error.UnknownVariable, result); } test "prog: assignment errors" { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer _ = arena.deinit(); const result = evalProgrammerString(arena.allocator(), "X = 5", .{}); - try testing.expectError(CalcError.InvalidOperandType, result); + try testing.expectError(Error.InvalidOperandType, result); } test "prog: function call errors" { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer _ = arena.deinit(); const result = evalProgrammerString(arena.allocator(), "sin(1)", .{}); - try testing.expectError(CalcError.UnknownFunction, result); + try testing.expectError(Error.UnknownFunction, result); } test "prog: ASCII literal single char" { @@ -411,7 +422,7 @@ test "prog: ASCII literal overflow 8-bit" { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer _ = arena.deinit(); const result = evalProgrammerString(arena.allocator(), "'AB'", .{ .int_type = .{ .width = .bits8 } }); - try testing.expectError(CalcError.Overflow, result); + try testing.expectError(Error.Overflow, result); } test "prog: float literal truncates to integer" { @@ -465,11 +476,11 @@ test "programmer mode: a float literal out of range errors instead of aborting" // `tally -p '1e40'` used to abort the process here: @intFromFloat on a value // past u128 is illegal behaviour, and only 3.14 was ever tested. try std.testing.expectError( - CalcError.Overflow, + Error.Overflow, evalProgrammerString(std.testing.allocator, "1e40", config), ); try std.testing.expectError( - CalcError.Overflow, + Error.Overflow, evalProgrammerString(std.testing.allocator, "1e100", config), ); // Still truncates the values that do fit. @@ -483,7 +494,7 @@ test "programmer mode: an infinite or NaN literal is a domain error" { const config: Config = .{}; // 10^400 overflows the exact tier's float projection to infinity. try std.testing.expectError( - CalcError.DomainError, + Error.DomainError, evalProgrammerString(std.testing.allocator, "1e400", config), ); } diff --git a/engine/src/units.zig b/engine/src/units.zig index 4bcdd6d..40b1541 100644 --- a/engine/src/units.zig +++ b/engine/src/units.zig @@ -19,9 +19,19 @@ //! No allocation, no I/O. Adding a unit means adding a table entry. const std = @import("std"); -const CalcError = @import("errors.zig").CalcError; -const rational_mod = @import("rational.zig"); -const Rational = rational_mod.Rational; +/// What unit conversion can fail with: its own two errors, plus whatever the exact +/// numeric path can raise, since the exact conversions compute in `Number`. +/// +/// The `||` is the point: this set grows when `number.Error` grows, without anyone +/// maintaining a list. It used to be one engine-wide set, and the translation from +/// numeric errors into it (`mapNumberError`) was a second copy of the same mapping +/// the evaluator had. +pub const Error = error{ + UnknownUnit, + IncompatibleUnits, + OutOfMemory, +} || number_mod.Error; +const Rational = @import("Rational.zig"); const number_mod = @import("number.zig"); const Number = number_mod.Number; @@ -440,8 +450,8 @@ pub fn findUnit(name: []const u8) ?UnitDef { /// Convert a value between two already-resolved units. /// Returns IncompatibleUnits if the units are in different categories. -pub fn convertUnits(value: f64, from: UnitDef, to: UnitDef) CalcError!f64 { - if (from.category != to.category) return CalcError.IncompatibleUnits; +pub fn convertUnits(value: f64, from: UnitDef, to: UnitDef) Error!f64 { + if (from.category != to.category) return Error.IncompatibleUnits; if (std.mem.eql(u8, from.name, to.name)) return value; return to.fromBase(from.toBase(value)); } @@ -449,9 +459,9 @@ pub fn convertUnits(value: f64, from: UnitDef, to: UnitDef) CalcError!f64 { /// Convert a value between two units named by string (canonical name or alias). /// Returns UnknownUnit if either name is unrecognized, or IncompatibleUnits if /// the units belong to different categories. -pub fn convert(value: f64, from_name: []const u8, to_name: []const u8) CalcError!ConvertResult { - const from = findUnit(from_name) orelse return CalcError.UnknownUnit; - const to = findUnit(to_name) orelse return CalcError.UnknownUnit; +pub fn convert(value: f64, from_name: []const u8, to_name: []const u8) Error!ConvertResult { + const from = findUnit(from_name) orelse return Error.UnknownUnit; + const to = findUnit(to_name) orelse return Error.UnknownUnit; const result = try convertUnits(value, from, to); // A single scaling factor only describes the relationship when neither @@ -570,14 +580,14 @@ fn splitTrailingUnit(text: []const u8) ?struct { value_text: []const u8, unit: U /// valid conversion. That is what disambiguates `in` the separator from `in` the /// unit: in "5 in in cm" the later `in` is the separator, while in "100 mm in in" /// the earlier one is, because only that reading resolves. -pub fn parseRequest(text: []const u8) CalcError!?ConversionRequest { +pub fn parseRequest(text: []const u8) Error!?ConversionRequest { var separators: [max_separators]Span = undefined; const count = collectSeparators(text, &separators); if (count == 0) return null; // Remember why the most recent candidate failed, so a committed-but-invalid // request reports a useful error instead of a parse error. - var failure: ?CalcError = null; + var failure: ?Error = null; var idx = count; while (idx > 0) { @@ -589,15 +599,15 @@ pub fn parseRequest(text: []const u8) CalcError!?ConversionRequest { if (left.len == 0 or right.len == 0) continue; const to_unit = findUnit(right) orelse { - failure = CalcError.UnknownUnit; + failure = Error.UnknownUnit; continue; }; const split = splitTrailingUnit(left) orelse { - failure = CalcError.UnknownUnit; + failure = Error.UnknownUnit; continue; }; if (split.unit.category != to_unit.category) { - failure = CalcError.IncompatibleUnits; + failure = Error.IncompatibleUnits; continue; } @@ -623,10 +633,10 @@ pub fn convertExactUnits( value: Number, from: UnitDef, to: UnitDef, -) CalcError!Number { - if (from.category != to.category) return CalcError.IncompatibleUnits; +) Error!Number { + if (from.category != to.category) return Error.IncompatibleUnits; if (std.mem.eql(u8, from.name, to.name)) { - return value.cloneWith(allocator) catch |err| return mapNumberError(err); + return try value.cloneWith(allocator); } // No exact factor available, or the value is already inexact: use floats. @@ -635,7 +645,7 @@ pub fn convertExactUnits( return Number.fromFloat(converted); } - return convertExactInner(allocator, value, from, to) catch |err| mapNumberError(err); + return convertExactInner(allocator, value, from, to); } fn convertExactInner( @@ -666,9 +676,6 @@ fn convertExactInner( return Number.div(allocator, shifted, to_factor); } -/// One mapping, in `number.zig`; this alias keeps the call sites short. -const mapNumberError = number_mod.toCalcError; - // -- Tests -- const testing = std.testing; @@ -956,16 +963,16 @@ test "findUnit returns null for unknown names" { } test "convert: unknown source unit errors" { - try testing.expectError(CalcError.UnknownUnit, convert(1, "bogus", "m")); + try testing.expectError(Error.UnknownUnit, convert(1, "bogus", "m")); } test "convert: unknown target unit errors" { - try testing.expectError(CalcError.UnknownUnit, convert(1, "m", "bogus")); + try testing.expectError(Error.UnknownUnit, convert(1, "m", "bogus")); } test "convert: incompatible categories error" { - try testing.expectError(CalcError.IncompatibleUnits, convert(1, "kg", "m")); - try testing.expectError(CalcError.IncompatibleUnits, convert(1, "C", "s")); + try testing.expectError(Error.IncompatibleUnits, convert(1, "kg", "m")); + try testing.expectError(Error.IncompatibleUnits, convert(1, "C", "s")); } test "convert: same unit is identity" { @@ -1146,21 +1153,21 @@ test "parseRequest: 'to' inside a word is not a keyword" { } test "parseRequest: unknown target unit errors" { - try testing.expectError(CalcError.UnknownUnit, parseRequest("100 km to smoots")); + try testing.expectError(Error.UnknownUnit, parseRequest("100 km to smoots")); } test "parseRequest: unknown source unit errors" { - try testing.expectError(CalcError.UnknownUnit, parseRequest("100 smoots to km")); + try testing.expectError(Error.UnknownUnit, parseRequest("100 smoots to km")); } test "parseRequest: missing value errors" { // "km to mi" has a unit with no value in front of it - try testing.expectError(CalcError.UnknownUnit, parseRequest("km to mi")); + try testing.expectError(Error.UnknownUnit, parseRequest("km to mi")); } test "parseRequest: mismatched categories error" { - try testing.expectError(CalcError.IncompatibleUnits, parseRequest("1 kg to m")); - try testing.expectError(CalcError.IncompatibleUnits, parseRequest("32F to km")); + try testing.expectError(Error.IncompatibleUnits, parseRequest("1 kg to m")); + try testing.expectError(Error.IncompatibleUnits, parseRequest("32F to km")); } test "parseRequest: dangling keyword returns null" { @@ -1176,8 +1183,8 @@ test "parseRequest: result feeds convertUnits correctly" { test "parseRequest: does not find a unit buried inside a word" { // The trailing "s" of "smoots" must not be read as seconds. - try testing.expectError(CalcError.UnknownUnit, parseRequest("100 smoots to km")); - try testing.expectError(CalcError.UnknownUnit, parseRequest("5 bananas to kg")); + try testing.expectError(Error.UnknownUnit, parseRequest("100 smoots to km")); + try testing.expectError(Error.UnknownUnit, parseRequest("5 bananas to kg")); } test "parseRequest: glued digit boundary still works" { @@ -1240,8 +1247,8 @@ test "parseRequest: trailing whitespace after the separator still resolves" { } test "parseRequest: backtracking reports the useful error, not a parse error" { - try testing.expectError(CalcError.UnknownUnit, parseRequest("5 cm in smoots")); - try testing.expectError(CalcError.IncompatibleUnits, parseRequest("5 cm in kg")); + try testing.expectError(Error.UnknownUnit, parseRequest("5 cm in smoots")); + try testing.expectError(Error.IncompatibleUnits, parseRequest("5 cm in kg")); } // -- Exact conversion (Task 2.0e) -- @@ -1360,7 +1367,7 @@ test "exact: incompatible categories still error" { var value = try Number.parse(alloc, "1"); defer value.deinit(); try testing.expectError( - CalcError.IncompatibleUnits, + Error.IncompatibleUnits, convertExactUnits(alloc, value, findUnit("kg").?, findUnit("m").?), ); } @@ -1446,8 +1453,9 @@ test "exact: only pi-derived units lack an exact factor" { } test "OOM safety: exact conversion releases everything at any failure point" { - // Also the only realistic way to reach mapNumberError, which translates the - // numeric model's errors into the engine's error set. + // The exact path computes in `Number`, so `OutOfMemory` reaches the caller from + // the numeric tier directly; `units.Error` includes `number.Error` for exactly + // this reason and nothing translates between them. const alloc = testing.allocator; const from = findUnit("in").?; const to = findUnit("ft").?; @@ -1458,14 +1466,14 @@ test "OOM safety: exact conversion releases everything at any failure point" { const a = failing.allocator(); var value = Number.parse(a, "12") catch |err| { - try testing.expectEqual(rational_mod.Error.OutOfMemory, err); + try testing.expectEqual(Rational.Error.OutOfMemory, err); continue; }; defer value.deinit(); var result = convertExactUnits(a, value, from, to) catch |err| { // The numeric model's OutOfMemory must surface as the engine's. - try testing.expectEqual(CalcError.OutOfMemory, err); + try testing.expectEqual(Error.OutOfMemory, err); continue; }; result.deinit(); diff --git a/src/main.zig b/src/main.zig index 2893230..3d7a66b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -363,9 +363,9 @@ pub fn formatConversion( to_name: []const u8, ) CliResult { const from = engine.units.findUnit(from_name) orelse - return .{ .output = errorMessage(engine.CalcError.UnknownUnit), .is_error = true }; + return .{ .output = errorMessage(engine.Error.UnknownUnit), .is_error = true }; const to = engine.units.findUnit(to_name) orelse - return .{ .output = errorMessage(engine.CalcError.UnknownUnit), .is_error = true }; + return .{ .output = errorMessage(engine.Error.UnknownUnit), .is_error = true }; // Parse the value exactly rather than through f64, so a decimal input like // 2.5 enters the conversion without being rounded first. @@ -540,23 +540,23 @@ fn formatProgrammerResult(buf: []u8, result: engine.Integer, config: engine.prog /// Turn an engine error into a CLI line. /// -/// The phrases live once, in `engine.errors.errorPhrase`. This adds the prefix and +/// The phrases live once, in `engine.phrase`. This adds the prefix and /// the newline at comptime, so the strings still have static lifetime and there is /// no second copy of the wording to drift. The CLI and the TUI previously each kept /// their own switch over the whole error set; the TUI's was already missing three /// cases and rendered them as "evaluation error". -fn errorMessage(err: engine.CalcError) []const u8 { +fn errorMessage(err: engine.Error) []const u8 { return decoratedError(err); } /// Comptime-decorated form of every error phrase: "error: \n". /// -/// `inline else` makes this exhaustive over the error set with no fallback branch: -/// a new `CalcError` member is a compile error in `errorPhrase`, not a string that -/// silently reads "evaluation error". -fn decoratedError(err: engine.CalcError) []const u8 { +/// `inline else` makes this exhaustive over the error set with no fallback branch: an +/// error added to any engine module fails to compile in `message.phrase` rather than +/// silently reading "evaluation error" here. +fn decoratedError(err: engine.Error) []const u8 { return switch (err) { - inline else => |e| comptime "error: " ++ engine.errors.errorPhrase(e) ++ "\n", + inline else => |e| comptime "error: " ++ engine.phrase(e) ++ "\n", }; } @@ -766,9 +766,9 @@ fn oomResult() CliResult { return .{ .output = "error: out of memory\n", .is_error = true }; } -fn amortErrorMessage(err: engine.CalcError) []const u8 { +fn amortErrorMessage(err: engine.Error) []const u8 { return switch (err) { - engine.CalcError.DomainError => + engine.Error.DomainError => // The realistic causes are all one of these, and a bare "domain error" // would leave the user guessing which. "error: check the loan terms: principal and periods must be positive, the rate cannot be negative, and the payment must at least cover the first period's interest\n", diff --git a/src/tui.zig b/src/tui.zig index f133eb7..3049129 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -1407,13 +1407,13 @@ pub fn drawHistory(items: []const App.HistoryEntry, surface: *vxfw.Surface, star /// Turn an engine error into a status-line string. /// -/// The phrases live once, in `engine.errors.errorPhrase`; the prefix is added at +/// The phrases live once, in `engine.phrase`; the prefix is added at /// comptime. This switch used to be a second full copy of the CLI's, and had fallen /// behind: `InsufficientParameters`, `ConvergenceFailure` and `InvalidExpression` /// all came out as "evaluation error". -fn errorStr(err: engine.CalcError) []const u8 { +fn errorStr(err: engine.Error) []const u8 { return switch (err) { - inline else => |e| comptime "error: " ++ engine.errors.errorPhrase(e), + inline else => |e| comptime "error: " ++ engine.phrase(e), }; } @@ -2987,19 +2987,19 @@ test "typed characters reach the prompt and Ctrl-C quits" { } test "errorStr covers the errors the TUI can surface" { - const errors = [_]engine.CalcError{ - engine.CalcError.DivisionByZero, - engine.CalcError.UnknownFunction, - engine.CalcError.UnknownVariable, - engine.CalcError.UnmatchedParen, - engine.CalcError.UnexpectedToken, - engine.CalcError.UnexpectedEnd, - engine.CalcError.InvalidNumber, - engine.CalcError.DomainError, - engine.CalcError.Overflow, - engine.CalcError.UnknownUnit, - engine.CalcError.IncompatibleUnits, - engine.CalcError.ConvergenceFailure, + const errors = [_]engine.Error{ + engine.Error.DivisionByZero, + engine.Error.UnknownFunction, + engine.Error.UnknownVariable, + engine.Error.UnmatchedParen, + engine.Error.UnexpectedToken, + engine.Error.UnexpectedEnd, + engine.Error.InvalidNumber, + engine.Error.DomainError, + engine.Error.Overflow, + engine.Error.UnknownUnit, + engine.Error.IncompatibleUnits, + engine.Error.ConvergenceFailure, }; for (errors) |err| { const text = errorStr(err); diff --git a/src/tui/financial.zig b/src/tui/financial.zig index 3f8759d..8a103b8 100644 --- a/src/tui/financial.zig +++ b/src/tui/financial.zig @@ -207,7 +207,7 @@ pub const Field = struct { pub const Outcome = union(enum) { /// The form is not yet answerable; the text says what it needs. hint: []const u8, - err: engine.CalcError, + err: engine.Error, value: Value, schedule: Schedule, @@ -870,15 +870,15 @@ fn money(buf: []u8, value: f64) []const u8 { /// Error text for this view. /// /// Only the cases where a form knows more than the engine does are overridden; the -/// rest defer to `engine.errors.errorPhrase`, so this is no longer a third copy of +/// rest defer to `engine.phrase`, so this is no longer a third copy of /// the whole table. A generic "domain error" is useless in a form, where the cause /// is always one of a few bad entries, but "division by zero" needs no improving. -pub fn errorText(err: engine.CalcError) []const u8 { +pub fn errorText(err: engine.Error) []const u8 { return switch (err) { - engine.CalcError.DomainError => "check the entries: values must be positive and a payment must cover the interest", - engine.CalcError.ConvergenceFailure => "no rate solves these cash flows", - engine.CalcError.InsufficientParameters => "these values do not determine an answer", - else => engine.errors.errorPhrase(err), + engine.Error.DomainError => "check the entries: values must be positive and a payment must cover the interest", + engine.Error.ConvergenceFailure => "no rate solves these cash flows", + engine.Error.InsufficientParameters => "these values do not determine an answer", + else => engine.phrase(err), }; } @@ -1103,7 +1103,7 @@ test "cagr form: says what it is waiting for" { test "cagr form: a bad entry is an error, not a wrong answer" { var state = stateWith(.cagr, &.{ "0", "25000", "5" }); - try testing.expectEqual(engine.CalcError.DomainError, state.outcome().err); + try testing.expectEqual(engine.Error.DomainError, state.outcome().err); } test "compound form: solves whichever of the four variables is blank" { @@ -1370,19 +1370,19 @@ test "every form has a label, a formula, and at most max_fields fields" { } test "errorText: every financial error has its own wording" { - const errors = [_]engine.CalcError{ - engine.CalcError.DomainError, - engine.CalcError.ConvergenceFailure, - engine.CalcError.InsufficientParameters, - engine.CalcError.DivisionByZero, - engine.CalcError.Overflow, - engine.CalcError.UnknownUnit, + const errors = [_]engine.Error{ + engine.Error.DomainError, + engine.Error.ConvergenceFailure, + engine.Error.InsufficientParameters, + engine.Error.DivisionByZero, + engine.Error.Overflow, + engine.Error.UnknownUnit, }; for (errors) |err| try testing.expect(errorText(err).len > 0); try testing.expect(!std.mem.eql( u8, - errorText(engine.CalcError.DomainError), - errorText(engine.CalcError.ConvergenceFailure), + errorText(engine.Error.DomainError), + errorText(engine.Error.ConvergenceFailure), )); }