2068 lines
102 KiB
Markdown
2068 lines
102 KiB
Markdown
# Design: Tally - Cross-Platform Calculator
|
||
|
||
## References
|
||
|
||
- #[[file:.kiro/specs/calculator/requirements.md]]
|
||
|
||
---
|
||
|
||
## 1. System Architecture
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Frontends │
|
||
├─────────────┬──────────────────┬────────────────────────────┤
|
||
│ CLI │ TUI │ Android │
|
||
│ (Zig) │ (Zig+libvaxis) │ (Kotlin/Compose) │
|
||
└──────┬──────┴────────┬─────────┴──────────────┬─────────────┘
|
||
│ │ │
|
||
│ Zig import │ Zig import │ JNI (C ABI)
|
||
│ │ │
|
||
┌──────▼───────────────▼────────────────────────▼─────────────┐
|
||
│ Engine (Zig Library) │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ parser │ evaluator │ programmer │ struct_layout │
|
||
│ │ │ │ │
|
||
│ tokens │ standard │ bitwise ops │ abi_profiles │
|
||
│ ast │ financial │ base conv │ layout_compute │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
The engine is a pure Zig library with no I/O. Frontends depend on the engine; the engine depends on nothing outside `std`.
|
||
|
||
### Build Graph
|
||
|
||
```
|
||
build.zig (workspace root)
|
||
|- engine/build.zig -> produces: static lib (.a), C header, shared lib (.so/.dylib/.dll)
|
||
|- cli/build.zig -> produces: statically-linked binary
|
||
|- tui/build.zig -> produces: statically-linked binary (links libvaxis)
|
||
|- android targets -> produces: libtally.so for arm64-v8a, x86_64
|
||
(consumed by android/ Gradle project for APK packaging)
|
||
```
|
||
|
||
**Build system philosophy:**
|
||
- `zig build` is the single entry point for all compilation. No Makefile, no CMake, no shell scripts.
|
||
- `zig build` (no args) -> builds engine + CLI + TUI for the host platform.
|
||
- `zig build -Dtarget=aarch64-linux-android` -> cross-compiles engine .so for Android arm64.
|
||
- `zig build test` -> runs all engine unit tests with coverage reporting.
|
||
- The `android/` Gradle project handles only APK packaging and Compose UI compilation - it does not invoke Zig. Prebuilt .so files are committed or produced by a prior `zig build` step.
|
||
|
||
**Toolchain management (mise):**
|
||
- `.mise.toml` at project root declares: Zig version, any other needed tools.
|
||
- `mise install` provisions the full dev environment. No other manual setup required.
|
||
- CI uses the same `.mise.toml` for reproducibility.
|
||
|
||
---
|
||
|
||
## 2. Engine Design
|
||
|
||
### 2.1 Module Breakdown
|
||
|
||
| Module | Responsibility |
|
||
|--------|---------------|
|
||
| `Integer.zig` | A fixed-width integer (file-as-struct), plus `IntType`, `BitWidth`, `Signedness` |
|
||
| `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 |
|
||
| `evaluator.zig` | Walk AST, produce results |
|
||
| `bitwise.zig` | The fixed-width operators (`& \| xor ~ << >> >>> rol ror`), shared by both modes |
|
||
| `programmer.zig` | Programmer-mode evaluation, its `Config`, wrapping arithmetic |
|
||
| `formatter.zig` | Display and clipboard strings for every base |
|
||
| `float_interp.zig` | IEEE 754 bit-level interpretation |
|
||
| `units.zig` | Unit conversion tables and resolver |
|
||
| `financial.zig` | CAGR, TVM, compound interest, amortization |
|
||
| `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 |
|
||
|
||
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
|
||
|
||
```zig
|
||
/// Result of any calculation
|
||
pub const Value = union(enum) {
|
||
integer: Integer,
|
||
float: f64,
|
||
boolean: bool,
|
||
struct_layout: StructLayoutResult,
|
||
multi_base: MultiBaseResult, // programmer mode result
|
||
};
|
||
|
||
pub const Integer = struct {
|
||
/// Raw bits stored in u64, interpretation depends on context
|
||
raw: u64,
|
||
bit_width: BitWidth,
|
||
signedness: Signedness,
|
||
};
|
||
|
||
pub const BitWidth = enum { bits8, bits16, bits32, bits64 };
|
||
pub const Signedness = enum { signed, unsigned };
|
||
|
||
pub const MultiBaseResult = struct {
|
||
value: Integer,
|
||
decimal_signed: []const u8, // formatted string
|
||
decimal_unsigned: []const u8,
|
||
hex: []const u8,
|
||
octal: []const u8,
|
||
binary: []const u8,
|
||
bit_pattern: [64]u1, // individual bits, index 0 = LSB
|
||
};
|
||
```
|
||
|
||
### 2.3 AST Nodes
|
||
|
||
The shape as implemented (`engine/src/ast.zig`); the earlier sketch here omitted
|
||
the `text` field, which the whole exact tier depends on.
|
||
|
||
```zig
|
||
pub const Expr = union(enum) {
|
||
number: Number,
|
||
string_literal: []const u8, // ASCII bytes to pack, quotes stripped
|
||
unary: Unary,
|
||
binary: Binary,
|
||
call: Call,
|
||
variable: []const u8,
|
||
assignment: Assignment,
|
||
};
|
||
|
||
pub const Number = struct {
|
||
float_value: f64,
|
||
/// For programmer mode: the exact integer when the literal fits u64.
|
||
int_value: ?u64,
|
||
base: Base,
|
||
/// The literal exactly as written, pointing into the source. Decimal literals
|
||
/// are re-parsed from this rather than from `float_value`, because
|
||
/// `float_value` has already rounded and `0.1` cannot be recovered from it.
|
||
text: []const u8,
|
||
};
|
||
|
||
pub const Base = enum { decimal, hex, octal, binary };
|
||
|
||
pub const Binary = struct {
|
||
op: BinaryOp,
|
||
left: *Expr,
|
||
right: *Expr,
|
||
};
|
||
|
||
pub const BinaryOp = enum {
|
||
add, sub, mul, div, mod, pow,
|
||
bit_and, bit_or, bit_xor, shift_left,
|
||
shift_right, shift_right_logical,
|
||
rotate_left, rotate_right,
|
||
};
|
||
```
|
||
|
||
Ownership: nodes are allocated individually and the caller releases the tree with
|
||
`parser.freeExpr`. Passing an arena and skipping the walk is allowed, but nothing
|
||
assumes it (see `ast.zig` for why that distinction matters).
|
||
|
||
**Size limits.** `Parser.max_nodes` (1000) and `Parser.max_nest_depth` (128) bound
|
||
the tree, because every walk over it recurses. A flat chain like `1+1+1+...` builds
|
||
a left-deep tree whose depth is the operator count, and 4000 terms segfaulted
|
||
`evalExact` before the limit existed. Exceeding either reports
|
||
`InvalidExpression`.
|
||
|
||
### 2.4 Parser Design
|
||
|
||
**Approach: Pratt parser (top-down operator precedence)**
|
||
|
||
Precedence table (low -> high):
|
||
|
||
| Level | Operators |
|
||
|-------|-----------|
|
||
| 1 | `\|` (OR), `or` |
|
||
| 2 | `xor` (XOR keyword) |
|
||
| 3 | `&` (AND), `and` |
|
||
| 4 | `<<`, `>>`, `>>>`, `rol`, `ror` |
|
||
| 5 | `+`, `-` |
|
||
| 6 | `*`, `/`, `%` |
|
||
| 7 | `^`, `**` (exponentiation, always) |
|
||
| 8 | Unary `-`, `~` (NOT), `not` |
|
||
| 9 | Function calls, parentheses |
|
||
|
||
**`^` is always exponentiation.** There is no mode-dependent operator
|
||
overloading. XOR is a keyword (`xor`), consistent with the existing
|
||
`rol`/`ror` keyword operators. Bitwise ops have both symbolic and
|
||
keyword forms: `&`/`and`, `|`/`or`, `~`/`not`. XOR is keyword-only
|
||
(`xor`) since `^` is reserved for power. Every operator means the same
|
||
thing in standard and programmer modes.
|
||
|
||
**One implementation of the fixed-width operators.** `bitwise.zig` implements the
|
||
eight fixed-width binary operators plus `~` and two's complement negation, over a
|
||
`Domain` of width plus signedness. Standard mode passes `Domain.standard`, which is
|
||
64-bit signed and not configurable; programmer mode passes its configured width and
|
||
signedness. Values are `u128` bit patterns masked to the width, the representation
|
||
`types.Integer` already used. Both callers dispatch through an `inline else` prong
|
||
that resolves the operator at comptime, so an operator added to `BinaryOp` that
|
||
belongs in this tier fails to compile rather than falling through to the rational
|
||
tier. FR-2.13 covers the distance rules: shifts run to completion, rotations are
|
||
cyclic, negative distances are domain errors.
|
||
|
||
**No implicit multiplication**: adjacency (e.g. `2pi`) is an error;
|
||
use explicit `*`. Spaces within hex/oct/bin literals are separators
|
||
(e.g. `0xFF FF`).
|
||
|
||
**Parsing does not depend on the mode.** `Tokenizer.init(source)` and
|
||
`Parser.init(allocator, source)` take no `Mode`: the same text produces the same
|
||
tree in standard and programmer mode, and only evaluation differs. Both used to
|
||
accept and store a `Mode` that no code read, which implied a mode-dependent grammar
|
||
that does not exist.
|
||
|
||
### 2.5 Evaluation
|
||
|
||
The evaluator maintains an `Environment`:
|
||
|
||
```zig
|
||
pub const Environment = struct {
|
||
allocator: Allocator,
|
||
/// Variables hold `Number`, so an assignment keeps whatever exactness its
|
||
/// expression had: `X = 0.1` stores exactly one tenth.
|
||
variables: std.StringHashMap(Number),
|
||
ans: Number,
|
||
};
|
||
```
|
||
|
||
It holds no mode and no programmer configuration. It used to hold both and read
|
||
neither: the caller picks `evalString` or `evalProgrammerString`, so which evaluator
|
||
runs is decided at the call rather than by state. The TUI was writing a mode into
|
||
that field on every mode change and nothing ever consulted it. `Mode` has left the
|
||
engine entirely; each frontend has its own, and they differ (the CLI has two, the TUI
|
||
has four including Convert, and the engine had a `financial` member it could never
|
||
act on because financial calculations are ordinary functions in expressions).
|
||
|
||
Programmer mode's configuration belongs to programmer mode:
|
||
|
||
```zig
|
||
// programmer.zig
|
||
pub const Config = struct {
|
||
int_type: IntType = .{},
|
||
/// Big-endian by default so the HEX row reads as the number itself; see FR-2.8.
|
||
display_endian: std.builtin.Endian = .big,
|
||
};
|
||
|
||
// Integer.zig: one concept, previously reassembled in three places
|
||
pub const IntType = struct {
|
||
width: BitWidth = .bits64,
|
||
signedness: Signedness = .signed,
|
||
};
|
||
```
|
||
|
||
`Integer.zig` is TitleCase because the file is the type: its top-level fields are
|
||
`raw` and `int_type`, and `IntType`, `BitWidth` and `Signedness` are declared inside
|
||
it. Byte order is `std.builtin.Endian` rather than an engine enum of the same two
|
||
members.
|
||
|
||
Standard mode evaluates to `Number`, the exact/inexact union of section 2.7. Programmer mode evaluates to `Integer`, which is a `u128` pattern plus the `IntType` that interprets it. Financial functions compute in `f64` and enter the expression language as inexact `Number` values (section 5.6).
|
||
|
||
### 2.6 Number Display Formatting
|
||
|
||
The engine provides raw values; frontends apply display formatting. The engine includes a formatting module that produces display strings and raw (clipboard-friendly) strings separately.
|
||
|
||
#### Decimal Formatting
|
||
|
||
| Context | Display | Clipboard |
|
||
|---------|---------|-----------|
|
||
| Standard result | `4,294,967,295` | `4294967295` |
|
||
| Fractional result | `231,677.04` | `231677.04` |
|
||
| Financial result | `$1,234,567.89` | `1234567.89` |
|
||
| Large decimals | `4,294,967,295` (never scientific unless > 15 digits of precision) | `4294967295` |
|
||
|
||
**Grouping applies to the integer part regardless of a fractional part.** Commas
|
||
group the digits left of the decimal point and never appear to the right of it,
|
||
so `231677.04` displays as `231,677.04` rather than ungrouped. Grouping only
|
||
whole numbers would make the same magnitude read two different ways depending on
|
||
whether cents happened to be present, which showed up immediately in financial
|
||
output. Because FR-1.8 accepts commas as input digit separators, the grouped
|
||
display remains valid input. Text already in scientific notation is left alone.
|
||
|
||
**Scientific notation threshold**: Only use scientific notation when the number exceeds what can be meaningfully displayed in decimal (> 15 significant digits, or absolute value > 10^15 or < 10^-15). Otherwise, always show full decimal with commas. This is a deliberate departure from calculators that jump to scientific notation at 10+ digits.
|
||
|
||
#### Programmer Mode Formatting
|
||
|
||
Values are displayed with visual grouping separators to aid readability, but clipboard copies the raw value without separators.
|
||
|
||
| Base | Display (value view) | Display (memory view) | Clipboard |
|
||
|------|---------------------|----------------------|-----------|
|
||
| Hex | `FF FF FF FF` | `FF FF FF FF` | `0xFFFFFFFF` |
|
||
| Bin | `1111 1111 1111 1111 1111 1111 1111 1111` | same | `0b11111111111111111111111111111111` |
|
||
| Oct | `0o37_777_777_777` | - | `0o37777777777` |
|
||
| Dec | `4,294,967,295` | - | `4294967295` |
|
||
|
||
Rationale for hex grouping:
|
||
- **Display uses space per byte** (`FF FF FF FF`): matches hex editors, debuggers, `xxd` output. Consistent with binary's nibble-space grouping. Universally readable.
|
||
- These are two different views of the same data: the *value* (what you'd write in source code) vs. the *memory* (how it sits in RAM). For this calculator, both use the same space-separated format.
|
||
|
||
Binary always groups by nibble (4 bits) with spaces - universally expected by programmers.
|
||
|
||
#### Formatting Data Structure
|
||
|
||
```zig
|
||
pub const FormattedValue = struct {
|
||
/// Human-readable display string (with commas, underscores, spaces)
|
||
display: []const u8,
|
||
/// Machine/clipboard string (raw, no separators, includes prefix)
|
||
raw: []const u8,
|
||
};
|
||
```
|
||
|
||
All frontends:
|
||
- Show `display` in the UI
|
||
- Copy `raw` to clipboard on long-press (Android) or yank (TUI)
|
||
|
||
---
|
||
|
||
### 2.7 Numeric Model: Exact and Inexact
|
||
|
||
#### 2.7.1 The problem
|
||
|
||
Standard mode currently evaluates everything as `f64`. That produces four
|
||
distinct classes of wrong answer, all reproducible today:
|
||
|
||
```
|
||
0.1 + 0.2 -> 0.30000000000000004 binary cannot represent 0.1
|
||
1.1 + 2.2 -> 3.3000000000000003 same
|
||
9007199254740993 -> 9.007199254740992e15 SILENTLY A DIFFERENT INTEGER
|
||
2^53 + 1 -> 9.007199254740992e15 same, past f64's integer limit
|
||
12 in in ft -> 0.9999999999999998 conversion factors are decimal
|
||
factorial(171) -> error: unknown function f64 overflow, wrong error too
|
||
```
|
||
|
||
The third case is the serious one. The user typed an exact integer and got a
|
||
different integer back. Meanwhile programmer mode, one Tab away, is exact to 128
|
||
bits. That is an internal inconsistency, not a rounding preference.
|
||
|
||
#### 2.7.2 The representation
|
||
|
||
A tagged number with an exact tier and an inexact tier:
|
||
|
||
```zig
|
||
/// Exact rational: p/q with arbitrary-size integers, always reduced, q > 0.
|
||
pub const Rational = struct {
|
||
num: std.math.big.int.Managed,
|
||
den: std.math.big.int.Managed,
|
||
};
|
||
|
||
pub const Number = union(enum) {
|
||
/// Exact value. Closed under + - * / and integer powers.
|
||
exact: Rational,
|
||
/// Inexact value, produced once a computation escapes the rationals.
|
||
inexact: f64,
|
||
};
|
||
```
|
||
|
||
**Contagion rule:** any operation with an `inexact` operand yields `inexact`.
|
||
A value never silently becomes exact again. This is what keeps the guarantee
|
||
honest: `exact` means "no rounding has occurred anywhere in this value's
|
||
history", not "happens to look clean right now".
|
||
|
||
#### 2.7.3 Why rational and not decimal128
|
||
|
||
Both fix `0.1 + 0.2`. They are not otherwise equivalent:
|
||
|
||
| | decimal128 | arbitrary rational |
|
||
|---|---|---|
|
||
| Form | +/- 34-digit significand x 10^exp, 128 bits fixed | p/q, arbitrary-size integers |
|
||
| `0.1`, `0.0254` | exact | exact (`1/10`, `127/5000`) |
|
||
| `1/3` | 34-digit approximation | **exact** |
|
||
| `(1/3)*3` | `0.999...9` | **exactly 1** |
|
||
| `9007199254740993` | exact | exact |
|
||
| `sqrt(2)`, `pi` | approximation | **cannot represent** |
|
||
| Rounds | every operation | never |
|
||
| Memory | fixed 16 bytes | grows with denominators |
|
||
| Trailing zeros (`2.50`) | **preserved** (scale is part of the value) | lost |
|
||
|
||
Two conclusions:
|
||
|
||
1. **Every decimal128 value is a rational.** Rationals strictly subsume them.
|
||
What decimal128 adds is fixed cost, standard rounding modes, and scale
|
||
semantics, not additional expressiveness.
|
||
2. **decimal128 does not deliver correctness, it delivers familiarity.**
|
||
`1/3 * 3` is still not 1. Since we are prioritizing correctness, rational
|
||
wins outright.
|
||
|
||
Decimal's genuine advantage is scale preservation, which matters for money
|
||
(`2.50`, not `2.5`). We recover that in financial mode with an explicit display
|
||
scale plus banker's rounding at the commit boundary, rather than a second
|
||
numeric type. See section 5.
|
||
|
||
#### 2.7.4 The exact/inexact boundary
|
||
|
||
The trigger is **whether the operation escapes the rationals**, evaluated per
|
||
call rather than per function. Notably it is *not* the input base: `0xdeadbeef`
|
||
is exactly 3735928559, and base is a lexical and display property only.
|
||
|
||
| Stays exact | Becomes inexact |
|
||
|---|---|
|
||
| `+ - * /` on exact operands | `sin cos tan asin acos atan` |
|
||
| `^` with integer exponent | `^` with non-integer exponent |
|
||
| `sqrt` of a perfect square | `sqrt` of a non-square |
|
||
| `%`, `abs`, `ceil`, `floor`, `round` | `log ln log2 log10 exp cbrt` |
|
||
| `factorial` (unbounded) | constants `pi`, `e`, `tau` |
|
||
| integer and decimal literals | any operand already inexact |
|
||
|
||
`sqrt(4)` is 2 exactly; `sqrt(2)` is not. We deliberately stop short of a CAS:
|
||
Qalculate simplifies `sqrt(8)` to `2*sqrt(2)` symbolically, we will not.
|
||
|
||
#### 2.7.5 Denominator growth and the demotion policy
|
||
|
||
Rationals grow. Chained division with coprime denominators multiplies them, so a
|
||
pathological input can consume unbounded memory. This must be designed in from
|
||
the start, not discovered later:
|
||
|
||
- Cap the denominator at a bit-width budget (starting proposal: 4096 bits).
|
||
- On exceeding it, **demote to `inexact`** rather than erroring or growing.
|
||
- Demotion is silent in the result value but visible in the tag, so a frontend
|
||
can indicate that exactness was lost.
|
||
|
||
Interactive single calculations will not approach this. The cap exists so the
|
||
failure mode is graceful degradation to today's behavior.
|
||
|
||
#### 2.7.6 Display
|
||
|
||
Exact values still have to be rendered at finite width, so display precision
|
||
remains a separate decision from compute precision:
|
||
|
||
- An exact value with a **terminating** decimal expansion prints exactly:
|
||
`1/10` -> `0.1`, and `12 in in ft` -> `1`.
|
||
- An exact value with a **non-terminating** expansion is rendered by long
|
||
division to a digit budget with correct rounding, and marked as approximate:
|
||
`100 km in mi` is exactly `781250/12573`, displayed as `62.137119224`.
|
||
- Because the exact form is retained, we can additionally offer the fraction
|
||
(`781250/12573`) as a display option. This is a feature, not a workaround.
|
||
|
||
This supersedes the unimplemented "exceeds 15 significant digits" clause of
|
||
NFR-7, which was never a precision decision and, read literally, would have
|
||
rendered `0.9999999999999998` as `9.999999999999998e-1`. NFR-7 needs a real
|
||
answer to a better-posed question: how many digits to show for a non-terminating
|
||
exact value.
|
||
|
||
#### 2.7.7 What is unaffected
|
||
|
||
- **Programmer mode** stays fixed-width `u128`. Wrapping and masking at a chosen
|
||
bit width is the entire point of that mode; exact arithmetic would break it.
|
||
- **The IEEE 754 float view** stays binary `f32`/`f64`. It exists to show binary
|
||
encodings.
|
||
- **The formatter's f64 paths** remain, since the inexact tier still needs them.
|
||
|
||
#### 2.7.8 Fallback precision: why f64 and not f128
|
||
|
||
The obvious objection to `f64` for the inexact tier is that the usual
|
||
justification (hardware instructions) does not apply to a calculator, which
|
||
evaluates one expression per human action. That objection is correct, and
|
||
performance is *not* the reason. The reason is that Zig 0.16's f128 support is
|
||
partial and, worse, silently inconsistent. Probed directly:
|
||
|
||
| Operation | f128 status |
|
||
|---|---|
|
||
| `@sqrt` | compiles, accurate to full f128 (34 digits verified) |
|
||
| `@sin`, `@cos` | compiles, accurate to full f128 |
|
||
| `@log`, `@log10`, `@exp` | compiles, but **only f64-accurate** |
|
||
| `std.math.pow` | **does not compile** ("pow not implemented for f128") |
|
||
| `std.math.cbrt` | **does not compile** |
|
||
| `std.math.atan`, `asin` | compiles |
|
||
| `parseFloat` / formatting | works, 112 mantissa bits (~34 decimal digits) |
|
||
|
||
The middle row is the disqualifying one. `@log(2.0)` as f128 returns
|
||
`0.6931471805599452862267639829951804`, which is the *f64* value widened: it
|
||
diverges from the true value at the 17th significant digit. So f128 storage
|
||
would hand us 34 digits of which roughly 18 are noise, with nothing to
|
||
distinguish them. For a calculator that then displays those digits, this is
|
||
actively worse than knowingly using f64.
|
||
|
||
The general principle: **the precision of the inexact tier is bounded by the
|
||
accuracy of the underlying transcendental implementations, not by the width of
|
||
the storage type.** Widening storage without widening the algorithms buys
|
||
nothing but false confidence.
|
||
|
||
##### Upstream status (checked, not assumed)
|
||
|
||
The f64-accuracy above is not a misconfiguration or a version quirk. It is
|
||
visible in Zig's own source, with its own TODO markers. `lib/compiler_rt/log.zig`:
|
||
|
||
```zig
|
||
pub fn logq(a: f128) callconv(.c) f128 {
|
||
// TODO: more correct implementation
|
||
return log(@floatCast(a));
|
||
}
|
||
```
|
||
|
||
A systematic scan of `lib/compiler_rt/` in Zig 0.16.0 splits the f128 routines
|
||
cleanly in two:
|
||
|
||
- **Downcast to f64, all five marked TODO:** `expq`, `exp2q`, `logq`, `log2q`,
|
||
`log10q`
|
||
- **Genuine f128 implementations:** `sinq`, `cosq`, `tanq`, `sqrtq`, `fabsq`,
|
||
`floorq`, `ceilq`, `roundq`, `truncq`
|
||
|
||
That is an exact match for the probe results: `@sin`/`@cos`/`@sqrt` accurate,
|
||
`@log`/`@log10`/`@exp` only f64-accurate.
|
||
|
||
Tracker status, as of this writing. Note that Zig has moved to Codeberg and
|
||
GitHub is effectively frozen (GitHub last pushed 2025-11-27; Codeberg shows ~742
|
||
open issues against GitHub's ~2876, so most GitHub issues were **not** migrated):
|
||
|
||
| Gap | Tracked? |
|
||
|---|---|
|
||
| `std.math.pow` f128 | [GH #23602](https://github.com/ziglang/zig/issues/23602) open, labeled "contributor friendly". [PR #23631](https://github.com/ziglang/zig/pull/23631) implemented it but was **closed unmerged** 2026-01-05 ("no CI on GitHub anymore... reopen on Codeberg"). Nobody has. No Codeberg issue exists. |
|
||
| `std.math.cbrt` f128 | **No issue on either tracker.** Unreported. `cbrt.zig` is ported from musl, which only ships `cbrtf`/`cbrt`. |
|
||
| `logq`/`expq` etc. f64 accuracy | **No issue on either tracker.** Only the in-source TODOs. |
|
||
|
||
The nearest historical artifacts are [GH #4026](https://github.com/ziglang/zig/issues/4026)
|
||
("implement all the math functions for all the floating point types", closed
|
||
2022-12-28 under milestone 0.11.0 with its f128 column still unchecked) and
|
||
[GH #15711](https://github.com/ziglang/zig/issues/15711) (a reference table
|
||
created and closed in the same second, which lists the f128 symbols as
|
||
*existing* - existence, not accuracy).
|
||
|
||
Adjacent open work suggesting f128 remains a soft spot generally:
|
||
[GH #23865](https://github.com/ziglang/zig/pull/23865) "Better @sqrt for f32, f64
|
||
and f128", [GH #23173](https://github.com/ziglang/zig/issues/23173) "introduce new
|
||
float types", and Codeberg #36093 "compiler_rt: min(f128) / max(f128) causes
|
||
integer overflow".
|
||
|
||
**Conclusion: "wait for Zig to fix f128" is not a plan.** Two of the three gaps
|
||
are not even reported, and the one with a working patch lost it to a tracker
|
||
migration. Obtaining true f128 accuracy would mean writing or porting the
|
||
routines ourselves, which is the same category and scale of work as going
|
||
arbitrary-precision - so it should be justified on its own terms, not smuggled in
|
||
as "just use a wider type".
|
||
|
||
(Optional, off the critical path: reviving the `pow` PR on Codeberg is a
|
||
well-scoped upstream contribution, and the issue is explicitly labeled
|
||
contributor friendly.)
|
||
|
||
So `f64` is chosen because ~15-16 digits is the precision we can actually
|
||
justify, and we display no more than that. The upgrade path is therefore *not*
|
||
"switch the storage type to f128"; it is "implement or bind correctly-rounded
|
||
arbitrary-precision transcendentals" (the problem MPFR exists to solve), which is
|
||
a separate project with its own justification.
|
||
|
||
Incidentally, the probe also confirms that width alone never fixes the decimal
|
||
problem: in f128, `0.1 + 0.2` is
|
||
`0.30000000000000000000000000000000004`. Only exactness fixes it.
|
||
|
||
#### 2.7.9 Implementation sequencing
|
||
|
||
The change must not land as one diff mixing structural churn (signature changes
|
||
rippling through hundreds of test assertions) with behavioral change (results
|
||
becoming correct). Reviewing that combination is impractical: the interesting
|
||
lines are buried in mechanical ones.
|
||
|
||
Two orderings were considered.
|
||
|
||
**Rejected: structure first.** Introduce `Number`, rewrite every test and the
|
||
display path to consume it, review, then change behavior. This fails because the
|
||
structural rewrite is not behavior-neutral: rewriting a test to consume `Number`
|
||
requires asserting whether each result is `exact` or `inexact`, and that
|
||
classification *is* the behavioral design. Every such assertion would be written
|
||
against the old f64 behavior and then flipped in the later step. Double churn,
|
||
and the intermediate commit is a large diff that improves nothing.
|
||
|
||
**Chosen: behavior first, behind the existing signature.**
|
||
|
||
1. **Add `rational.zig` and `number.zig`, wired to nothing.** Pure addition with
|
||
their own tests. Reviewable in isolation, zero risk to existing behavior.
|
||
2. **Switch the evaluator's internals to the exact tier, keeping `evalString`
|
||
returning `f64`.** All existing tests must still pass. Review and commit.
|
||
3. **Change `evalString` and the display path to return and consume `Number`**,
|
||
with the resulting mechanical churn in test signatures. Review and commit.
|
||
4. **Add the exactness tests** that only the `Number` API can express.
|
||
|
||
What makes step 2 worthwhile rather than invisible is that converting an exact
|
||
value to `f64` *once, at the boundary* already fixes most of the payoff. Today's
|
||
errors are accumulated: `0.1` and `0.2` are each rounded before being added. An
|
||
exact `3/10` rounded a single time to the nearest f64 prints as `0.3`.
|
||
|
||
Bug-by-bug, where the fix lands:
|
||
|
||
| Bug | Fixed by step 2 (f64 boundary) | Needs step 3 (`Number`) |
|
||
|---|---|---|
|
||
| `0.1 + 0.2` -> `0.3` | yes | - |
|
||
| `1.1 + 2.2` -> `3.3` | yes | - |
|
||
| `0.1 * 3` -> `0.3` | yes | - |
|
||
| `1e20 + 1 - 1e20` -> `1` | yes | - |
|
||
| `factorial(171)` | partly (becomes `inf`, not an error) | yes, for the exact value |
|
||
| `9007199254740993` | **no** (f64 cannot hold it) | yes |
|
||
| `2^53 + 1` | **no** | yes |
|
||
| `1/3` retained exactly | no (only observable as exact) | yes |
|
||
| `12 in in ft` -> `1` | **no** - see below | needs exact unit factors |
|
||
|
||
**Unit conversion is a separate problem from the evaluator.** An earlier draft of
|
||
this table claimed the exact evaluator would fix `12 in in ft`. It does not, and
|
||
the reason is worth recording: `UnitDef.to_base_factor` is an `f64`, so `0.0254`
|
||
is *already* the binary approximation before any arithmetic happens, and
|
||
`convertUnits` does its own f64 multiply and divide without ever touching the
|
||
evaluator. Making conversions exact requires the factors themselves to be exact
|
||
(declared as decimal text and parsed into rationals), which is a mechanical change
|
||
across ~100 table entries and belongs in its own commit. Tracked as Task 2.0e.
|
||
|
||
So step 2 is independently verifiable through the current API, and the
|
||
large-integer cases become the motivating tests for step 3 rather than
|
||
speculative ones.
|
||
|
||
#### 2.7.10 Migration and test impact
|
||
|
||
The dominant risk is **compile-time** breakage, not behavioral. An audit of the
|
||
402-test suite found **no test that asserts the broken behavior** - nothing
|
||
pins `0.30000000000000004`, the 2^53 cliff, or the `factorial` limit. So the
|
||
target is "no test changes its expected value", not "no test file is touched".
|
||
|
||
Unaffected by construction: `programmer.zig` (47), `float_interp.zig` (21),
|
||
`formatter.zig` (40), `types.zig` and `tui.zig` (16).
|
||
|
||
Kept passing by making the change additive:
|
||
|
||
- `tokenizer.zig` (39): `NumberValue` **gains** an exact field, does not replace
|
||
`float`/`int_value`.
|
||
- `parser.zig` (32, 10 of which read `number.float_value`): the AST number node
|
||
likewise gains a field.
|
||
- `evaluator.zig` (62, 40 asserting `@as(f64, ...)`): keep `evalString`
|
||
returning `f64` as a wrapper over the exact core. Every one of those
|
||
assertions involves small values where exact-to-f64 is bit-identical.
|
||
- `units.zig` (106, 95 routed through the `expectConvert` helper): change the
|
||
helper once, leave the call sites alone.
|
||
|
||
Three existing items must change because we are invalidating their premise:
|
||
|
||
1. `tokenizer.zig` test `"parseNumber huge decimal falls back to float"` -
|
||
`99999999999999999999999999` becomes exactly representable, so the test still
|
||
passes but its intent is now false. Rewrite it.
|
||
2. `formatter.zig` `is_integer`, gated on `< 2^53` - that guard is the display
|
||
half of the `9007199254740993` bug.
|
||
3. `evaluator.zig` `factorial`'s `x > 170` rejection, which is also the source of
|
||
the misleading `unknown function` error.
|
||
|
||
**Compatibility wrappers are scaffolding with a deadline, not an end state.** If
|
||
the CLI and TUI keep calling the `f64` API, the exactness never reaches the user
|
||
and the work is invisible. The display path must move to `Number` (step 3 of
|
||
2.7.9), and that is where `main.zig`'s ~20 output assertions get reviewed.
|
||
Literal zero test churn across the whole sequence would be evidence we had not
|
||
delivered anything.
|
||
|
||
Windows Calculator draws the same exact/inexact line we are drawing: its
|
||
arbitrary-precision rational engine covers basic arithmetic only, not
|
||
transcendentals.
|
||
|
||
New tests required: rational arithmetic and reduction, exact/inexact contagion,
|
||
the demotion policy at the denominator cap, rational-to-decimal rendering and
|
||
rounding, and a regression test per bug in 2.7.1 - including one asserting
|
||
`9007199254740993` round-trips, which nothing currently guards.
|
||
|
||
##### Allocation-failure testing
|
||
|
||
`errdefer` cleanup paths are not reachable by ordinary tests, and covering them
|
||
is worthless on its own: the line exists to *prevent a leak*, so executing it
|
||
proves nothing unless the test also asserts that nothing leaked.
|
||
|
||
`std.testing.FailingAllocator` fails after exactly N allocations. Sweeping N
|
||
across an operation's whole allocation sequence, with `testing.allocator`
|
||
underneath to detect leaks at test end, turns those paths into real assertions.
|
||
(`std.mem.Allocator.failing` fails on the *first* allocation, which only covers
|
||
entry-point OOM, not the interesting mid-construction failures.)
|
||
|
||
This is not a coverage exercise; the sweep immediately found three genuine bugs
|
||
in `rational.zig` that ordinary tests could never reach:
|
||
|
||
1. **A double free.** `initOwned` took its numerator and denominator *by value*
|
||
and freed them via `errdefer` on its own failure, while every caller ALSO had
|
||
an `errdefer` for the same values. Any allocation failure inside normalization
|
||
freed them twice, which segfaulted.
|
||
2. **A dangling copy.** Because the parts were passed by value, and normalization
|
||
can reallocate limbs, a caller's `errdefer` could free a stale pointer even
|
||
without the double free.
|
||
Fixed by renaming to `finish` and taking both parts **by pointer**, with the
|
||
contract that ownership transfers only on success: on failure the caller's
|
||
`errdefer` sees live, current values and frees them exactly once.
|
||
3. **Two struct-literal leaks.** `initInt`, `initZero` and `initRatio` built
|
||
`.{ .num = try initSet(...), .den = try initSet(...) }` in a single literal.
|
||
An `errdefer` cannot cover a value that does not exist yet, so a failure on
|
||
the second allocation orphaned the first. Fixed by building the parts as
|
||
separate statements, each with its own `errdefer`. `floor` and `factorial`
|
||
were also missing an `errdefer` on their denominator.
|
||
|
||
The remaining uncovered lines in these modules are `unreachable` arms guarded
|
||
upstream, and diagnostic paths inside the tests themselves.
|
||
|
||
---
|
||
|
||
## 3. Struct Layout Engine
|
||
|
||
### 3.1 Mini-DSL Grammar
|
||
|
||
```
|
||
struct_def := "struct" "{" field_list "}"
|
||
field_list := (field ";")*
|
||
field := type_name identifier
|
||
type_name := "u8" | "u16" | "u32" | "u64"
|
||
| "i8" | "i16" | "i32" | "i64"
|
||
| "f32" | "f64" | "bool" | "ptr"
|
||
| "[" number "]" type_name // arrays
|
||
identifier := [a-zA-Z_][a-zA-Z0-9_]*
|
||
```
|
||
|
||
### 3.2 ABI Profile Interface
|
||
|
||
```zig
|
||
pub const AbiProfile = struct {
|
||
name: []const u8,
|
||
pointer_size: u8, // bytes
|
||
max_alignment: u8, // maximum alignment enforced
|
||
|
||
/// Given a field type, return its (size, alignment) in bytes
|
||
field_layout: *const fn (field_type: FieldType) -> struct { size: u8, alignment: u8 },
|
||
|
||
/// Struct-level alignment (usually max of all field alignments)
|
||
struct_alignment: *const fn (fields: []const FieldInfo) -> u8,
|
||
|
||
/// Whether tail padding is added to round up to struct alignment
|
||
tail_padding: bool,
|
||
};
|
||
|
||
// Built-in profiles
|
||
pub const x86_64_sysv = AbiProfile{ ... }; // default
|
||
pub const x86_64_win64 = AbiProfile{ ... }; // future
|
||
pub const packed = AbiProfile{ ... }; // no padding
|
||
```
|
||
|
||
### 3.3 Layout Computation Algorithm
|
||
|
||
```
|
||
function compute_layout(fields, abi):
|
||
offset = 0
|
||
struct_align = 1
|
||
result_fields = []
|
||
|
||
for each field in fields:
|
||
(size, align) = abi.field_layout(field.type)
|
||
struct_align = max(struct_align, align)
|
||
|
||
# Insert padding for alignment
|
||
padding = (align - (offset % align)) % align
|
||
if padding > 0:
|
||
emit_padding(offset, padding)
|
||
offset += padding
|
||
|
||
# Place field
|
||
emit_field(field.name, field.type, offset, size)
|
||
offset += size
|
||
|
||
# Tail padding
|
||
if abi.tail_padding:
|
||
tail = (struct_align - (offset % struct_align)) % struct_align
|
||
if tail > 0:
|
||
emit_padding(offset, tail)
|
||
offset += tail
|
||
|
||
return StructLayout {
|
||
total_size: offset,
|
||
alignment: struct_align,
|
||
fields: result_fields,
|
||
}
|
||
```
|
||
|
||
### 3.4 Layout Result Structure
|
||
|
||
```zig
|
||
pub const StructLayoutResult = struct {
|
||
total_size: usize,
|
||
alignment: u8,
|
||
fields: []const LayoutField,
|
||
bytes: []const ByteInfo, // byte-by-byte map for visualization
|
||
abi_name: []const u8,
|
||
};
|
||
|
||
pub const LayoutField = struct {
|
||
name: []const u8,
|
||
type_name: []const u8,
|
||
offset: usize,
|
||
size: usize,
|
||
alignment: u8,
|
||
padding_before: usize,
|
||
};
|
||
|
||
pub const ByteInfo = union(enum) {
|
||
field: struct { field_index: usize, byte_within_field: usize },
|
||
padding: struct { before_field_index: usize },
|
||
tail_padding: void,
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Unit Conversion Engine
|
||
|
||
### 4.1 Architecture
|
||
|
||
Units are organized by category. Each category has a canonical base unit. All conversions go through the base unit (from -> base -> to), except temperature which uses formula-based conversion.
|
||
|
||
```zig
|
||
pub const UnitCategory = enum {
|
||
length, mass, temperature, time, digital_storage,
|
||
speed, area, volume, energy, pressure, data_rate, angle,
|
||
};
|
||
|
||
pub const UnitDef = struct {
|
||
name: []const u8, // canonical name (e.g., "km")
|
||
aliases: []const []const u8, // alternatives (e.g., "kilometer", "kilometers")
|
||
category: UnitCategory,
|
||
/// base = value * to_base_factor + to_base_offset
|
||
/// DERIVED at comptime from the exact text below; never written by hand.
|
||
to_base_factor: f64,
|
||
to_base_offset: f64 = 0,
|
||
/// The factor as exact text (decimal or `p/q`), or null when the factor is
|
||
/// irrational and no exact form exists.
|
||
factor_text: ?[]const u8 = null,
|
||
offset_text: ?[]const u8 = null,
|
||
|
||
pub fn toBase(self: UnitDef, value: f64) f64 {
|
||
return value * self.to_base_factor + self.to_base_offset;
|
||
}
|
||
|
||
/// The reverse direction is DERIVED, not stored.
|
||
pub fn fromBase(self: UnitDef, base: f64) f64 {
|
||
return (base - self.to_base_offset) / self.to_base_factor;
|
||
}
|
||
};
|
||
```
|
||
|
||
**Factors are declared as exact text; the f64 is derived.** Tables are written
|
||
with `define(.length, &.{ .{ .name = "in", .factor = "0.0254" }, ... })`, and
|
||
`define` computes the f64 fields at comptime. This is the same single-source-of-
|
||
truth argument as `fromBase` below, applied one level up: an inch is exactly
|
||
127/5000 m, and if the exact and approximate forms were both hand-written they
|
||
could disagree.
|
||
|
||
Some factors need the `p/q` form because they have no terminating decimal
|
||
expansion: Fahrenheit scales by exactly 5/9, a knot is exactly 1852/3600 m/s, a
|
||
torr exactly 101325/760 Pa. A few units are defined in terms of pi (the angle
|
||
units, and the parsec at 648000/pi au) and so have NO exact rational factor;
|
||
those declare `approx_factor` instead and their conversions fall back to the
|
||
inexact tier.
|
||
|
||
**Only the to-base mapping is stored; the reverse is derived.** An earlier draft
|
||
of this design gave each unit four fields (`to_base_factor`/`to_base_offset`
|
||
plus `from_base_factor`/`from_base_offset`). That was changed because it lets a
|
||
table entry describe a conversion that disagrees with its own inverse: nothing
|
||
stops a typo in `from_base_factor` from making `km -> m -> km` lossy. Deriving
|
||
`fromBase` by inverting `toBase` gives one source of truth per unit, halves the
|
||
numbers a contributor has to get right, and makes round-trip correctness
|
||
structural rather than something tests have to police. A unit test still
|
||
round-trips every unit and every within-category unit pair as a guard against
|
||
bad factors.
|
||
|
||
### 4.2 Conversion Resolution
|
||
|
||
Unit names resolve through `findUnit(name)`, which searches every category's
|
||
table by canonical name and alias:
|
||
|
||
1. An **exact, case-sensitive** match is tried first. This is what keeps
|
||
case-distinguished units meaningful: `K` is Kelvin (not a "kilo" something),
|
||
`B` is byte, and `kB` (1000) never silently becomes `KiB` (1024).
|
||
2. Only if nothing matches exactly does a **case-insensitive** pass run, which
|
||
is what makes forgiving input like `KM` or `Celsius` work.
|
||
|
||
Conversion goes through the category base unit: `to.fromBase(from.toBase(value))`.
|
||
Both units must be in the same category, or the result is `IncompatibleUnits`.
|
||
An unrecognized name is `UnknownUnit`.
|
||
|
||
A single multiplicative factor is reported alongside the result only when both
|
||
units are linear (zero offset). Temperature conversions are affine, so no single
|
||
factor describes them and the factor is reported as null.
|
||
|
||
**Free-form conversion requests** (`units.parseRequest`) let conversion be plain
|
||
expression input rather than a special subcommand, so `tally 32F in C` and typing
|
||
`100 km to mi` in the TUI both work:
|
||
|
||
- A standalone, whitespace-delimited, case-insensitive `to` or `in` is the
|
||
trigger. This needs no unit-aware tokenizer, because neither word is otherwise
|
||
an identifier in the grammar.
|
||
- No separator means `parseRequest` returns null and the caller falls through
|
||
to ordinary expression evaluation, so existing behavior is untouched.
|
||
- **`in` is both a separator and the name for inches.** Candidate separators are
|
||
therefore tried last-to-first, keeping the first reading that yields a valid
|
||
conversion. In `5 in in cm` the later `in` is the separator; in `100 mm in in`
|
||
the earlier one is, because only that reading resolves. `1 in in in` is a valid
|
||
identity. `min` is untouched because a separator must be whitespace-delimited.
|
||
- Backtracking also improves errors: if a candidate reading fails, the reason is
|
||
remembered, so a committed-but-invalid request reports
|
||
`UnknownUnit`/`IncompatibleUnits` instead of a confusing parse error.
|
||
- The trailing unit is split off by trying the **longest** suffix first, so
|
||
`100 km` yields `("100", km)` and not `("100 k", m)`, and no space is required
|
||
(`32F`). A candidate must start at a **token boundary** (the preceding
|
||
character may not be a letter), which is what stops the trailing `s` of
|
||
`100 smoots` from being read as seconds.
|
||
- The value portion is returned **unevaluated** as text, so `parseRequest` needs
|
||
no dependency on the evaluator and callers can evaluate it in whatever
|
||
environment they already have. This is why `2*3 kg to lb` works.
|
||
|
||
NOT YET IMPLEMENTED: unit-qualified arithmetic (`5 kg + 3 lb`), which genuinely
|
||
does need unit-aware tokenizing and evaluation. The planned approach:
|
||
|
||
For expressions like `5 kg + 3 lb`, the parser would:
|
||
1. Identify `5 kg` as a unit-qualified value
|
||
2. See `+ 3 lb` and convert `3 lb` to `kg` (left-hand unit wins)
|
||
3. Evaluate as `5 + 1.36078 = 6.36078 kg`
|
||
|
||
### 4.3 Adding New Units
|
||
|
||
New units require only a table entry - no parser changes:
|
||
|
||
```zig
|
||
// In units.zig
|
||
const length_units = [_]UnitDef{
|
||
.{ .name = "m", .aliases = &.{"meter", "meters"}, .category = .length, .to_base_factor = 1.0 },
|
||
.{ .name = "km", .aliases = &.{"kilometer", "kilometers"}, .category = .length, .to_base_factor = 1000.0 },
|
||
.{ .name = "mi", .aliases = &.{"mile", "miles"}, .category = .length, .to_base_factor = 1609.344 },
|
||
// ... add new entries here
|
||
};
|
||
```
|
||
|
||
Invariants enforced by tests rather than convention: every category's base unit
|
||
must have `to_base_factor == 1` and no offset, no unit may have a zero factor,
|
||
canonical names must be unique across all categories, every unit must be listed
|
||
in the table matching its own `category` field, and every unit and unit pair must
|
||
round-trip. A bad table entry fails the suite instead of silently producing wrong
|
||
answers.
|
||
|
||
---
|
||
|
||
## 5. Financial Module
|
||
|
||
### 5.1 CAGR
|
||
|
||
```
|
||
CAGR = (end_value / start_value) ^ (1 / periods) - 1
|
||
```
|
||
|
||
### 5.2 Compound Interest
|
||
|
||
```
|
||
FV = PV * (1 + rate/100/m)^(m * years)
|
||
```
|
||
|
||
Solvable for any of its four variables, all in closed form:
|
||
|
||
```
|
||
PV = FV / (1 + rate/100/m)^(m*years)
|
||
rate = m * ((FV/PV)^(1/(m*years)) - 1) * 100
|
||
years = ln(FV/PV) / (m * ln(1 + rate/100/m))
|
||
```
|
||
|
||
`m` (compounds per year) is deliberately NOT solvable. As `m` rises, FV climbs
|
||
from the annual case toward the continuous limit `PV * e^(rate*years/100)`, so a
|
||
solution exists only inside that band: for 10,000 at 6% over 10 years, only for
|
||
future values between about 17,908 and 18,221. Nearly every real input has no
|
||
answer, and a fractional answer ("compound 7.3 times a year") would be
|
||
meaningless, so it stays an input with a prefilled default of 1.
|
||
|
||
**Nominal versus effective.** `compoundRate` returns a NOMINAL annual rate: at
|
||
`m = 12` it is the APR a lender quotes, not what the money earned.
|
||
`effectiveAnnualRate` converts (18% nominal monthly is 19.56% effective), and the
|
||
TUI shows both whenever `m > 1`. This only matters when solving, because a rate
|
||
that was typed in is one the user already understands; a rate the calculator
|
||
produces is one they are about to compare against something else.
|
||
|
||
Solving for the rate at `m = 1` is exactly CAGR. Both entry points are kept: CAGR
|
||
is a named thing people look for, and it takes periods rather than years so it
|
||
works for quarters or months without a frequency argument. A test asserts the two
|
||
agree, so they cannot drift.
|
||
|
||
### 5.3 TVM (Time Value of Money)
|
||
|
||
The five TVM variables: N (periods), I/Y (interest rate per period), PV (present value), PMT (payment), FV (future value).
|
||
|
||
Relationship:
|
||
```
|
||
PV * (1 + r)^N + PMT * [((1 + r)^N - 1) / r] + FV = 0
|
||
```
|
||
where `r = I/Y / 100`.
|
||
|
||
Solving for the unknown variable:
|
||
- **FV, PV, PMT**: algebraic (closed-form)
|
||
- **N**: logarithmic solution
|
||
- **I/Y**: Newton-Raphson iterative solver (no closed form)
|
||
|
||
Sign convention is the cash-flow one used by financial calculators: money
|
||
received is positive, money paid out is negative. A borrower taking a 200,000
|
||
loan has `pv = 200000` and a negative `pmt`. The solver never flips signs on the
|
||
caller's behalf, because a silent flip is harder to debug than a wrong-looking
|
||
answer.
|
||
|
||
The rate solver takes its derivative by central difference rather than
|
||
analytically: the annuity-due form of the derivative is unwieldy and the
|
||
numerical version is not the accuracy bottleneck. Two details make it reliable:
|
||
|
||
1. **Scale-relative acceptance.** The residual of the TVM equation is
|
||
proportional to the size of the cash flows, so it is compared against
|
||
`tolerance * max(|pv|, |fv|, |pmt| * n, 1)`. With an absolute tolerance a
|
||
200,000 mortgage cannot converge at all, because floating-point noise in the
|
||
residual exceeds any fixed epsilon.
|
||
2. **Multiple seeds.** The residual can be flat or badly sloped near a poor
|
||
initial guess, so the solver retries from a small set of starting rates
|
||
(5%, 1%, 10%, 0.5%, 25%, -5%, 50%). A single seed fails on well-posed inputs.
|
||
|
||
### 5.4 Amortization
|
||
|
||
An amortization schedule is generated period by period from the balance rather
|
||
than from a closed form, because that is the only way the rounding matches what a
|
||
lender's table shows. Each period:
|
||
|
||
```
|
||
interest = round(balance * r)
|
||
principal = payment - interest
|
||
balance = balance - principal
|
||
```
|
||
|
||
Rows are read from the borrower's side, so every figure is positive: `payment` is
|
||
an amount paid, `balance` an amount owed. Mixing in the TVM sign convention only
|
||
makes a table harder to read.
|
||
|
||
Three behaviors are deliberate:
|
||
|
||
- **The final period settles the balance exactly.** Cent rounding leaves a
|
||
residue over hundreds of periods, so the last payment differs slightly from the
|
||
rest. This is what real schedules do, and it is why the total of the principal
|
||
column equals the loan amount to the cent.
|
||
- **An overlarge payment retires the loan early**, and the schedule simply ends
|
||
short of `periods`.
|
||
- **An underfunded term ends in a balloon.** A payment that covers the interest
|
||
but not the full term leaves a large final payment, which is shown rather than
|
||
smoothed away. A payment that does not cover even the first period's interest
|
||
never amortizes at all, so it is a domain error rather than an infinite loop.
|
||
|
||
Single rows (`amortizationEntry`) and totals (`amortizationTotals`) walk the same
|
||
cursor as the full schedule, so a row fetched on its own can never disagree with
|
||
the same row inside a table. Only the full schedule allocates.
|
||
|
||
### 5.5 Reachability: expression functions, not just a mode
|
||
|
||
Financial calculations are exposed as ordinary expression functions in standard
|
||
mode, mirroring how unit conversion is reachable from a bare expression. That
|
||
keeps them composable (`cagr(10000, 25000, 5) * 100`) and usable from the CLI
|
||
without a subcommand per formula. See FR-5.7 for the function list.
|
||
|
||
This required the evaluator's function dispatch to grow beyond the 0/1/2-argument
|
||
cases it previously handled; financial functions take three or four arguments. An
|
||
unknown name at those arities still reports `UnknownFunction`, and a wrong arity
|
||
for a known financial function does too, rather than filling in defaults.
|
||
|
||
The one exception is the amortization table itself: a table is not a value, so it
|
||
lives behind the `tally amort` subcommand (FR-6.11). The per-period figures are
|
||
still reachable as functions (`amort_interest(...)` and friends).
|
||
|
||
### 5.6 Precision: f64 plus explicit rounding
|
||
|
||
This module is f64, not the exact `Number` tier used elsewhere (design 2.7).
|
||
Every formula here needs a non-integer power or a logarithm - CAGR raises to
|
||
`1/n`, the period solver takes `ln`, the rate solver iterates - and those escape
|
||
the rationals by definition, so there is nothing for the exact tier to preserve.
|
||
|
||
What money needs is not exactness but controlled rounding, which is a separate
|
||
concern: `roundToScale(value, decimals, mode)` and `roundToCents` are applied at
|
||
the point a figure becomes an amount. The default is half-even (banker's
|
||
rounding); half-up is available but is not the default because it biases totals
|
||
upward across many roundings.
|
||
|
||
---
|
||
|
||
## 6. C API (for Android/FFI)
|
||
|
||
```zig
|
||
// c_api.zig - extern "C" exports
|
||
//
|
||
// SAFETY: All string inputs use pointer + length (not null-terminated).
|
||
// This avoids buffer overread vulnerabilities and allows embedded nulls.
|
||
// All returned strings include a length - caller must free with tally_result_free().
|
||
|
||
pub const CalcString = extern struct {
|
||
ptr: [*]const u8,
|
||
len: usize,
|
||
};
|
||
|
||
pub const CalcResult = extern struct {
|
||
/// JSON-encoded result. Null if error.
|
||
json_ptr: ?[*]u8,
|
||
json_len: usize,
|
||
/// Error message if json_ptr is null.
|
||
error_ptr: ?[*]u8,
|
||
error_len: usize,
|
||
};
|
||
|
||
/// Evaluate an expression, return JSON result.
|
||
/// Caller must free the result with tally_result_free().
|
||
export fn tally_eval(
|
||
expr_ptr: [*]const u8,
|
||
expr_len: usize,
|
||
mode: c_int, // 0=standard, 1=programmer, 2=financial
|
||
config_ptr: ?[*]const u8, // optional JSON config (null if none)
|
||
config_len: usize, // 0 if no config
|
||
) callconv(.c) CalcResult;
|
||
|
||
/// Compute struct layout, return JSON result.
|
||
export fn tally_struct_layout(
|
||
def_ptr: [*]const u8,
|
||
def_len: usize,
|
||
abi_ptr: [*]const u8, // "sysv", "win64", "packed"
|
||
abi_len: usize,
|
||
endian: c_int, // 0=little, 1=big
|
||
) callconv(.c) CalcResult;
|
||
|
||
/// Convert a value between units, return JSON result.
|
||
export fn tally_convert(
|
||
value: f64,
|
||
from_ptr: [*]const u8,
|
||
from_len: usize,
|
||
to_ptr: [*]const u8,
|
||
to_len: usize,
|
||
) callconv(.c) CalcResult;
|
||
|
||
/// Free a CalcResult's buffers.
|
||
export fn tally_result_free(result: *CalcResult) callconv(.c) void;
|
||
|
||
/// Get version string (static lifetime, do not free).
|
||
export fn tally_version(out_len: *usize) callconv(.c) [*]const u8;
|
||
```
|
||
|
||
Design notes:
|
||
- **No null-terminated strings.** All inputs take `(ptr, len)` pairs. This prevents buffer overread, allows binary data in expressions if ever needed, and is the idiomatic C pattern for length-known strings.
|
||
- **`CalcResult` struct** bundles success/error in a single return. Caller checks `json_ptr != null` for success.
|
||
- **JSON result format** keeps the Android/FFI boundary simple - Kotlin parses JSON natively. On the JNI side, the Kotlin bridge reads `(ptr, len)` into a `ByteArray` and decodes UTF-8.
|
||
|
||
---
|
||
|
||
## 7. CLI Design
|
||
|
||
```
|
||
tally [OPTIONS] <expression>
|
||
tally struct [OPTIONS] <definition | --file path>
|
||
tally convert <value> <from_unit> <to_unit>
|
||
tally amort <principal> <rate> <periods> [payment] [--monthly] [--summary] [--exact]
|
||
|
||
Options:
|
||
-p, --programmer Programmer mode
|
||
-b, --bits <WIDTH> Bit width: 8, 16, 32, 64 (default: 64)
|
||
--base <BASE> Output base: dec, hex, oct, bin, all (default: all)
|
||
--json JSON output
|
||
--abi <ABI> Struct ABI: sysv (default), packed
|
||
--endian <E> Endianness: little (default), big
|
||
-h, --help Show help
|
||
--version Show version
|
||
```
|
||
|
||
### CLI Output Examples
|
||
|
||
Standard:
|
||
```
|
||
$ tally "2^32 - 1"
|
||
4,294,967,295
|
||
```
|
||
|
||
Programmer (actual output, verified against the binary):
|
||
```
|
||
$ tally -p "0xFF & 0x0F"
|
||
dec(signed): 15
|
||
dec(unsigned): 15
|
||
hex: 00 00 00 00 00 00 00 0F
|
||
oct: 0 000 000 000 000 000 000 017
|
||
bin: 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 1111
|
||
```
|
||
Separators are spaces, not underscores, and the base prefix appears only in the
|
||
clipboard form. There is no `bits:` line.
|
||
|
||
Struct:
|
||
```
|
||
$ tally struct "struct { u32 x; u8 flags; u16 id; u64 ts; }"
|
||
ABI: x86-64 System V | Endian: little
|
||
|
||
Offset Size Field Type Align Padding
|
||
────── ──── ────────── ───── ───── ───────
|
||
0 4 x u32 4
|
||
4 1 flags u8 1
|
||
5 1 (padding) 1 byte
|
||
6 2 id u16 2
|
||
8 8 ts u64 8
|
||
|
||
Total: 16 bytes (alignment: 8, padding: 1 byte, tail: 0)
|
||
|
||
Memory map:
|
||
00: [xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx] x
|
||
04: [ffff ffff] [········] [iiii iiii iiii iiii] flags, pad, id
|
||
08: [tttt tttt tttt tttt tttt tttt tttt tttt ts
|
||
0C: tttt tttt tttt tttt tttt tttt tttt tttt]
|
||
```
|
||
|
||
Unit conversion (actual output):
|
||
```
|
||
$ tally convert 100 km mi
|
||
100 km = 62.13711922373339696174 mi
|
||
|
||
$ tally "72 F to C"
|
||
72 F = 22.22222222222222222222 C
|
||
```
|
||
Both operands are echoed, canonical short names are used rather than long or
|
||
symbol forms, and the full exact expansion is shown (20 fractional digits for a
|
||
non-terminating one). `tally "5 kg + 3 lb"` is FR-4.2 and is NOT implemented: it
|
||
reports `error: unexpected token`.
|
||
|
||
---
|
||
|
||
## 8. TUI Layout
|
||
|
||
### 8.0.0 How the TUI is tested
|
||
|
||
Terminal code is usually left uncovered on the grounds that it needs a terminal.
|
||
Most of it does not. Two harnesses cover nearly all of it:
|
||
|
||
- **Rendered frames.** `src/tui/test_render.zig` draws a real frame through the
|
||
real widget draw path into a real surface, then reads the cells back as rows of
|
||
text. Assertions are made against what a user would see (`"= PMT -1,199.10"`,
|
||
`"rows 1-7 of 360"`), plus `furniture(rows).intact()`, which checks that the
|
||
separator, prompt and status rows are still where they belong. That last check is
|
||
the load-bearing one: content which overflows its region lands on those rows, so
|
||
checking them is how overflow is detected.
|
||
|
||
This originally offered `wellFormed(rows, width)` instead, described here as a
|
||
check that no column had been miscomputed. It compared each row's length against
|
||
the width it had just been allocated with, so it could never fail. Two layout
|
||
bugs (programmer rows drawn over the prompt at 100x16, financial chips drawn over
|
||
it at 20x5) lived under a green suite because of it. Style is still not
|
||
observable: `flatten` keeps graphemes and drops attributes, so focus highlighting
|
||
and the cursor cannot be asserted, and tests that care assert a neighbouring
|
||
glyph and say so.
|
||
- **Real event handlers.** `vxfw.EventContext` is constructible in a test, so key
|
||
presses and mouse events go through `handleKey` and `handleMouse` rather than
|
||
through a reimplementation of them. This is what catches a binding that was
|
||
never routed, or one mode swallowing another mode's keys.
|
||
|
||
What is left uncovered is the terminal boundary itself: the event loop, `vaxis`
|
||
initialisation, and `main`. Coverage is reported per source tree (engine, CLI,
|
||
TUI) with disjoint include paths, so a file is never accounted for twice and a
|
||
query tool never has to guess which report answers for it.
|
||
|
||
### 8.0 Mouse Input Architecture
|
||
|
||
The TUI is mouse-driven as well as keyboard-driven (FR-7.11). Rather than each
|
||
view doing its own coordinate arithmetic against hardcoded layout constants, hit
|
||
targets are registered as a side effect of drawing:
|
||
|
||
```zig
|
||
pub const Action = union(enum) {
|
||
mode: Mode, // tab bar
|
||
prog_field: struct { field: ProgField, bit: ?u7 }, // focus field, optional cursor
|
||
toggle_bit: u7, // flip one bit
|
||
focus_input, close_help, toggle_float,
|
||
cycle_width, toggle_endian, toggle_signedness, toggle_float_format,
|
||
conv_category: UnitCategory, conv_from: usize, conv_to: usize, conv_swap,
|
||
fin_form: Form, fin_field: usize, // financial chips and rows
|
||
};
|
||
|
||
pub const RegionSet = struct {
|
||
items: [512]HitRegion, // { row, col, len, action }
|
||
count: usize,
|
||
pub fn clear(self: *RegionSet) void;
|
||
pub fn add(self: *RegionSet, row: u16, col: u16, len: u16, action: Action) void;
|
||
pub fn at(self: *const RegionSet, row: u16, col: u16) ?Action;
|
||
};
|
||
```
|
||
|
||
Key properties:
|
||
|
||
- **Regions are rebuilt every frame.** `clearRegions()` runs at the top of the
|
||
draw function, and each view calls `addRegion` right where it draws the thing.
|
||
Hit targets therefore cannot drift out of sync with what is on screen, which is
|
||
the usual failure mode for hand-maintained click maps.
|
||
|
||
With one caveat that was found by review: registration is co-located with
|
||
drawing, but drawing itself had no bounds check, so a row drawn off the bottom
|
||
registered a region anyway and a click on the separator focused an invisible
|
||
field. Co-location keeps the mapping honest only as far as the drawing is; the
|
||
height guards added to the programmer and convert views are what make the
|
||
property hold.
|
||
- **Newest region wins.** `at()` searches backwards, so a view can register a
|
||
broad row-wide fallback first (click anywhere on the HEX row to focus it) and
|
||
then finer targets on top (click a specific nibble to put the cursor there).
|
||
- **Draw-time registration means one source of truth.** The same loop that
|
||
decides where a bit character goes also registers that cell, so the mapping
|
||
from cell to bit index is written once. `registerDigitRegions` mirrors
|
||
`drawFieldWithCursor`'s digit walk for the same reason.
|
||
- **Left press only.** Release, motion, and drag are ignored so one physical
|
||
click produces exactly one action. The wheel is the one exception: in financial
|
||
mode `wheel_up`/`wheel_down` scroll the amortization schedule, handled before
|
||
the press filter because a wheel event is not a press. The wheel does not go
|
||
through the region table at all, since it acts on the view rather than on a
|
||
specific cell.
|
||
- **A fixed-size buffer, not an allocation.** Drawing happens on every keystroke
|
||
and must not fail; an exhausted budget silently drops extra regions (a click
|
||
does nothing) instead of erroring. 512 comfortably covers the worst case, the
|
||
128-bit programmer view.
|
||
|
||
vxfw already puts the terminal in mouse mode and hit-tests surfaces, delivering
|
||
`.mouse` events to the root widget with local coordinates equal to screen
|
||
coordinates, so no terminal-level setup is needed here.
|
||
|
||
Click semantics are chosen per field to match what the data means: a bit grid
|
||
cell and a BIN digit each represent exactly one bit, so clicking flips it, while
|
||
a HEX nibble or OCT digit holds several bits with no single sensible "toggle", so
|
||
clicking places the cursor for typing instead.
|
||
|
||
### 8.1 Standard Mode
|
||
```
|
||
┌─ Tally ─────────────────────── [Standard] [Programmer] [Financial] ─┐
|
||
│ │
|
||
│ History: │
|
||
│ 2^10 = 1024 │
|
||
│ sqrt(144) = 12 │
|
||
│ Ans * 2 = 24 │
|
||
│ │
|
||
│ ───────────────────────────────────────────────────────────────── │
|
||
│ > 3 * pi + 1_ │
|
||
│ │
|
||
│ = 10.42477796... │
|
||
│ │
|
||
└─ ?:help | Tab:mode | Enter:eval | Ctrl-L:clear | Ctrl-C:quit ───────────┘
|
||
```
|
||
|
||
The footer is the real status line. The `[d]ec [h]ex [o]ct [b]in` quick-switch it
|
||
used to show was FR-7.8, which has been removed: those letters are hex and binary
|
||
digits once the value fields became editable.
|
||
|
||
### 8.2 Programmer Mode
|
||
```
|
||
┌─ Tally ─────────────────────── [Standard] [Programmer] [Financial] [Convert] ─┐
|
||
│ Bit Width: [64] Signed: [yes] Endian: [LE] │
|
||
│ │
|
||
│ ┌─ Bit Pattern (63 -> 0) ─────────────────────────────────────────────────────┐ │
|
||
│ │ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 │ │
|
||
│ │ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 │ │
|
||
│ └─────────────────────────────────────────────────────────────────────────────┘ │
|
||
│ cursor: bit 3 ▲ │
|
||
│ │
|
||
│ Signed: 255 │
|
||
│ Unsigned: 255 │
|
||
│ Hex: 0x0000_0000_0000_00FF │
|
||
│ Oct: 0o377 │
|
||
│ Bin: 0000 0000 0000 0000 0000 0000 1111 1111 │
|
||
│ │
|
||
│ > 0xFF & 0x0F_ │
|
||
│ = 15 │
|
||
│ │
|
||
└──── arrows:nav Space:toggle w:width s:sign e:endian y:yank -- Tab:mode ----┘
|
||
```
|
||
|
||
**NOTE: The wireframe above (8.2) is superseded by the editable-fields
|
||
design below. Keeping the old wireframe for reference during transition.**
|
||
|
||
#### 8.2.1 Programmer Mode - Editable Fields (REVISED DESIGN)
|
||
|
||
The programmer mode is an **editable multi-field form** where every
|
||
representation of the value is live-editable. A change in any field
|
||
instantly updates all others. Closer to integer.exposed than a
|
||
traditional calculator with a prompt.
|
||
|
||
Fields (navigable with Up/Down):
|
||
1. Bit grid - arrow keys move, Space toggles individual bits
|
||
2. DEC(s) - editable signed decimal
|
||
3. DEC(u) - editable unsigned decimal
|
||
4. HEX - editable hex (space-separated bytes: FF 00 AB CD)
|
||
5. ASCII - read-only (derived) view of the bytes, one glyph per byte,
|
||
MSB first, aligned column-for-column under the HEX row directly above
|
||
it. Printable bytes (0x20-0x7E) show as themselves; every other byte
|
||
shows as `.`, matching the `xxd` hex-dump convention (e.g. `.ELF`,
|
||
`...ascii`). Not part of the Up/Down focus cycle since it has nothing
|
||
to edit.
|
||
6. OCT - editable octal
|
||
7. BIN - editable binary (nibble-separated: 1111 0000)
|
||
8. Expression - full expression evaluator (& | ^ ~ << >> etc.)
|
||
|
||
Navigation:
|
||
- Up/Down moves focus between fields
|
||
- When a text field is focused, typing replaces its content;
|
||
value updates on Enter (or live as you type - TBD)
|
||
- When bit grid is focused: left/right/up/down navigate, Space toggles
|
||
- Tab cycles between Standard/Programmer mode
|
||
- Ctrl-W cycles bit width (8/16/32/64/128)
|
||
- Ctrl-E toggles endianness display (LE/BE)
|
||
|
||
Bit width is a non-destructive display lens:
|
||
- The stored value keeps full precision (u128). Cycling the width never
|
||
truncates it, so narrowing to a smaller width and then widening again
|
||
restores the hidden upper bits.
|
||
- The DEC/HEX/OCT/BIN/ASCII rows and the bit grid show the value masked to
|
||
the current width.
|
||
- While the stored value has bits set beyond the current width, a warning
|
||
line is shown ("value exceeds N bits - showing low bits (widen to
|
||
restore)"). It is a warning, not an error: the value is still usable.
|
||
- Explicit value edits (typing a decimal digit, toggling a bit, editing a
|
||
hex/oct/bin digit) commit to the current width, clearing any hidden upper
|
||
bits. Only width cycling is non-destructive; deliberately editing at a
|
||
narrow width means you are now working at that width.
|
||
|
||
Endianness:
|
||
- Toggle with Ctrl-E; header shows current setting (`Endian: LE` / `Endian: BE`)
|
||
- Default: big-endian. The HEX row's primary job is to show the value in hex,
|
||
so by default it reads MSB-first and matches DEC/OCT/BIN. Little-endian is
|
||
the opt-in "how it sits in x86-64 memory" view.
|
||
- Affects: HEX byte display order and ASCII byte order only. In LE the display
|
||
bytes are reversed (e.g. 0xDEADBEEF -> `EF BE AD DE`).
|
||
- Does NOT affect: the underlying value, DEC, OCT, or BIN display, nor the
|
||
clipboard `raw` form of HEX (which stays the canonical MSB-first number).
|
||
- Rationale for BE default (revised from an earlier LE default): defaulting to
|
||
LE would reverse the HEX row so it no longer matches the number shown in the
|
||
DEC/BIN rows directly beside it, which is surprising in a calculator context.
|
||
|
||
Text/ASCII literals:
|
||
- Single-quoted strings in expressions: `'ELF'`, `'RIFF'`, `'a'`
|
||
- Each character becomes one byte packed into the integer
|
||
- Packing order follows endianness setting:
|
||
- BE: first char -> most significant used byte (`'AB'` = 0x4142)
|
||
- LE: first char -> least significant byte (`'AB'` = 0x4241)
|
||
- Max chars = bit_width / 8 (1 for 8-bit, 2 for 16, 4 for 32, 8 for 64)
|
||
- Non-ASCII characters are an error
|
||
- Useful for: magic numbers (`'ELF'`), protocol tags, inspecting
|
||
how string bytes pack into integer registers
|
||
|
||
IEEE 754 float interpretation:
|
||
- A sub-view within programmer mode (toggle with a key, e.g. Ctrl-F)
|
||
- Reinterprets the current bit pattern as an IEEE 754 float
|
||
- Supported formats:
|
||
- float32 (1 sign + 8 exponent + 23 significand, bias 127)
|
||
- float64 (1 sign + 11 exponent + 52 significand, bias 1023)
|
||
- Bit grid color-codes the three fields:
|
||
- Sign bit: one color (e.g. pink)
|
||
- Exponent: another color (e.g. cyan)
|
||
- Significand: another color (e.g. green)
|
||
- Display below the bit grid shows:
|
||
- Decimal value (exact)
|
||
- Value and ULP use scientific notation for very small/large
|
||
magnitudes (e.g. a subnormal ULP shows as `1.4e-45`), fixed-point
|
||
otherwise, so each row stays on one line
|
||
- Formula: (-1)^s * 2^(e-bias) * 1.mantissa
|
||
- Classification: normal, denormal, zero, infinity, NaN
|
||
- ULP: delta to next representable value
|
||
- Raw fields: sign=0, exp=127 (unbiased: 0), frac=0x000000
|
||
- Input: can type a decimal float value (e.g. `3.14`) and it
|
||
shows the closest representable bit pattern, noting if rounding
|
||
occurred
|
||
- Auto-entry: switching into programmer mode (Tab) with a non-integer
|
||
value in `ans` opens this float view automatically on the exact f64
|
||
bit pattern, since the integer bit views cannot represent a fraction.
|
||
An integer `ans` uses the integer views as usual. Infinity/NaN also
|
||
open the float view (the only place they are representable).
|
||
- Toggling bits in the grid updates the float interpretation live
|
||
- Does NOT change bit_width setting - float32 uses 32-bit view,
|
||
float64 uses 64-bit view (auto-switches width when toggling
|
||
float format)
|
||
|
||
### 8.3 Struct Visualizer (sub-view of Programmer Mode)
|
||
```
|
||
┌─ Tally ────────────────── [Programmer > Struct Layout] ─────────────┐
|
||
│ ABI: x86-64 SysV Endian: LE │
|
||
│ │
|
||
│ Definition: │
|
||
│ struct { u32 x; u8 flags; u16 id; u64 ts; } │
|
||
│ │
|
||
│ ┌─ Memory Map ─────────────────────────────────────────────────────┐ │
|
||
│ │ 00 01 02 03 │ 04 │ 05 │ 06 07 │ 08 09 0A 0B 0C 0D 0E 0F │ │ │
|
||
│ │ ████████████ │ ▓▓ │ ░░ │ ▒▒▒▒▒ │ ░░░░░░░░░░░░░░░░░░░░░░░ │ │ │
|
||
│ │ x │flag│pad │ id │ ts │ │ │
|
||
│ └───────────────────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ Field Type Offset Size Align Pad Before │
|
||
│ x u32 0 4 4 0 │
|
||
│ flags u8 4 1 1 0 │
|
||
│ id u16 6 2 2 1 │
|
||
│ ts u64 8 8 8 0 │
|
||
│ │
|
||
│ Total: 16 bytes | Alignment: 8 | Padding: 1 byte (6.25%) │
|
||
│ Packed would be: 15 bytes │
|
||
└──── a:abi e:endian Enter:edit struct Esc:back ─────────────────────┘
|
||
```
|
||
|
||
### 8.4 Convert Mode
|
||
|
||
```
|
||
Tally [Standard] [Programmer] [Convert]
|
||
|
||
Category:
|
||
Length Mass Temperature Time Digital Storage Speed Area
|
||
Volume Energy Pressure Data Rate Angle
|
||
|
||
100 km
|
||
= 62.13711922373339 mi
|
||
1 km = 0.621371192237334 mi
|
||
|
||
From To
|
||
> nm nm
|
||
um um
|
||
mm mm
|
||
... > mi
|
||
|
||
------------------------------------------------------------------
|
||
> 100
|
||
Arrows:select | Ctrl-S:swap | `:input | Tab:mode | ?:help
|
||
```
|
||
|
||
Structure:
|
||
|
||
- **Category chips** wrap to as many lines as the terminal width needs. The
|
||
selected chip is highlighted; when the selection zone has focus on the category
|
||
column it is highlighted differently again.
|
||
- **The value** comes from the shared input line at the bottom, keeping input
|
||
consistent with the other modes. A bare number is used directly; anything else
|
||
is evaluated as a standard expression first, so `2*3.5` or `sqrt(2)` work as
|
||
input values.
|
||
- **The factor line** shows the single multiplicative factor between the two
|
||
units. Temperature is affine, so instead of a misleading number it says so.
|
||
- **Two unit columns** (From and To) list every unit in the category with the
|
||
selection marked. Both columns are clickable; the whole column slot is the
|
||
click target, not just the name text, so a one-character unit like `m` is not a
|
||
one-character target.
|
||
- If the terminal is too short to show every unit, a `... N more (resize to see
|
||
all)` line is drawn rather than silently truncating the list.
|
||
|
||
Interaction:
|
||
|
||
- Backtick toggles between the input zone and the selection zone, matching the
|
||
convention already used in programmer mode.
|
||
- In the selection zone, Left/Right move between the category, From, and To
|
||
columns; Up/Down move the selection within the focused column, wrapping.
|
||
- Ctrl-S swaps the two units, and works from either zone.
|
||
- Selecting a new category resets the unit pair to that category's defaults (its
|
||
base unit plus a distinct second unit), since the previous indices would refer
|
||
to unrelated units in the new table.
|
||
|
||
---
|
||
|
||
### 8.5 Financial Mode
|
||
|
||
```
|
||
Tally [Standard] [Programmer] [Financial] [Convert]
|
||
|
||
Calculation:
|
||
CAGR Compound TVM Amortization
|
||
|
||
> Principal 200,000
|
||
Rate per period 0.5 %
|
||
Periods 360
|
||
Payment (derived)
|
||
|
||
Payment 1,199.10 | total interest 231,677.04 | total paid 431,677.04
|
||
|
||
Period Payment Interest Principal Balance
|
||
1 1,199.10 1,000.00 199.10 199,800.90
|
||
2 1,199.10 999.00 200.10 199,600.80
|
||
3 1,199.10 998.00 201.10 199,399.70
|
||
...
|
||
rows 1-7 of 360 (PgUp/PgDn)
|
||
------------------------------------------------------------------
|
||
>
|
||
Up/Dn:field | L/R:calc | Enter:eval | Space:END/BGN | Ctrl-U:clear
|
||
```
|
||
|
||
Four forms behind one set of chips: CAGR, Compound, TVM, Amortization. The TVM
|
||
form adds a sixth row, an END/BGN toggle for annuity due.
|
||
|
||
**Solving by omission.** TVM and compound interest solve for whichever field is
|
||
left blank, the way a financial calculator does and the way the engine's own
|
||
`TvmParams` is shaped. Blank is therefore a meaningful state rather than an error,
|
||
and a blank solve-for field renders as `[solve]` instead of looking like missing
|
||
input. When the form is not yet answerable it says what it is waiting for ("clear
|
||
one field to solve for it"), so an incomplete form is never a silent no-op.
|
||
Amortization treats a blank payment differently: it means "derive it", since a
|
||
schedule always has a payment.
|
||
|
||
**Defaults are prefilled, not placeheld.** Compounds/year starts at `1` rather
|
||
than showing an empty field that means "annual". That keeps "blank means solve for
|
||
this" true for every field the user sees, instead of blank meaning a default in one
|
||
place and a solve slot in another. The prefill is baked into the comptime initial
|
||
state rather than applied at draw time, because a draw-time default cannot tell
|
||
"never touched" from "deliberately cleared". Clearing a non-solvable field has
|
||
to report that it needs a value, not try to solve for it.
|
||
|
||
**A solved rate says which kind it is.** Above annual compounding the compound
|
||
form reports `= rate 18.00% nominal (19.56% effective)`. See design 5.2 for why:
|
||
the nominal figure alone invites a wrong comparison against a quoted APY, and this
|
||
is the only place the calculator produces a rate rather than consuming one.
|
||
|
||
**Two ways to enter a number, deliberately.** Typing in the form zone goes
|
||
straight into the focused field, which is what makes quick entry work. The shared
|
||
input line at the bottom evaluates an expression and drops the result into the
|
||
focused field, so a value can also be computed from outside the form.
|
||
|
||
**Fields hold expressions, and group as you type.** `12 * 30` is valid input for a
|
||
period count; it displays verbatim in orange with an `(Enter)` prompt, and Enter
|
||
evaluates it in place. A plain number is grouped for display as it is typed
|
||
(`200` then `0` shows `2,000`), while the buffer keeps the ungrouped text so it
|
||
still parses and still round-trips through Enter. Grouping the buffer instead
|
||
would mean stripping separators on every read and would make cursor arithmetic
|
||
depend on where the commas happen to fall. A typed comma is absorbed for the same
|
||
reason.
|
||
|
||
A field holding an unevaluated expression is reported as such rather than being
|
||
treated as empty, since a blank field means "solve for this" in two of the four
|
||
forms and silently conflating the two would be a wrong answer rather than a
|
||
cosmetic problem.
|
||
|
||
**Rates say they are rates.** A percent field renders a trailing `%` after the
|
||
value and absorbs a typed one, so `6%` and `6` both mean six percent. The label
|
||
carries that information too, but not once the eye has moved to the value column,
|
||
which is where a bare `6` is ambiguous between a rate and a multiplier.
|
||
|
||
**Focus styling.** The focused field is a dark selection fill (Molokai's own
|
||
selection colour) with the value in its normal foreground, and a single yellow
|
||
block cursor. The earlier version reversed bright cyan into the background, which
|
||
read as a 1980s terminal and fought every other colour on screen; a neutral fill
|
||
keeps the number legible and leaves the cursor as the only bright cell on the row.
|
||
The row marker and focused label are yellow, matching the mode's own tab colour
|
||
rather than introducing a fourth accent.
|
||
|
||
**Results are live, not history.** Every keystroke recomputes, so there is
|
||
nothing to submit and nothing is appended to history. That is why financial mode
|
||
is the one mode whose input line does not produce a history entry.
|
||
|
||
**The schedule is the reason this mode exists.** Everything else here is
|
||
available as an expression function (FR-5.7), but a 360-row table is not a value.
|
||
It scrolls with PgUp/PgDn from either zone and with the mouse wheel, and shows
|
||
`rows X-Y of N` whenever it is showing a window rather than the whole thing. The
|
||
scroll offset is clamped at draw time rather than at scroll time, so a resize or a
|
||
changed period count cannot leave a stale offset showing an empty table.
|
||
|
||
**Formula breakdown.** Under the result, the relationship being solved is shown,
|
||
plus a substituted line for the forms where it fits (`= (25000 / 10000)^(1/5) -
|
||
1`). TVM and amortization show the general form only; substituting five variables
|
||
into the TVM identity produces a line too long to be useful.
|
||
|
||
Interaction, consistent with the other modes:
|
||
|
||
- Backtick toggles the input zone and the form zone.
|
||
- Up/Down move between fields, wrapping. Left/Right switch calculation, so every
|
||
form is reachable from the keyboard alone (FR-7.7).
|
||
- Enter evaluates the focused field in place.
|
||
- Space toggles END/BGN on the payments row; Ctrl-U (or Delete) empties a field,
|
||
which is also how a TVM variable is marked as the one to solve for.
|
||
- Clicking a chip selects a calculation; clicking a row focuses that field.
|
||
Switching calculation resets the focused field and the scroll offset, but each
|
||
form keeps its own entries, so moving between them does not discard typing.
|
||
|
||
### 8.6 Help Overlay
|
||
|
||
The overlay is a static line list (headers, bindings, free text) rendered through
|
||
one windowed loop, not a sequence of sections that each gate themselves on the
|
||
remaining height. The old shape had a specific failure: every section ended with
|
||
`if (row < height - 6)`, so as sections were added the later ones silently
|
||
disappeared on a 24-row terminal with no indication anything was missing. That was
|
||
found by a test asserting the Convert section was present, which failed once the
|
||
Financial section was added above it.
|
||
|
||
As a line list the content can be scrolled (Up/Down, PgUp/PgDn, wheel) and its
|
||
length is knowable without drawing, which is what lets the App clamp its own
|
||
scroll offset. Any non-scrolling key returns, preserving the "press a key to get
|
||
out" behaviour. The footer shows `lines X-Y of N` only when something is off
|
||
screen, so a tall terminal is not cluttered with a position indicator it does not
|
||
need.
|
||
|
||
---
|
||
|
||
## 9. Android UI Design
|
||
|
||
The Android app uses a fundamentally different interaction model from the TUI. Where the TUI is keyboard-driven with a prompt, Android is **touch-first with purpose-built input surfaces** for each mode. No text-cursor-in-a-prompt paradigm - instead, tappable buttons, interactive grids, and form fields.
|
||
|
||
### 9.1 Navigation & Shell
|
||
|
||
Bottom navigation bar with 4 destinations: **Standard**, **Programmer**, **Financial**, **Convert**.
|
||
|
||
```
|
||
┌─────────────────────────────────────────┐
|
||
│ ┌─────────────────────────────────┐ │
|
||
│ │ │ │
|
||
│ │ Mode Content Area │ │
|
||
│ │ │ │
|
||
│ └─────────────────────────────────┘ │
|
||
│ │
|
||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||
│ 🔢 Standard 💻 Programmer 💰 Finance 🔄 Convert │
|
||
└─────────────────────────────────────────┘
|
||
```
|
||
|
||
### 9.2 Standard Calculator Screen
|
||
|
||
Classic calculator layout. Expression builds at top, result previews live below it, button grid fills the bottom half.
|
||
|
||
```
|
||
┌─────────────────────────────────────────┐
|
||
│ Expression area (scrollable) │
|
||
│ ┌─────────────────────────────────┐ │
|
||
│ │ 2 × (3 + 4) │ │ ← expression in natural format
|
||
│ │ = 14 │ │ ← live result preview (dimmed)
|
||
│ └─────────────────────────────────┘ │
|
||
│ │
|
||
│ History peek: last 2-3 results │
|
||
│ ┌─────────────────────────────────┐ │
|
||
│ │ 5^2 = 25 sqrt(144) = 12│ │
|
||
│ └─────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─────────────────────────────────┐ │
|
||
│ │ sin │ cos │ tan │ ( │ ) │ ⌫ │ │ ← function row (swipeable for more)
|
||
│ ├─────┼─────┼─────┼─────┼─────┼───┤ │
|
||
│ │ 7 │ 8 │ 9 │ ÷ │ ^ │ │ │
|
||
│ ├─────┼─────┼─────┼─────┼─────┤ │ │
|
||
│ │ 4 │ 5 │ 6 │ × │ % │ │ │
|
||
│ ├─────┼─────┼─────┼─────┼─────┤ │ │
|
||
│ │ 1 │ 2 │ 3 │ - │ sqrt│ │ │
|
||
│ ├─────┼─────┼─────┼─────┼─────┤ │ │
|
||
│ │ 0 │ . │ Ans │ + │ = │ │ │
|
||
│ └─────┴─────┴─────┴─────┴─────┘ │ │
|
||
└─────────────────────────────────────────┘
|
||
```
|
||
|
||
**Key interactions:**
|
||
- Tap buttons to build expression (no keyboard typing required)
|
||
- Long-press `=` to copy result
|
||
- Swipe function row for more functions (log, ln, abs, etc.)
|
||
- Tap history items to recall into expression
|
||
- Pull-down to expand full history view
|
||
|
||
### 9.3 Programmer Mode Screen
|
||
|
||
Split into three vertical zones: **value display**, **bit grid**, and **input buttons**.
|
||
|
||
```
|
||
┌─────────────────────────────────────────┐
|
||
│ ┌─── Bit Width ────────────────────┐ │
|
||
│ │ [ 8 ] [ 16 ] [ 32 ] [●64] │ │ ← segmented control
|
||
│ └──────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─── Value Display ────────────────┐ │
|
||
│ │ DEC 4,294,967,295 │ │ ← tappable to switch primary
|
||
│ │ HEX 0xFFFF_FFFF │ │
|
||
│ │ OCT 0o37_777_777_777 │ │
|
||
│ │ BIN 1111 1111 1111 1111 11.. │ │
|
||
│ │ SIGN -1 (signed) │ │
|
||
│ └──────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─── Bit Grid ─────────────────────┐ │
|
||
│ │ 31 30 29 28 │ 27 26 25 24 │ │ ← bit index labels
|
||
│ │ 1 1 1 1 │ 1 1 1 1 │ │ ← tappable cells
|
||
│ │ 23 22 21 20 │ 19 18 17 16 │ │
|
||
│ │ 1 1 1 1 │ 1 1 1 1 │ │
|
||
│ │ 15 14 13 12 │ 11 10 9 8 │ │
|
||
│ │ 1 1 1 1 │ 1 1 1 1 │ │
|
||
│ │ 7 6 5 4 │ 3 2 1 0 │ │
|
||
│ │ 1 1 1 1 │ 1 1 1 1 │ │
|
||
│ └──────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─── Operators ────────────────────┐ │
|
||
│ │ AND│ OR │XOR │NOT │ << │ >> │ROL │ │ ← bitwise op buttons
|
||
│ ├────┼────┼────┼────┼────┼────┼────┤ │
|
||
│ │ A │ B │ C │ D │ E │ F │ │ │ ← hex input
|
||
│ ├────┼────┼────┼────┼────┼────┤ │ │
|
||
│ │ 7 │ 8 │ 9 │ 0x│ 0b│CLR │ │ │ ← digits + base prefix
|
||
│ ├────┼────┼────┼────┼────┼────┤ │ │
|
||
│ │ 4 │ 5 │ 6 │ + │ - │ = │ │ │
|
||
│ ├────┼────┼────┼────┼────┼────┤ │ │
|
||
│ │ 1 │ 2 │ 3 │ × │ ÷ │ ⌫ │ │ │
|
||
│ └────┴────┴────┴────┴────┴────┘ │ │
|
||
└─────────────────────────────────────────┘
|
||
```
|
||
|
||
**Key interactions:**
|
||
- Tap any bit in the grid to toggle it - all displays update instantly
|
||
- Haptic pulse on bit toggle
|
||
- Tap DEC/HEX/OCT/BIN label to make that the primary input base
|
||
- Bit grid scrolls horizontally for 64-bit (or collapses to 4 rows of 16)
|
||
- FAB or toolbar button for "Struct Layout" sub-screen
|
||
|
||
### 9.4 Struct Layout Screen (Programmer sub-screen)
|
||
|
||
Accessed via a button within Programmer mode. Uses a **form-based struct builder** rather than raw text DSL (though raw text input is available via a toggle).
|
||
|
||
```
|
||
┌─────────────────────────────────────────┐
|
||
│ ← Back to Programmer │
|
||
│ │
|
||
│ ┌─── ABI / Settings ──────────────┐ │
|
||
│ │ ABI: [x86-64 SysV ▾] Endian: [LE ▾]│
|
||
│ └──────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─── Fields ───────────────────────┐ │
|
||
│ │ ┌────────┬──────────┬─────────┐ │ │
|
||
│ │ │ Type ▾ │ Name │ ⊖ │ │ │ ← each row is a field
|
||
│ │ ├────────┼──────────┼─────────┤ │ │
|
||
│ │ │ u32 │ x │ ⊖ │ │ │
|
||
│ │ │ u8 │ flags │ ⊖ │ │ │
|
||
│ │ │ u16 │ id │ ⊖ │ │ │
|
||
│ │ │ u64 │ ts │ ⊖ │ │ │
|
||
│ │ └────────┴──────────┴─────────┘ │ │
|
||
│ │ [ + Add Field ] │ │
|
||
│ │ [ ✏️ Raw DSL input toggle ] │ │
|
||
│ └──────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─── Memory Map ───────────────────┐ │
|
||
│ │ ██████████ ▓▓ ░░ ▒▒▒▒ ░░░░░░░░░ │ │ ← color-coded byte bar
|
||
│ │ x(4) fl p id ts(8) │ │ ← labels below
|
||
│ └──────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─── Summary ──────────────────────┐ │
|
||
│ │ Total: 16 bytes Align: 8 │ │
|
||
│ │ Padding: 1 byte (6.25%) │ │
|
||
│ │ Packed: 15 bytes │ │
|
||
│ └──────────────────────────────────┘ │
|
||
└─────────────────────────────────────────┘
|
||
```
|
||
|
||
**Key interactions:**
|
||
- Type dropdown for each field (Material exposed dropdown menu)
|
||
- Swipe-to-delete or tap ⊖ to remove a field
|
||
- Drag handles to reorder fields (see padding change in real-time)
|
||
- Memory map is a custom Canvas composable with tappable segments (tap a segment -> highlights field info)
|
||
- Toggle for raw DSL text input (for copy-paste from code)
|
||
- Share button: export struct layout as text or image
|
||
|
||
### 9.5 Financial Mode Screen
|
||
|
||
Card-based interface with function selector at top.
|
||
|
||
```
|
||
┌─────────────────────────────────────────┐
|
||
│ ┌─── Function ────────────────────┐ │
|
||
│ │ [●CAGR] [TVM] [Compound] [PV] │ │ ← scrollable chip row
|
||
│ └─────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─── CAGR Inputs ────────────────┐ │
|
||
│ │ │ │
|
||
│ │ Start Value │ │
|
||
│ │ ┌─────────────────────────┐ │ │
|
||
│ │ │ 10,000 │ │ │ ← text field
|
||
│ │ └─────────────────────────┘ │ │
|
||
│ │ │ │
|
||
│ │ End Value │ │
|
||
│ │ ┌─────────────────────────┐ │ │
|
||
│ │ │ 25,000 │ │ │
|
||
│ │ └─────────────────────────┘ │ │
|
||
│ │ │ │
|
||
│ │ Periods (years) │ │
|
||
│ │ ┌─────────────────────────┐ │ │
|
||
│ │ │ 5 │ │ │
|
||
│ │ └─────────────────────────┘ │ │
|
||
│ │ │ │
|
||
│ │ [ Calculate ] │ │
|
||
│ └─────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─── Result ─────────────────────┐ │
|
||
│ │ CAGR = 20.11% │ │
|
||
│ │ │ │
|
||
│ │ Formula: │ │
|
||
│ │ (25000/10000)^(1/5) - 1 │ │
|
||
│ │ = 2.5^0.2 - 1 │ │
|
||
│ │ = 0.2011... │ │
|
||
│ └─────────────────────────────────┘ │
|
||
└─────────────────────────────────────────┘
|
||
```
|
||
|
||
**Key interactions:**
|
||
- Chip row selects function type, input form adapts
|
||
- For TVM: 5 input fields, user leaves one blank -> that's the solve target
|
||
- Compute triggers engine call, result animates in
|
||
- Result card shows formula breakdown (expandable)
|
||
- Long-press result to copy
|
||
|
||
### 9.6 Convert Mode Screen
|
||
|
||
Designed for fast, one-handed unit conversion.
|
||
|
||
```
|
||
┌─────────────────────────────────────────┐
|
||
│ ┌─── Category ────────────────────┐ │
|
||
│ │ [Length] [Mass] [Temp] [Time] ▸ │ │ ← scrollable chip row
|
||
│ └─────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─────────────────────────────────┐ │
|
||
│ │ │ │
|
||
│ │ 100 │ │ ← large editable number
|
||
│ │ kilometers [▾] │ │ ← unit selector dropdown
|
||
│ │ │ │
|
||
│ │ ⇅ (swap) │ │ ← tap to swap from/to
|
||
│ │ │ │
|
||
│ │ 62.1371 │ │ ← live result (updates as you type)
|
||
│ │ miles [▾] │ │ ← unit selector dropdown
|
||
│ │ │ │
|
||
│ └─────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─── Keypad ─────────────────────┐ │
|
||
│ │ 7 │ 8 │ 9 │ │ │
|
||
│ ├─────┼─────┼─────┤ ⌫ │ │
|
||
│ │ 4 │ 5 │ 6 │ │ │
|
||
│ ├─────┼─────┼─────┼─────────────┤ │
|
||
│ │ 1 │ 2 │ 3 │ │ │
|
||
│ ├─────┼─────┼─────┤ C │ │
|
||
│ │ 0 │ . │ ± │ │ │
|
||
│ └─────┴─────┴─────┴─────────────┘ │
|
||
└─────────────────────────────────────────┘
|
||
```
|
||
|
||
**Key interactions:**
|
||
- Conversion updates live as user types (no "calculate" button needed)
|
||
- Swap button rotates from/to units with an animation
|
||
- Unit selectors open a searchable bottom sheet (for categories with many units)
|
||
- Category chips filter which units appear in the selectors
|
||
- Recent conversions remembered
|
||
|
||
### 9.7 Tablet Layout (landscape / large screen)
|
||
|
||
On tablets, modes use a two-pane layout:
|
||
|
||
```
|
||
┌─────────────────────┬──────────────────────────────┐
|
||
│ │ │
|
||
│ Input / Controls │ Results / Visualization │
|
||
│ │ │
|
||
│ (button grid, │ (history, bit grid, │
|
||
│ form fields, │ memory map, formula │
|
||
│ struct editor) │ breakdown) │
|
||
│ │ │
|
||
└─────────────────────┴──────────────────────────────┘
|
||
│ 🔢 Standard 💻 Programmer 💰 Finance 🔄 Convert │
|
||
└─────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 9.8 Android Architecture (Technical)
|
||
|
||
```
|
||
┌──────────────────────────────────────┐
|
||
│ Compose UI Layer │
|
||
│ ├── StandardScreen │
|
||
│ ├── ProgrammerScreen │
|
||
│ │ ├── BitGridComposable │
|
||
│ │ └── StructVisualizerScreen │
|
||
│ ├── FinancialScreen │
|
||
│ └── ConvertScreen │
|
||
├──────────────────────────────────────┤
|
||
│ ViewModel Layer │
|
||
│ ├── CalculatorViewModel │
|
||
│ ├── ProgrammerViewModel │
|
||
│ ├── FinancialViewModel │
|
||
│ ├── ConvertViewModel │
|
||
│ └── manages state, calls engine │
|
||
├──────────────────────────────────────┤
|
||
│ JNI Bridge (TallyEngine.kt) │
|
||
│ ├── external fun evaluate(...) │
|
||
│ ├── external fun structLayout(...) │
|
||
│ ├── external fun convert(...) │
|
||
│ └── JSON serialization/deser │
|
||
├──────────────────────────────────────┤
|
||
│ Native .so (libtally.so) │
|
||
│ └── C ABI from engine/c_api.zig │
|
||
└──────────────────────────────────────┘
|
||
```
|
||
|
||
The JNI bridge sends expression strings down and receives JSON results back. This keeps the boundary thin and debuggable.
|
||
|
||
---
|
||
|
||
## 10. Error Handling Strategy
|
||
|
||
Each module declares what it can fail with, and the sets compose:
|
||
|
||
```zig
|
||
// parser.zig
|
||
pub const Error = error{
|
||
UnexpectedToken, UnmatchedParen, UnexpectedEnd,
|
||
InvalidExpression, InvalidNumber, OutOfMemory,
|
||
};
|
||
|
||
// 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;
|
||
```
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## 11. Key Design Decisions
|
||
|
||
| # | Decision | Rationale |
|
||
|---|----------|-----------|
|
||
| 1 | Pratt parser over recursive descent | Easier to extend precedence levels; cleaner for infix with mixed prefix operators |
|
||
| 2 | `u64` storage for all programmer integers | Simplest representation; bit_width used for masking/interpretation, not storage |
|
||
| 3 | JSON over C ABI boundary | Avoids complex struct marshaling; Kotlin/Swift handle JSON natively; small perf cost acceptable for calculator |
|
||
| 4 | Separate parser for struct DSL | Struct grammar is different enough from expressions; keeps both parsers simple |
|
||
| 5 | ABI as interface/vtable | Adding Windows x64 or ARM is implementing one struct; no changes to layout algorithm |
|
||
| 6 | libvaxis for TUI | Most mature Zig TUI; supports Windows/Mac/Linux; active development; used by Ghostty |
|
||
| 7 | `^` always means power; XOR is the `xor` keyword | Avoids mode-dependent operator overloading; every operator means the same thing in both modes; consistent with existing `rol`/`ror` keyword operators |
|
||
| 8 | Statically-linked desktop binaries | Zero dependencies for end user; Zig makes this trivial |
|
||
| 9 | Unit conversion via base-unit normalization | Simple to implement, easy to extend; only temperature needs special case (offset) |
|
||
| 10 | Android: button grids, not text prompts | Touch-first UX; no keyboard needed for basic use; purpose-built surfaces per mode beat a generic expression prompt |
|
||
| 11 | Android: form-based struct builder + raw toggle | Most users won't want to type DSL syntax on a phone; visual builder is more natural; power users can toggle to raw text |
|
||
| 12 | Android: live conversion without "Calculate" button | Conversions are instant; eliminating a tap makes the UX feel responsive and direct |
|