2970 lines
162 KiB
Markdown
2970 lines
162 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, not built yet)
|
|
| | |
|
|
+------v---------------v----------------------v---------------+
|
|
| Engine (Zig library) |
|
|
+-------------------------------------------------------------+
|
|
| Parser.zig | evaluator.zig | programmer.zig units.zig |
|
|
| tokenizer | financial.zig | bitwise.zig float_interp|
|
|
| ast.zig | number/Rational/Integer grouping.zig|
|
|
| struct_layout.zig CStructParser.zig |
|
|
+-------------------------------------------------------------+
|
|
```
|
|
|
|
The engine is a pure Zig library with no I/O. Frontends depend on the engine; the
|
|
engine depends on nothing outside `std`. The CLI and TUI ship today; the Android
|
|
column is a plan, and `c_api.zig` is the seam it will use (section 6).
|
|
|
|
### Build Graph
|
|
|
|
One `build.zig` at the workspace root. The per-frontend build files this section
|
|
used to describe (`engine/build.zig`, `cli/build.zig`, `tui/build.zig`) were never
|
|
written: the engine is one import the two frontends share, and splitting the build
|
|
would buy nothing.
|
|
|
|
```
|
|
build.zig
|
|
|- import "engine" <- engine/src/engine.zig, shared by both frontends
|
|
|- exe "tally" <- src/main.zig (CLI + TUI in one binary)
|
|
|- lib "tally" <- static and shared libraries from engine/src/engine.zig
|
|
|- step "test" <- three test binaries: engine, CLI, TUI
|
|
|- step "coverage" <- kcov over the same three, one report each
|
|
(build/Coverage.zig, kcov downloaded on first use)
|
|
|- step "run" <- runs the binary
|
|
```
|
|
|
|
**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 the engine libraries and the `tally` binary for the host.
|
|
- `zig build test` -> runs every test in the three trees.
|
|
- `zig build coverage -Dcoverage-threshold=N` -> the same tests under kcov, failing below the threshold.
|
|
- Android cross-compilation (`-Dtarget=aarch64-linux-android`) is not wired up yet. When it is, the `android/` Gradle project will handle only APK packaging and Compose UI compilation; it will not invoke Zig.
|
|
|
|
**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.
|
|
|
|
**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 File Breakdown
|
|
|
|
| File | Responsibility |
|
|
|--------|---------------|
|
|
| `grouping.zig` | The thousands rule, shared by both display paths |
|
|
| `Integer.zig` | A fixed-width integer (file-as-struct): `raw`, `width`, `signedness`, the interpretations, and the notations it renders in |
|
|
| `Rational.zig` | Exact rationals over big integers (file-as-struct) |
|
|
| `number.zig` | The exact/inexact numeric model (section 2.7), including how a value renders itself at a budget the caller supplies. 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 (file-as-struct) |
|
|
| `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 |
|
|
| `float_interp.zig` | IEEE 754 bit-level interpretation |
|
|
| `units.zig` | Unit conversion tables and resolver |
|
|
| `financial.zig` | CAGR, TVM, compound interest, amortization, and money display (cents are a property of money, not of a screen) |
|
|
| `struct_layout.zig` | Struct layout (section 3): the ABI profiles as data, a language-neutral `Definition`, and `compute`. Knows no syntax |
|
|
| `CStructParser.zig` | Reads a C struct definition into a `struct_layout.Definition` (file-as-struct, like `Parser.zig`), with its own lexer and positioned diagnostics |
|
|
| `message.zig` | Gone: the wording lives in `engine.zig` beside the `Error` union it words |
|
|
| `engine.zig` | Public API surface (Zig-native): the nine files a frontend imports, nine name aliases, the derived `Error` union, and `phrase` |
|
|
| `c_api.zig` | `extern "C"` seam for JNI/FFI consumers. Two of its three exports are stubs; see section 6 |
|
|
|
|
Every file 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 file that
|
|
owns it.
|
|
|
|
A file is named TitleCase when the file *is* the type, with its fields at container
|
|
level: `Integer.zig`, `Rational.zig` and `Parser.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. `tokenizer.zig` stays
|
|
lowercase for a different reason: `Base`, `TokenKind` and `Token` are the lexer's
|
|
vocabulary rather than parts of `Tokenizer`, and `Base` in particular is used by the
|
|
AST and both evaluators without a tokenizer anywhere in sight.
|
|
|
|
### 2.2 Core Data Types
|
|
|
|
The result of every calculation is a `Number`: two tiers, one type (section 2.7).
|
|
|
|
```zig
|
|
/// number.zig
|
|
pub const Number = union(enum) {
|
|
exact: Rational,
|
|
inexact: f64,
|
|
};
|
|
|
|
/// Integer.zig, the fixed-width value programmer mode computes on. The file IS the
|
|
/// type, so these are container-level fields.
|
|
raw: u128, // two's complement bit pattern, may carry bits above the width
|
|
width: BitWidth = .bits64,
|
|
signedness: Signedness = .signed,
|
|
|
|
pub const BitWidth = enum(u8) { bits8 = 8, bits16 = 16, bits32 = 32, bits64 = 64, bits128 = 128 };
|
|
pub const Signedness = enum { signed, unsigned };
|
|
```
|
|
|
|
An earlier version of this section described a `Value` union with `integer`, `float`,
|
|
`boolean`, `struct_layout` and `multi_base` members, and a `MultiBaseResult` carrying
|
|
five preformatted strings and a `[64]u1` bit array. None of it was built, and the
|
|
reasons are worth keeping:
|
|
|
|
- **`Value` was never the result type.** The exact tier needs one type that can be
|
|
either exact or inexact and can move between the two, which is what `Number` is. A
|
|
union with a `boolean` member also promised comparisons the language does not have.
|
|
- **`MultiBaseResult` was a frontend's job.** A result does not need to carry five
|
|
renderings of itself: `Integer.fmt(.hex, .{})` renders on demand, at the width and
|
|
byte order the caller asks for, and the bit grid reads the bits it needs from `raw`.
|
|
Precomputing all five meant deciding separator style and grouping inside the engine,
|
|
which is exactly the decision NFR-9.9 puts in the frontend.
|
|
- **`raw` is a `u128`, not a `u64`.** 128-bit width is supported, so the pattern needs
|
|
the width to match; `bit_width` is spelled `width` because the file is already
|
|
`Integer`.
|
|
|
|
### 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 {
|
|
base: Base,
|
|
/// The literal exactly as written, pointing into the source. The only
|
|
/// representation kept, because it is the only lossless one: each reader parses
|
|
/// it at the precision it works in (`Number.parse` for the exact tier,
|
|
/// `Base.parseDigits` for a fixed-width pattern). This node used to carry an
|
|
/// `f64` and a `?u64` computed up front, which capped a literal at 128
|
|
/// characters and 64 bits; see Task 5.18.
|
|
text: []const u8,
|
|
};
|
|
|
|
pub const Base = enum {
|
|
decimal,
|
|
hex,
|
|
octal,
|
|
binary,
|
|
/// The literal's digits as a value, separators skipped, `Overflow` past u128.
|
|
pub fn parseDigits(self: Base, text: []const u8) error{Overflow}!u128 { ... }
|
|
};
|
|
|
|
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
|
|
`ExpressionTooLarge` or `NestingTooDeep`, so the message says which.
|
|
|
|
### 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.
|
|
|
|
**The table above is one declaration in the code.** `parser.infix_table` lists each
|
|
infix operator once as `{ trigger, op, prec, assoc }`, where the trigger is either a
|
|
token kind or a keyword's text. It replaced four switches that had to agree pairwise
|
|
plus an inline special case for right-associativity; see Task 5.19. Both spellings of
|
|
power associate to the right (`2^3^2` is `2^(3^2)`), everything else to the left.
|
|
|
|
**Assignment is a statement, not an operator.** `name = expr` is recognised at the top
|
|
of an input, not in prefix position, so it cannot appear where an operand can. It has
|
|
a precedence level in the table above for documentation only: nothing returns it.
|
|
|
|
**One implementation of the fixed-width operators.** `bitwise.zig` implements the
|
|
eight fixed-width binary operators plus `~` and two's complement negation, over
|
|
`Integer` values that carry their own width and signedness. There is no separate
|
|
domain parameter: an operand is a pattern and the type to read it as, together, so an
|
|
operation cannot be handed a pattern from one width and a type from another. Standard
|
|
mode uses the `Integer` default, 64-bit signed and not configurable (FR-2.3);
|
|
programmer mode uses its configured width and signedness. `apply` asserts both
|
|
operands are the same type and returns a value of the left operand's type.
|
|
|
|
The operators this tier owns are named by `bitwise.Op`, a narrower enum than
|
|
`ast.BinaryOp`, with `fromBinaryOp` mapping between them and returning null for the
|
|
arithmetic operators, which are rational in standard mode and wrapping in programmer
|
|
mode and so have nothing to share. 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 {
|
|
width: BitWidth = .bits64,
|
|
signedness: Signedness = .signed,
|
|
/// 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: the file is the type
|
|
raw: u128,
|
|
width: BitWidth = .bits64,
|
|
signedness: Signedness = .signed,
|
|
```
|
|
|
|
`Integer.zig` is TitleCase because the file is the type. Width and signedness were
|
|
briefly a nested `IntType` struct, which existed only because `bitwise.zig` operated
|
|
on bare `u128` patterns and needed the pair passed alongside. Now that the operators
|
|
take values (`apply(op, left: Integer, right: Integer)`), nothing carries the pair
|
|
without bits to go with it, so the fields sit directly on the value and `Config` holds
|
|
them flat. That also removed a class of mistake: a pattern can no longer be operated on
|
|
at a width it did not come from.
|
|
|
|
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 width and signedness that interpret it (the nested `IntType` this used to name is gone; the fields sit on `Integer` itself).
|
|
|
|
### 2.6 Number Display Formatting
|
|
|
|
The engine provides values that can render themselves at a budget the caller supplies;
|
|
frontends decide the budget (NFR-9.9) and the sink. There is no separate formatter:
|
|
`Number.render` and `Integer.fmt` are methods on the values, and both go through
|
|
`grouping.zig` for the thousands rule so every surface groups digits identically
|
|
(NFR-7). A `formatter.zig` held this once and was dismantled, because it had become a
|
|
second place where display decisions lived.
|
|
|
|
#### 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 f64 rendering 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. `Number.renderInexact`'s 2^53 bound (then `formatter.zig`'s `is_integer`) - 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 files are `unreachable` arms guarded
|
|
upstream, and diagnostic paths inside the tests themselves.
|
|
|
|
---
|
|
|
|
## 3. Struct Layout Engine
|
|
|
|
Built as two engine files and a CLI subcommand (FR-3, FR-6.5). The TUI sub-view (8.3) and
|
|
the Android screen (9.4) are not, and the C ABI has no export for it yet. An earlier
|
|
version of this section was a proposal: a DSL of its own, an ABI interface of function
|
|
pointers, and a precomputed byte map. Each of those went a different way once built, and
|
|
3.6 says why.
|
|
|
|
The split is between syntax and layout:
|
|
|
|
- `CStructParser.zig` reads text into a `struct_layout.Definition`. It is the only part
|
|
that knows C.
|
|
- `struct_layout.zig` lays a `Definition` out under an `Abi` and knows no syntax at all.
|
|
A Rust `#[repr(C)]` or Zig `extern struct` front end would be a second parser
|
|
producing the same `Definition` (FR-3.13).
|
|
|
|
### 3.1 The input is C
|
|
|
|
The point is pasting a struct from real code, so the input is C rather than a language
|
|
invented for the calculator: a struct copied out of a header should be accepted as it
|
|
stands, or rejected with a reason and a position. FR-3.1 and FR-3.12 list what the
|
|
subset accepts and refuses.
|
|
|
|
```
|
|
input := directive* definition directive*
|
|
definition := "typedef"? "struct" attrs? tag? attrs? "{" declaration* "}" attrs? name? ";"?
|
|
declaration := specifiers declarator ("," declarator)* ";"
|
|
declarator := attrs? ("*" qualifier*)* (identifier | "(" "*"+ identifier dims ")" suffix)
|
|
dims attrs?
|
|
dims := ("[" integer? "]")* // only the first may be empty
|
|
suffix := "(" balanced ")" | dims // a function pointer, or a pointer to an array
|
|
directive := "#pragma pack(" (N | "push" ("," N)? | "pop" | "") ")"
|
|
```
|
|
|
|
Decisions worth recording:
|
|
|
|
- **Its own lexer.** The expression tokenizer was the obvious thing to reuse and is wrong
|
|
for C in three places that would each give a wrong answer rather than an error: `1,000`
|
|
is a grouped number there, `010` is ten (C says eight), and a hex literal continues
|
|
across a space. It also has no braces or brackets.
|
|
- **Type words are counted, then resolved,** C's way: `unsigned` is `unsigned int`,
|
|
`int long long unsigned` is `unsigned long long`, and `short char` is an error. A name
|
|
that is none of the words and no known typedef is `UnknownType`, pointing at the name.
|
|
- **Unknown is an error, never a skip.** `#if 0` is refused rather than ignored, because
|
|
ignoring it would count the members inside it. An unknown attribute is refused because
|
|
`mode(DI)` and `vector_size` change a type. Harmless ones (`unused`, `deprecated`,
|
|
`may_alias`) are accepted.
|
|
- **Every error carries a span** (`Diagnostic`), and `locate` turns it into a line and
|
|
column, so the CLI can say `line 3, column 5: uint33_t`. Layout errors carry the
|
|
member's index instead, and the member carries the span of its name.
|
|
- **`Parsed` owns an arena** with every name copied in, so a definition outlives its
|
|
source. Input is capped at 64 KiB before anything is read (NFR-8).
|
|
|
|
### 3.2 An ABI is a table
|
|
|
|
```zig
|
|
pub const Abi = struct {
|
|
id: []const u8, // "sysv64", what --abi takes
|
|
label: []const u8, // "64-bit Linux and macOS (x86-64 SysV, AArch64)"
|
|
shapes: std.EnumArray(Primitive, ?Shape), // size and alignment, null where absent
|
|
rules: Rules, // .gnu or .microsoft (3.3)
|
|
};
|
|
|
|
pub const Primitive = enum {
|
|
char, bool, short, int, long, long_long, int128,
|
|
float, double, pointer, wchar, enumeration,
|
|
};
|
|
```
|
|
|
|
`Primitive` lists C's kinds rather than its spellings: `uint32_t`, `u32` and `unsigned int`
|
|
all arrive as `.int`, and `size_t` and every pointer as `.pointer`, because on every
|
|
profile here `size_t` is pointer-sized. A profile is a value in a table, not an
|
|
implementation of an interface: `EnumArray.init` requires a shape for every kind, so a
|
|
profile cannot forget one, and the proposal's function pointers would have bought
|
|
nothing a table cannot say.
|
|
|
|
| Profile | `long` | pointer | 8-byte types align to | `wchar_t` | `__int128` | Rules |
|
|
|---|---|---|---|---|---|---|
|
|
| `sysv64` (default) | 8 | 8 | 8 | 4 | 16, aligned 16 | GNU |
|
|
| `win64` | 4 | 8 | 8 | 2 | 16, aligned 16 | Microsoft |
|
|
| `i386` | 4 | 4 | 4 | 4 | absent | GNU |
|
|
| `arm32` | 4 | 4 | 8 | 4 | absent | GNU |
|
|
|
|
AArch64 is not its own profile: for every type here it matches x86-64 SysV, on Linux and
|
|
macOS alike (they differ in `long double`, which is not modelled). The clang check
|
|
(3.5) holds `sysv64` to all three targets, which is the evidence for folding them.
|
|
|
|
### 3.3 The algorithm
|
|
|
|
```
|
|
for each member:
|
|
alignment = scalar alignment
|
|
if packed (member or struct): alignment = 1
|
|
gnu: alignment = max(alignment, aligned); alignment = min(alignment, pack)
|
|
microsoft: alignment = min(alignment, pack); alignment = max(alignment, aligned)
|
|
offset = round_up(offset, alignment) # padding_before = the difference
|
|
place the member; offset += element size * product(dims) # 0 for a flexible array
|
|
struct_alignment = max(struct_alignment, alignment)
|
|
struct_alignment = max(struct_alignment, aligned on the struct)
|
|
size = round_up(offset, struct_alignment) # tail padding = the difference
|
|
```
|
|
|
|
**The order of `aligned` and `pack` is the one rule that differs by compiler, and the
|
|
clang check found it.** The first version used the GNU order everywhere, and the
|
|
`windows-msvc` targets refused two of the test structs: under `#pragma pack(2)`,
|
|
`char c; alignas(8) int i;` puts `i` at 2 under GCC's rules and at 8 under MSVC's, where
|
|
an explicit alignment is applied after the cap and wins. That is now `Abi.rules`, a
|
|
property of the profile, since clang chooses it by target too. Bitfields are where the
|
|
two families differ most, which is one reason they are out of scope (FR-3.12).
|
|
|
|
Every size is checked arithmetic, and a struct larger than the ABI can address
|
|
(`PTRDIFF_MAX`: 2^31 - 1 on the 32-bit profiles) is `StructTooLarge`, because a C
|
|
compiler refuses it and a layout that no program could have is not an answer.
|
|
|
|
### 3.4 The result
|
|
|
|
```zig
|
|
pub const Layout = struct {
|
|
abi: *const Abi,
|
|
members: []const Member, // borrowed from the definition
|
|
placements: []Placement, // owned: offset, size, alignment, padding_before
|
|
size: u64,
|
|
alignment: u32,
|
|
tail_padding: u64,
|
|
packed_size: u64, // sum of member sizes: FR-3.10's comparison
|
|
pub fn padding(self) u64;
|
|
pub fn segments(self) SegmentIterator; // member / padding / tail runs, in order
|
|
};
|
|
```
|
|
|
|
The proposal's `bytes: []const ByteInfo` is `segments()` instead: the same information,
|
|
derived on demand. A struct holding `char buf[1 << 20]` is three segments, not a million
|
|
entries, and the CLI's memory map draws a 2 GiB struct as quickly as a small one because
|
|
it finds its rows from the segments.
|
|
|
|
### 3.5 Checked against clang
|
|
|
|
NFR-3 says a layout must match what a C compiler produces, and this is how that is held
|
|
rather than hoped for. `engine/test/struct_oracle.zig` runs on the build machine and
|
|
writes one C file per profile:
|
|
|
|
- a probe per scalar kind: `sizeof`, and the offset of the scalar after a `char` (its
|
|
in-struct alignment, which on i386 is not `_Alignof`'s answer for `double`);
|
|
- `__int128`'s presence or absence, as an `#error` when the target disagrees;
|
|
- for each of 29 test structs: `sizeof`, `_Alignof`, and every member's offset and size.
|
|
|
|
Each test struct is C read by the engine's own parser, so the compiler and the engine see
|
|
the same text. `build.zig` compiles each file, freestanding with `-Werror`, for every
|
|
clang target its profile claims:
|
|
|
|
| Profile | Targets |
|
|
|---|---|
|
|
| `sysv64` | `x86_64-linux-gnu`, `aarch64-linux-gnu`, `aarch64-macos` |
|
|
| `win64` | `x86_64-windows-msvc`, `aarch64-windows-msvc` |
|
|
| `i386` | `x86-linux-gnu` |
|
|
| `arm32` | `arm-linux-gnueabihf` |
|
|
|
|
Compiling is the test, inside `zig build test`, and nothing beyond Zig is needed: the
|
|
clang is the one Zig ships. A wrong table entry fails with the profile, the struct and the
|
|
claim in the message; changing i386's `double` to align 8 failed twenty assertions. The
|
|
generator refuses to run unless every profile in the engine has a target listed, so a
|
|
profile cannot be added without a compiler to answer to.
|
|
|
|
### 3.6 What changed from the proposal, and why
|
|
|
|
- **A DSL of our own became C.** The DSL's only user would have been someone retyping a
|
|
struct they already had in C.
|
|
- **The function-pointer interface became a table.** Sizes and alignments are data;
|
|
the one behavioural difference found so far (3.3) is an enum.
|
|
- **`packed` stopped being a profile.** It is a property of a struct, and applies under
|
|
any ABI; a "packed profile" could not have said what a pointer's size is.
|
|
- **The byte map became an iterator** (3.4).
|
|
- **Endianness was deferred** (FR-3.8): without values it only relabels bytes.
|
|
|
|
---
|
|
|
|
## 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 `b` is bit, `Mb` is megabit and `MB` is megabyte, 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. That pass must find
|
|
**exactly one** unit. Where a fold reaches two units that the tables
|
|
deliberately distinguish by case, the name is `AmbiguousUnit` rather than a
|
|
guess: `Mbps` is megabits per second and `MBps` is megabytes per second, so
|
|
`MBPS` is a factor of eight away from one of the two readings with no way to
|
|
tell which was meant. Returning the first fold match is how `MBPS` silently
|
|
became `Mbps`, and how `mB` became `MB`.
|
|
|
|
Bits sit alongside bytes in `digital_storage` (`b`, `kb`, `Mb`, `Gb`, `Tb`, `Pb`),
|
|
because the category's whole convention is that lowercase `b` is a bit and
|
|
uppercase `B` a byte. Before they existed, `Mb` matched nothing exactly, fell
|
|
through to the fold, and `100 Mb to MB` answered `100 MB`: a megabit-to-megabyte
|
|
question returning an identity. A bit is an eighth of a byte, so every factor in
|
|
the category is still exact.
|
|
|
|
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 part of the engine 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)
|
|
|
|
**What exists today:** `engine/src/c_api.zig` is the root of the shared library and
|
|
implements the design below. Twelve exports, a hand-written `include/tally.h`, and two
|
|
test layers: Zig tests that build sessions with `std.testing.allocator` so the whole seam
|
|
is leak-checked, and `engine/test/c_abi_test.c`, which links against the real library
|
|
through the real header and is run by `zig build test`. `zig build android`
|
|
cross-compiles the library for arm64-v8a, x86_64 and armeabi-v7a, laid out for Gradle's
|
|
`jniLibs`, with the JNI layer (6.4) on top.
|
|
|
|
What is not built: the financial and float-view payloads. Standard mode, programmer
|
|
mode, unit conversion, preview (6.5), whole-category conversion (6.6), save and load
|
|
(6.7) and the units
|
|
catalogue are across.
|
|
|
|
```zig
|
|
pub const Status = enum(c_int) { ok = 0, eval_error = 1, out_of_memory = 2, invalid_argument = 3 };
|
|
pub const Mode = enum(c_int) { standard = 0, programmer = 1 };
|
|
|
|
/// Interpretation and display, set once on the session rather than per call. The
|
|
/// display fields are NFR-9.9: the engine holds no digit budget of its own.
|
|
pub const Config = extern struct {
|
|
bits: u16 = 64, // 8, 16, 32, 64, 128
|
|
is_signed: u8 = 1,
|
|
big_endian: u8 = 1,
|
|
separators: u8 = 1,
|
|
never_abbreviate: u8 = 0,
|
|
fraction_digits: u16 = 20,
|
|
max_integer_digits: u16 = 40,
|
|
significant_digits: u16 = 17,
|
|
scientific_below_exponent: u16 = 15,
|
|
};
|
|
|
|
export fn tally_session_new() ?*Session;
|
|
export fn tally_session_free(?*Session) void;
|
|
export fn tally_session_configure(?*Session, ?*const Config) Status;
|
|
export fn tally_session_config(?*Session, ?*Config) Status;
|
|
export fn tally_eval(?*Session, ?[*]const u8, usize, c_int, ?*[*]const u8, ?*usize) Status;
|
|
export fn tally_preview(?*Session, ?[*]const u8, usize, c_int, ?*[*]const u8, ?*usize) Status;
|
|
export fn tally_convert(?*Session, ?[*]const u8, usize, ?[*]const u8, usize, ?*[*]const u8, ?*usize) Status;
|
|
export fn tally_session_save(?*Session, ?*[*]const u8, ?*usize) Status;
|
|
export fn tally_session_load(?*Session, ?[*]const u8, usize) Status;
|
|
export fn tally_unit_catalog(?*Session, ?*[*]const u8, ?*usize) Status;
|
|
export fn tally_version(?*usize) [*]const u8;
|
|
export fn tally_abi_version() u32;
|
|
```
|
|
|
|
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.
|
|
- **A session, not a function.** `Environment` outlives an evaluation, so the handle does too: `x = 5` then `x * 2` works, and `Ans` has a value. This is what the one-shot `tally_eval` in the original sketch could not do.
|
|
- **`tally_result_free` is gone.** Results are borrowed from the session (6.2), so there is nothing for a caller to free and no way to free it wrongly.
|
|
- **JSON payloads**, written by `std.json` rather than by hand, so escaping is not a local invention. A standard result is
|
|
`{"ok":true,"display":"256","exact":true,"truncated":false,"bases":{"hex":"01 00",...}}`,
|
|
with `bases` present only when the expression used a non-decimal literal (FR-1.9) and
|
|
`conversion` present only when it was a unit conversion. Programmer mode returns every
|
|
row the screen shows. An error is `{"ok":false,"error":"division by zero"}`, worded by
|
|
`engine.phrase`, which is where the CLI and TUI get the same sentence.
|
|
- **Statuses distinguish three failures**: the expression was bad (`eval_error`, with the
|
|
reason in the JSON), the machine is out of memory (`out_of_memory`, with no JSON,
|
|
because there may be no memory to write it), or the call itself was malformed
|
|
(`invalid_argument`, which touches nothing).
|
|
- **A units catalogue**, so a picker is filled from `units.unitsIn` and the aliases table
|
|
rather than from a list retyped in Kotlin.
|
|
|
|
### 6.1 The decisions this design still owes
|
|
|
|
The boundary is designed with the Android app rather than ahead of it: the JNI layer is
|
|
the only caller, so its access pattern decides the shape. Each decision below is
|
|
additive - the Zig API the CLI and TUI use needs nothing new to support any of them.
|
|
|
|
1. **Session lifetime.** `Environment` holds the variables and the last answer, and
|
|
both frontends keep one alive across evaluations. A one-shot `tally_eval` cannot:
|
|
`x = 5` then `x * 2` would fail, and `Ans` would never have a value, on the surface
|
|
least able to compensate because a phone has no scrollback to retype from. The C API
|
|
needs an opaque handle (`tally_session_new` / `tally_session_free`, with every other
|
|
entry point taking one). Decide this first: it changes every signature.
|
|
2. **Memory ownership.** See 6.2, which is the longest of these because it is the one
|
|
that fails silently.
|
|
3. **Who chooses the display budget.** `Number.render` takes `FormatOptions`, and
|
|
NFR-9.9 says the frontend supplies them; a phone screen is not an 80-column terminal
|
|
and should not inherit one's digit counts. Either the options cross the ABI as
|
|
fields on the session config, or the C layer invents a budget and Android lives with
|
|
it. The CLI and TUI each declare their own, so there is a precedent to copy rather
|
|
than a question to answer from scratch.
|
|
4. **What the JSON carries.** A result is more than a string in three places: the
|
|
multi-base rows of programmer mode, the decomposition of the float view, and the
|
|
amortization schedule. The unit tables and the financial forms also need
|
|
enumerating, or Android has to hardcode lists the engine already owns. Rendering
|
|
inside the library keeps one grouping rule (NFR-7) and one set of words for errors
|
|
(`engine.phrase`), which argues for the JSON carrying rendered text alongside the
|
|
raw values rather than values alone. Whatever shape this takes is also what a CLI
|
|
`--json` would print, which is why the old FR-6.9 was removed rather than specified
|
|
separately.
|
|
|
|
### 6.2 Memory ownership across the boundary
|
|
|
|
Every engine entry point takes an `Allocator`; nothing allocates behind a caller's
|
|
back. That is a good position to start from and it means the ABI gets to choose, so the
|
|
choice should be made explicitly rather than inherited from the first export written.
|
|
|
|
**What JNI actually does.** Kotlin declares an `external fun`, calls it, and reads the
|
|
returned bytes into a `ByteArray` or `String` before returning from the same JNI frame.
|
|
The copy is immediate and the app then owns a JVM object. Nothing on the Kotlin side
|
|
wants to hold a native pointer, and nothing can be trusted to free one on a predictable
|
|
schedule: a `ViewModel` is cleared whenever Android decides.
|
|
|
|
**Options considered.**
|
|
|
|
- *A library-owned global allocator*, with `tally_result_free` handing memory back to
|
|
it. This is the shape the current stub implies. It puts a mutable global in the
|
|
shipped library, which is the one property the engine does not have today (the only
|
|
container-level `var` in the tree is inside a test). It is also where the
|
|
cross-allocator free bug lives: any path that frees a result after its session is
|
|
gone, or through the wrong allocator, is a corruption rather than an error. A global
|
|
`DebugAllocator` never runs its leak check in a shared library either, so leaks go
|
|
unreported.
|
|
- *A caller-provided buffer*, snprintf style: the app passes a buffer and a capacity,
|
|
the library writes into it and reports the length it needed. No ownership question at
|
|
all, no free, thread-safe by construction, and every byte is accounted for as JVM
|
|
memory. The cost is the two-call pattern when a result does not fit, which for an
|
|
amortization schedule is the common case rather than the rare one.
|
|
- *Caller-provided allocator callbacks.* The app passes malloc/free function pointers
|
|
at session creation. Maximum control, and more machinery than a calculator needs.
|
|
|
|
**The recommendation.** The session owns all of it, and results are borrowed:
|
|
|
|
- `tally_session_new` allocates a session that holds the `Environment`, a scratch arena
|
|
reset at the end of every call, and a reusable result buffer. No global state: two
|
|
sessions share nothing, so a test and an app can hold one each.
|
|
- A result is a `(ptr, len)` into that session's result buffer, valid until the next
|
|
call on the same session or until `tally_session_free`. The caller copies it
|
|
immediately, which is what JNI was going to do anyway. `tally_result_free` disappears,
|
|
and with it every way to free the wrong thing at the wrong time.
|
|
- One session is used from one thread at a time. That is what a `ViewModel` does
|
|
naturally, and it keeps locks out of the library. Documented rather than enforced,
|
|
because enforcing it means a mutex on every call for a constraint the only caller
|
|
already satisfies.
|
|
- Out of memory is a status code on every export, never a crash. The engine already
|
|
threads `error.OutOfMemory` through every path, so this costs nothing to honour.
|
|
|
|
#### Three lifetimes, three allocators
|
|
|
|
The split is not a preference; it is what the engine's own ownership rules already
|
|
imply. `Environment.setVar` and `setAns` clone into the environment's own allocator
|
|
(`evaluator.zig`), while `evalStringInfo(env, allocator, source)` builds the parse tree
|
|
and the returned value from the allocator it is handed. So:
|
|
|
|
| Lifetime | Holder | Allocator |
|
|
|---|---|---|
|
|
| Session: variables, `Ans`, the result buffer | `Environment`, `ArrayList(u8)` | the session's general-purpose allocator |
|
|
| One call: parse tree, intermediate rationals, rendered text | nothing; the arena owns it | `ArenaAllocator` over the session allocator, `reset(.retain_capacity)` at the end of each call |
|
|
| The bytes handed back | the session's result buffer | session allocator, `clearRetainingCapacity` per call |
|
|
|
|
- **The session allocator is `std.heap.page_allocator`** at the C boundary, and the
|
|
loader chose it, not taste. The Android library declares no dynamic dependencies (6.3),
|
|
so every symbol it references must be one it defines: `smp_allocator` keeps its
|
|
per-thread state in a thread-local, which emits a TLS segment and an undefined
|
|
`__tls_get_addr` that only Bionic's libc can satisfy. It links on a desktop and fails in
|
|
`dlopen` on a device, before any code runs. `std.heap.DebugAllocator` over
|
|
`page_allocator` is free of that symbol and is the upgrade if page granularity ever
|
|
costs anything measurable; what rules out page granularity being a problem today is the
|
|
arena below, which buys pages in chunks and hands out bytes. The allocations that do hit
|
|
`page_allocator` one for one are `Environment` variable clones and the geometric growth
|
|
of the result buffer, both bounded by what a person types.
|
|
- **The allocator is a parameter everywhere above the boundary.** The Zig-visible
|
|
session type takes an `Allocator`, so tests pass `std.testing.allocator` and get leak
|
|
detection; only the `export fn` picks a default. That is how the ABI gets leak-checked
|
|
without the library carrying a debug allocator into production. It is also what made the
|
|
fix above a one-line change to one function.
|
|
- **`retain_capacity` is what makes the steady state cheap.** The first evaluation buys
|
|
the arena's pages and every later one reuses them, so a calculator being typed into
|
|
stops calling the backing allocator almost entirely.
|
|
|
|
**One rule this imposes on the ABI code**, learned by writing the probe rather than by
|
|
thinking about it: values allocated from the arena must never be individually `deinit`'d.
|
|
The arena owns them and the reset frees them, and a `defer value.deinit()` in the same
|
|
scope as the reset runs *after* it - which segfaulted on the first attempt, freeing
|
|
big-integer limbs into memory the arena had already reclaimed. With an arena the teardown
|
|
is the reset, and nothing else.
|
|
|
|
A probe that ran `x = 2^100`, then `x + 1`, then `Ans / 2`, resetting the arena between
|
|
each, produced exactly `633,825,300,114,114,700,748,351,602,688.5` and leaked nothing
|
|
under the testing allocator. That is the shape the real `tally_eval` should have.
|
|
|
|
**What this trades away.** A borrowed pointer is a rule the caller has to follow: hold
|
|
it past the next call and it is stale. The alternative that removes the rule is the
|
|
caller-provided buffer, at the cost of a size-negotiation round trip. If the JNI layer
|
|
turns out to want a pointer it can keep, the caller-provided buffer is the fallback, and
|
|
the switch touches only the ABI: the session design above is unchanged either way.
|
|
|
|
**What was verified, and what is still assumed.** The cross-compile has now been run.
|
|
Building the engine for `aarch64-linux-android` and `x86_64-linux-android` produces a
|
|
shared object per ABI with **no dynamic dependencies at all** (zero `DT_NEEDED` entries),
|
|
containing the full exact tier: a probe that evaluated `2^100 + 1` and rendered it
|
|
through `Number.render` compiled and linked on both, at 230KB under `ReleaseSmall`. No
|
|
NDK was involved, and the three-lifetime design above compiles for both ABIs too. The
|
|
shipped libraries, with the panic machinery removed below, are 170KB to 186KB.
|
|
|
|
Asking for `-lc` on those targets fails outright: Zig 0.16 bundles glibc and musl and no
|
|
Bionic, so it reports `unable to provide libc for target 'aarch64-linux...android.29'`
|
|
and suggests the gnu and musl triples instead. Anyone wanting libc there needs an NDK
|
|
sysroot. That is what removed `std.heap.c_allocator` from this design - it was the
|
|
recommendation here until the build was actually attempted - and the upside is a library
|
|
that needs no NDK at all. The cost is that allocations do not pass through `malloc`, so
|
|
Android's malloc-based tooling cannot attribute them; for a calculator whose steady state
|
|
is an arena reset per keystroke, that is a fair trade.
|
|
|
|
**No longer assumed.** The library loads under `System.loadLibrary`, the JVM resolves the
|
|
symbols, and the engine computes correctly on a device: on an Android 15 x86_64 image with
|
|
16 KiB pages, `2+3*4` gave `14`, `100 km to mi` gave
|
|
`62.13711922373339696174 mi`, `x = 7` then `x * 6` gave `42` - which is the
|
|
`Environment`-survives-the-arena-reset rule holding across calls - and `1/0` reported
|
|
`division by zero` rather than crashing. The 10 instrumented tests pass on the same image.
|
|
What remains untested is a physical device, arm64-v8a in particular, and behaviour under
|
|
real memory pressure.
|
|
|
|
**What a library with no `DT_NEEDED` actually costs, found on a device.** The zero above
|
|
is a constraint, not just a property, and the first build that ran on an emulator did not
|
|
load at all:
|
|
|
|
```
|
|
java.lang.UnsatisfiedLinkError: dlopen failed: cannot locate symbol "__tls_get_addr"
|
|
referenced by ".../lib/x86_64/libtally.so"
|
|
```
|
|
|
|
Nothing in the engine asked for a libc symbol on purpose. Three pieces of std did, all on
|
|
paths that had nothing to do with arithmetic:
|
|
|
|
| Symbol | Pulled in by | Removed by |
|
|
|---|---|---|
|
|
| `__tls_get_addr` | `smp_allocator`'s thread-local | `page_allocator` (6.2) |
|
|
| `__tls_get_addr`, `getauxval` | std's default panic handler: a thread-local recursion guard, and reading the library's own ELF headers to print a stack trace | a panic handler that writes the message to stderr and traps, letting Android's own crash handler produce the tombstone |
|
|
| `getauxval` (arm64-v8a only) | `std.heap.pageSize()`, a runtime auxv query wherever Zig's `page_size_min` and `page_size_max` differ - which is aarch64 alone, at 4 KiB to 64 KiB | `std.options.queryPageSize` returning 16 KiB, with only the *maximum* overridden |
|
|
|
|
Two things about that last row are worth keeping. It reproduced on **arm64-v8a only**,
|
|
so the x86_64 emulator that caught the first failure would have passed it while most real
|
|
phones failed - the symbol audit below is what catches that class, not testing. And
|
|
`page_size_min` was deliberately left alone: std documents it as the alignment a caller
|
|
may assume from a page allocation, and on a 4 KiB kernel `mmap` returns 4 KiB-aligned
|
|
memory, so raising it would promise an alignment the kernel does not give. Overriding the
|
|
ceiling and the query rounds lengths up, which is the safe direction.
|
|
|
|
The general lesson is that a freestanding shared library's dependencies are decided by
|
|
code that never runs. Two of the three came from a debugging aid on the panic path, and
|
|
with `BIND_NOW` set - it is, on all three ABIs - even an unreferenced PLT entry is
|
|
resolved at load, so "nothing calls it" is not a defence. The audit is therefore part of
|
|
the build rather than a one-time check: `zig build android` runs `build/audit_android.zig`
|
|
over each ABI's library before installing any of them, and requires zero `DT_NEEDED`, zero
|
|
undefined dynamic symbols, no TLS segment, `LOAD` aligned to 0x4000, the right ELF machine,
|
|
and the JNI entry points exported. `zig build test` holds the auditor itself to failing on
|
|
a fixture library that breaks every one of those rules (Task 5.32). Removing the
|
|
panic machinery also took the libraries from ~290KB to ~170-186KB.
|
|
|
|
### 6.4 How Kotlin reaches the C ABI
|
|
|
|
**Decided: a hand-written `engine/src/jni.zig`, compiled only for Android, with no NDK.**
|
|
|
|
A Kotlin `external fun` cannot call `tally_eval` directly; something has to translate
|
|
`jstring` and `JNIEnv*` into pointers and lengths (design 6.3). Three ways to get that
|
|
glue were considered:
|
|
|
|
1. **`engine/src/jni.zig`, compiled only for Android.** The glue stays in one language, is
|
|
testable from Zig, and adds no dependency to the app. It needs the JNI type
|
|
definitions, which means either the NDK's `jni.h` or hand-declaring what it uses.
|
|
2. **A small C shim compiled by Gradle** (`externalNativeBuild` with CMake). Standard
|
|
Android practice, and it moves native compilation into Gradle, which this design has
|
|
kept out.
|
|
3. **JNA**, which calls a plain C ABI with no per-project glue at all. Cheapest to start,
|
|
and it costs a dependency and per-call marshalling.
|
|
|
|
The first, without `jni.h`, because the library already builds with no NDK and no libc
|
|
(6.2) and dragging in a sysroot for three functions would undo that.
|
|
|
|
**What that costs, and how it is held down.** `JNIEnv*` is a pointer to a table of
|
|
function pointers at fixed indices, so reaching `NewStringUTF`, `GetStringUTFChars` and
|
|
`ReleaseStringUTFChars` without the header means four integers have to be right, and a
|
|
wrong one is a jump into the wrong function rather than a compile error. Three things
|
|
guard it:
|
|
|
|
- The table is declared at its full published length (233 entries since JNI 1.6, with
|
|
`GetObjectRefType` last), and a comptime check rejects an index past the end. The
|
|
arithmetic that arrives at exactly that length is written out in the file, because that
|
|
count is what catches a dropped entry.
|
|
- `JNI_OnLoad` proves the indices against the JVM that is actually running, before
|
|
anything else: it checks the version, then round-trips a string through `NewStringUTF`
|
|
and `GetStringUTFChars` and compares the bytes. On failure it returns `JNI_ERR`, so
|
|
`System.loadLibrary` throws at startup rather than the app crashing later inside an
|
|
evaluation.
|
|
- The tests run every entry point against a synthetic function table, covering the
|
|
marshalling, the handle arithmetic and the self-check in both directions (including a
|
|
deliberately broken table). They cannot check the indices themselves, because they
|
|
supply the table.
|
|
- **A real JVM can, and not only Android's.** JNI's function table is fixed by the
|
|
specification rather than by the platform, so a desktop JVM resolves the same indices an
|
|
Android runtime does. `zig build jvm-test` builds the library for the host, compiles
|
|
`engine/test/java/dev/lerch/tally/TallyEngine.java`, and runs it: `System.loadLibrary`
|
|
executes `JNI_OnLoad`, and the test then drives every entry point, including a catalogue
|
|
string over 4KB. Moving one index by a single slot makes that step fail, which is how we
|
|
know the check has teeth. It is a separate step rather than part of `zig build test`
|
|
because it needs a JDK and nothing else in the suite does.
|
|
|
|
**The glue is also what makes borrowed results safe.** The bytes from `tally_eval` become
|
|
a Java string inside the same function that asked for them, so the app never holds a
|
|
native pointer and cannot outlive one. `c_api` leaves a NUL just past the reported length
|
|
for exactly this, so the copy the JVM makes is the only copy in the call.
|
|
|
|
The C ABI is unchanged by this choice: a Swift or C consumer links the same library and
|
|
calls it directly.
|
|
|
|
### 6.3 Who the ABI is for, and what is actually Android-specific
|
|
|
|
The shape in 6.2 was chosen from Android's access pattern, which invites a fair
|
|
question: should the exports be named for Android, or built differently per target? The
|
|
answer is no to the first and yes to the second, for reasons that are not symmetrical.
|
|
|
|
**The C ABI is not Android-specific, and its names should not pretend to be.** Take the
|
|
decisions one at a time and ask which consumer they came from:
|
|
|
|
- *A session handle* is what any caller wants. Variables and `Ans` outlive a single
|
|
evaluation for a C program, a Swift app or a WASM page exactly as they do for Kotlin.
|
|
- *Borrowed results* suit every managed binding, because they all copy on receipt: JNI
|
|
into a `ByteArray`, Swift into a `String` or `Data`, JS into a string. The rule only
|
|
chafes for a raw C caller that wants to accumulate results without copying, and
|
|
`sqlite3_column_text` has held that exact contract for two decades in the most widely
|
|
embedded C library there is.
|
|
- *Status codes instead of crashes on OOM* is table stakes anywhere.
|
|
- *One session per thread at a time* is what a `ViewModel` does naturally and what a C
|
|
caller can arrange trivially.
|
|
|
|
Only two choices are platform-flavoured, and neither is visible in a signature: using
|
|
`c_allocator` for the session's long-lived allocations (internal), and preferring JSON
|
|
for compound results (a preference Kotlin, Swift and JS all share).
|
|
|
|
So the exports keep names that say what they do: `tally_session_new`, `tally_eval`,
|
|
`tally_session_free`. Naming them `tally_android_eval` would encode the first consumer
|
|
into a contract that outlives it, and the second consumer would then either call
|
|
functions labelled for a platform it is not, or get a duplicate set to keep in step.
|
|
This project has already renamed one thing for that reason: `Coverage.addModule` became
|
|
`addReport` because the old name described the caller's mental model rather than the
|
|
thing itself. If a future consumer genuinely cannot live with a borrowed pointer, the
|
|
answer is one more function beside the existing ones (`tally_eval_into`, taking a
|
|
caller-provided buffer), not a rename and not a parallel set.
|
|
|
|
**There is an Android-specific layer, and it is not the C ABI.** A Kotlin `external fun`
|
|
does not call `tally_eval`. JNI resolves either a symbol named
|
|
`Java_<package>_<Class>_<method>` or whatever `JNI_OnLoad` registers with
|
|
`RegisterNatives`, and either way the function's signature is in JNI's terms: a
|
|
`JNIEnv*`, a `jobject`, and `jstring` arguments that have to be pinned with
|
|
`GetStringUTFChars` and released again. That translation is genuinely Android's:
|
|
|
|
- it names a Java package, so it cannot be shared with anything else,
|
|
- it needs `jni.h` types that do not exist on any other target,
|
|
- and it is thin: convert, call the C ABI, copy the result into a `jstring` or
|
|
`ByteArray`, return.
|
|
|
|
That layer belongs in its own file (`engine/src/jni.zig`), compiled only for Android
|
|
behind a build option, with names that do say Android because JNI requires it. The C ABI
|
|
stays byte-identical on every target; Android gets an additional, thinner set of exports
|
|
on top of it. A Swift or C consumer links the same library and calls the C ABI directly,
|
|
with no shim at all, because Swift imports C headers natively.
|
|
|
|
**The shim is also what makes borrowed results safe here.** The copy into JVM memory
|
|
happens inside `jni.zig`, in the same function, before control returns to Kotlin. The
|
|
app never holds a native pointer and cannot outlive one, so the lifetime rule in 6.2 is
|
|
enforced by code in this repository that can be tested, rather than by an app developer
|
|
remembering a sentence in a document. That is a better position than the caller-buffer
|
|
alternative would leave us in, and it is the argument that settled 6.2 for me.
|
|
|
|
Two things to add while the boundary is being written, both cheap and both awkward to
|
|
retrofit:
|
|
|
|
- **A hand-written `include/tally.h`.** Zig no longer emits reliable C headers, and the
|
|
header is what a Swift or C consumer actually compiles against. Writing it by hand is
|
|
fine; leaving it to drift is not, so a test should compile it against the real exports
|
|
(a `translate-c` of the header, or a C file that takes the address of every function,
|
|
built as part of `zig build test`).
|
|
- **`tally_abi_version()` beside `tally_version()`.** Android ships a prebuilt `.so`
|
|
that can fall out of step with the Kotlin calling it, and a struct layout or an
|
|
argument order that changed silently is a crash in the field rather than an error. The
|
|
product version and the ABI version are different facts and should be separately
|
|
readable.
|
|
|
|
### 6.5 Preview: an evaluation that stores nothing
|
|
|
|
**Decided and built, for the live result on the standard screen (9.2).** A screen that
|
|
answers as you type calls the engine on every keystroke, and `tally_eval` is the wrong
|
|
call for that: typing `x = 7` would assign `x` at `x =` ... `7`, and every intermediate
|
|
answer would overwrite `Ans`, so `Ans` would mean "whatever was last on screen" rather
|
|
than "what I last committed". Both are silent - the numbers are plausible - which is
|
|
the kind of wrong a calculator cannot afford.
|
|
|
|
```zig
|
|
export fn tally_preview(?*Session, ?[*]const u8, usize, c_int, ?*[*]const u8, ?*usize) Status;
|
|
```
|
|
|
|
Same arguments, same JSON, same borrowing rule as `tally_eval`; the only difference is
|
|
that nothing in the session changes. An assignment previews as the value it would store,
|
|
and a built-in target (`pi = 3`) previews as the error committing it would give.
|
|
|
|
- **A separate function, not a flag.** A `bool` on `tally_eval` changes a signature that
|
|
shipped; folding a bit into `mode` makes one integer mean two things. A new export is
|
|
additive, and `abi_version` moves to 2 so a prebuilt library without it is refused at
|
|
startup rather than failing on the first keystroke.
|
|
- **The guarantee is the type, not a branch.** Assignment is a statement (Task 5.19): the
|
|
parser only produces it at the root, never inside an expression. So `evalStringInfo`
|
|
now handles the root assignment itself, and the recursive evaluator below it takes a
|
|
`*const Environment` - it *cannot* store anything. The preview path,
|
|
`engine.previewStringInfo`, is the same walk without the two writes at the end, and it
|
|
takes a `*const Environment` too, so "preview mutated the session" is a compile error
|
|
rather than a test. The CLI and TUI get the same function; neither uses it yet.
|
|
- **Programmer mode is already pure** - it never touches the environment - so its preview
|
|
is its evaluation.
|
|
|
|
### 6.6 Convert: one value, every unit
|
|
|
|
**Decided and built, for the converter screen (9.6).**
|
|
|
|
```zig
|
|
export fn tally_convert(?*Session, value: ?[*]const u8, usize, unit: ?[*]const u8, usize, ?*[*]const u8, ?*usize) Status;
|
|
```
|
|
|
|
`{"ok":true,"input":"100","from":"C","category":"temperature","results":[{"unit":"C",
|
|
"display":"100","exact":true,"truncated":false},{"unit":"F","display":"212",...},...]}`
|
|
|
|
- **The whole category in one call,** because the screen shows all of it. The
|
|
alternative - one `tally_eval("100 C to F")` per row - is a dozen JNI round trips and
|
|
a dozen parses per keystroke, and puts the list of units in Kotlin.
|
|
- **The value is an expression, previewed** (6.5): `6*12` in inches works, and nothing
|
|
in the session changes.
|
|
- **A row that fails carries its own `error`** and leaves the rest of the table alone.
|
|
- **The rendering budget is the session's** (`Config`, 6.1 point 3), so the converter
|
|
sets fewer digits on its own session than the calculator does. Over JNI that is
|
|
`configureDisplay`, which reads the current config, changes the two digit counts and
|
|
writes it back, so the other fields keep their values.
|
|
- `abi_version` 3.
|
|
|
|
---
|
|
|
|
### 6.7 Save and load: a session that survives the process
|
|
|
|
**Decided and built, for the calculator reopening as it was left (9.2).** The engine
|
|
holds the variables and `Ans`, so the app saving only what its screen shows would bring
|
|
back `x * 2` without `x`.
|
|
|
|
```zig
|
|
export fn tally_session_save(?*Session, ?*[*]const u8, ?*usize) Status;
|
|
export fn tally_session_load(?*Session, ?[*]const u8, usize) Status;
|
|
```
|
|
|
|
`{"format":1,"ans":{"exact":"1/7"},"variables":[{"name":"r","value":{"bits":"3ff6a09e667f3bcd"}}]}`
|
|
|
|
- **Exact, both tiers.** An exact value is saved as `toFractionString` writes it, every
|
|
digit; it loads through `Rational.parseFraction`, which has no length cap, because
|
|
`parse`'s 512-digit limit is right for typing and wrong for `factorial(1000)`. An
|
|
inexact value is saved as its IEEE-754 bits, so it comes back as the same float and
|
|
not a decimal near it. A reloaded session saves to the same bytes it was loaded from.
|
|
- **Not a replay.** Re-evaluating the history on launch was the alternative and is
|
|
wrong twice: `Ans * 2` depends on every answer before it, so history cannot be trimmed
|
|
without changing values, and clearing it would silently drop the variables.
|
|
- **All or nothing.** The text is read and checked completely - format, a name an
|
|
assignment could have made, no duplicates, exactly one well-formed value form - and
|
|
only then swapped in. A refused load leaves the session untouched. Unknown fields are
|
|
ignored, so a later format can add to it.
|
|
- **Deterministic:** variables are listed by name.
|
|
- **Not included:** the configuration, which is the frontend's to set.
|
|
- Tested by round trips (2^100, 1/3, sqrt(2), factorial(500), `Ans`), a byte-identical
|
|
re-save, a load that replaces rather than merges, fourteen malformed states each
|
|
leaving the session as it was, and an allocation-failure sweep over save and load. ABI
|
|
version 5.
|
|
|
|
### 6.8 Programmer mode from a frontend that holds the value
|
|
|
|
**Built, for the programmer screen (9.3), ABI version 6.** Two additions, both because
|
|
the screen keeps a register rather than a text answer:
|
|
|
|
- **`pattern` in the programmer result:** the value masked to the width, as lowercase
|
|
hex with no prefix or grouping (`"pattern":"ff"`). The rows are for reading and their
|
|
format is free to change; this is for holding. Unsigned at the width, so a negative
|
|
8-bit result is `f0`, never sign-extended past it. Before it, the only way to learn
|
|
the value was to parse the binary row.
|
|
- **`configureProgrammer(handle, bits, signed, bigEndian)` in JNI:** read the session's
|
|
configuration, change the three programmer fields, configure. The display budget is
|
|
untouched, as `configureDisplay` leaves the programmer fields untouched. The C ABI
|
|
already had this through `tally_session_configure`; only JNI lacked it.
|
|
|
|
Tested in `c_api` (masking, a negative at 8 bits, zero, all 128 bits of -1), the C ABI
|
|
check through the header, JNI against the synthetic table (each of the three settings
|
|
visible in one result, a nonzero `jboolean` as true, refused widths changing nothing),
|
|
`jvm-test` (an int and two booleans through a real JVM), and an instrumented test.
|
|
|
|
|
|
## 7. CLI Design
|
|
|
|
```
|
|
tally [OPTIONS] <expression>
|
|
tally convert <value> <from-unit> [to] <to-unit>
|
|
tally amort <principal> <rate-per-period> <periods> [payment] [--monthly] [--summary] [--exact]
|
|
tally struct <definition | file | -> [--abi <name>] [--color <when>]
|
|
|
|
Options:
|
|
-p, --programmer Programmer mode
|
|
--bits <N> Bit width: 8, 16, 32, 64, 128 (default 64). Implies -p
|
|
--signed / --unsigned How to read programmer values (default signed). Imply -p
|
|
--endian <big|little> Byte order of the hex and ASCII rows (default big). Implies -p
|
|
-h, --help Show help
|
|
--version Show version
|
|
|
|
Each subcommand answers -h with its own usage.
|
|
```
|
|
|
|
The flags this section used to list (`-b`, `--base`, `--json`) were never built.
|
|
`--bits` is the spelling, there is no way to select a single output base (the
|
|
multi-base view appears when the expression used a non-decimal literal), and JSON
|
|
output belongs to the C API rather than the CLI. `--abi` exists, but on `tally struct`
|
|
rather than at the top level, since nothing else has an ABI.
|
|
|
|
### 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 (actual output). Each member's letter in the table is its letter in the map,
|
|
`.` is padding, and the profile is named because the answer depends on it:
|
|
```
|
|
$ tally struct 'struct packet { uint8_t kind; uint32_t len; char name[24]; void *next; }'
|
|
struct packet
|
|
ABI sysv64: 64-bit Linux and macOS (x86-64 SysV, AArch64)
|
|
|
|
Offset Size Align Type Name
|
|
a 0 1 1 uint8_t kind
|
|
1 3 (padding)
|
|
b 4 4 4 uint32_t len
|
|
c 8 24 1 char[24] name
|
|
d 32 8 8 void * next
|
|
|
|
40 bytes, alignment 8, 3 bytes of padding (7.5%); 37 bytes packed
|
|
|
|
0x00 a . . . b b b b c c c c c c c c
|
|
0x10 c c c c c c c c c c c c c c c c
|
|
0x20 d d d d d d d d
|
|
```
|
|
The sketch this replaced used box-drawing rules and a middle dot, a `Padding` column, and
|
|
a map of one bracketed run per member. The output is ASCII, a padding run is a row of its
|
|
own so the table reads in address order, and the map is one letter per byte: sixteen
|
|
bytes a row is what a hex dump looks like, so the eye already knows how to read it. Rows
|
|
that repeat collapse to `*`, as in hexdump, and the map is found from the layout's
|
|
segments rather than walked byte by byte, so a 2 GiB array costs three lines.
|
|
|
|
On a terminal the same output is colored (FR-6.5.1): a member's key and name in the
|
|
table and its run of bytes in the map share one of the terminal's six hues, and padding
|
|
is faint, so the map reads as colored blocks with gaps between them. Color is layered on
|
|
rather than substituted: `format.Paint` is `plain` or `ansi`, the renderer writes the
|
|
same text either way plus escape codes in the second case, and a test strips the codes
|
|
and compares the result to the plain output byte for byte. `main` decides which, from
|
|
`--color`, whether stdout supports escape codes, `NO_COLOR` and `TERM`, through a pure
|
|
`ColorChoice.resolve` that is tested on its own. Three choices behind it:
|
|
|
|
- **The terminal's palette, not fixed RGB.** The TUI's Molokai triples (open item 17)
|
|
ignore the user's theme; SGR 31-36 are whatever the theme says, and a theme is built
|
|
so its own colors read on its own background.
|
|
- **No background, ever.** Forcing black was considered as insurance against an
|
|
unreadable hue and rejected: it would undo the theme on every light terminal to fix a
|
|
problem the theme already solves.
|
|
- **Faint for padding, not bright black.** Several themes (Solarized among them) draw
|
|
bright black in nearly the background color, which would make padding vanish where it
|
|
should recede. Every code begins with a reset, because faint persists until cleared.
|
|
|
|
An error names its place:
|
|
```
|
|
$ tally struct 'struct { int a; uint33_t b; }'
|
|
error: unknown type (typedefs and macros from elsewhere are not visible)
|
|
line 1, column 17: uint33_t
|
|
```
|
|
|
|
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
|
|
|
|
The mockups in this section are sketches of intent, not records of output. Where one
|
|
disagrees with the program, the program is right: the frames in `src/tui.zig`'s render
|
|
tests are drawn by the real draw path and asserted character by character, which is
|
|
where to look for what a view actually produces. Sketches that are known to have
|
|
drifted are marked.
|
|
|
|
### 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 two structural checks bundled as
|
|
`test_render.expectSound(rows, full_width_rows)`: `furniture(rows).intact()`, which
|
|
checks that the separator, prompt and status rows are still where they belong, and
|
|
`noTextClipped`, which checks that no row other than the ones drawn full width on
|
|
purpose reaches the final column. The first catches content that overflowed its
|
|
region downward; the second catches content too wide for the terminal, which
|
|
`draw.writeStr` truncates in silence.
|
|
|
|
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. `noTextClipped` was written at
|
|
the same time and then left uncalled for as long, which hid two more: the 64-bit
|
|
binary row needed 90 columns and lost its low bits on an 80-column terminal, and
|
|
every mode's status line lost its last hint mid-word. 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] [Convert] -+
|
|
| |
|
|
| 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.
|
|
|
|
Two details the sketch simplifies: there is no "History:" heading, and the answer is
|
|
not repeated under the prompt. A result appears in the history list as `= value` on the
|
|
line under its expression, which is where the sketch's `= 10.42...` line comes from.
|
|
|
|
### 8.2 Programmer Mode
|
|
|
|
A real 80-column frame, 64-bit, value 0xDEADBEEFCAFEF00D:
|
|
|
|
```
|
|
| Tally Standard Programmer Financial Convert |
|
|
| |
|
|
| Bits: 64 Signed: yes Endian: BE [float: Ctrl-F] |
|
|
| |
|
|
| 1101 1110 1010 1101 1011 1110 1110 1111 |
|
|
| 1100 1010 1111 1110 1111 0000 0000 1101 |
|
|
| |
|
|
| DEC(s): -2,401,053,089,206,439,923 |
|
|
| DEC(u): 16,045,690,984,503,111,693 |
|
|
| HEX: DE AD BE EF CA FE F0 0D |
|
|
| ASCII: . . . . . . . . |
|
|
| OCT: 1 572 555 756 771 277 570 015 |
|
|
| BIN: 1101 1110 1010 1101 1011 1110 1110 1111 |
|
|
| 1100 1010 1111 1110 1111 0000 0000 1101 |
|
|
| |
|
|
| (history) |
|
|
|--------------------------------------------------------------------------------|
|
|
| > |
|
|
| Tab:mode | Arrows:nav | Space:toggle | Up/Down:field | Ctrl-W:width |
|
|
```
|
|
|
|
The binary row wraps at 32 bits because 64 will not fit an 80-column terminal
|
|
(FR-7.3.2); a 90-column one shows it on one line. The status line drops whole hints
|
|
from the end rather than cutting one in half (FR-7.9.1), which is why `Ctrl-E:endian`
|
|
and `Ctrl-F:float` are absent here and the float affordance sits on the header row
|
|
instead.
|
|
|
|
An earlier wireframe in this section showed a bordered "Bit Pattern (63 -> 0)" box, a
|
|
`cursor: bit 3` indicator, `0x`-prefixed hex, and a footer of single-letter keys
|
|
(`w:width s:sign e:endian y:yank`). None of that was built: there are no borders, the
|
|
prefix appears only in the clipboard form, the width and sign keys are Ctrl-chords
|
|
because plain letters are hex digits in an editable field (FR-7.8), and there is no
|
|
yank command yet (open item 14).
|
|
|
|
#### 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.)
|
|
|
|
Field 8 was never built. `ProgField.expression` exists and Up/Down cycles through it,
|
|
but no row is drawn for it and no key does anything there, so it reads as a keystroke
|
|
that did nothing (open item 18). The expression language is reachable from the input
|
|
line instead, which is where it has always been.
|
|
|
|
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) - DESIGN ONLY, NOT BUILT
|
|
|
|
The engine behind this exists (section 3); the view does not, deliberately. It waits
|
|
until `tally struct` has been used enough to say what an interactive view should do:
|
|
whether people paste whole definitions or build them a member at a time, and whether
|
|
reordering to watch the padding move is worth a key. The sketch below predates the CLI,
|
|
and where they disagree the CLI's table and map (section 7) are the better starting
|
|
point, not least because the TUI draws one byte per cell and cannot show the box and
|
|
block glyphs this sketch uses.
|
|
|
|
```
|
|
┌─ 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 shell (9.1), standard (9.2), programmer (9.3) and convert (9.6) are built; struct
|
|
layout, financial and tablet (9.4, 9.5, 9.7) are still DESIGN ONLY. 9.2 was rewritten
|
|
after the first build ran on a device, which is the input the rest of this section was
|
|
waiting for.
|
|
|
|
That first build was a text field, the system keyboard, an `=` button and a monospace
|
|
scrollback: the TUI's prompt moved onto glass, the exact paradigm the paragraph below
|
|
rules out. It proved the boundary and was nearly unusable, and the specific ways it
|
|
failed are what 9.2 is now built against. Read the struct layout screen (9.4) as doubly
|
|
speculative, since the engine feature behind it is also unbuilt.
|
|
|
|
The remaining designs are deliberately left unreconciled with what the TUI became during
|
|
the review, even where the two now disagree (9.5 has a Calculate button; the TUI dropped
|
|
it because results are live). Expect each to be rewritten the way 9.2 was: from a
|
|
running screen, not from what the TUI settled on.
|
|
|
|
**The principle the built screens converged on,** written down because it was learned by
|
|
having proposals rejected, and the unbuilt screens are where it will be tested next:
|
|
|
|
- **Nothing moves unless the user moves it.** No auto-scroll to a chosen item, no
|
|
reordering under a finger, no list that jumps when a value changes. The value line
|
|
already says what was chosen; motion the user did not cause costs them their place.
|
|
(Rejected along the way: scrolling the result list to the chosen unit; recency order
|
|
within a category.)
|
|
- **State is remembered, not guessed.** Each screen reopens exactly as it was left, and
|
|
each part of it keeps its own memory - the converter's categories each keep their
|
|
value, unit and scroll position. When something is unknown, the honest default (the
|
|
top of a list, a blank field) beats a plausible reconstruction. (Rejected: carrying a
|
|
number across categories, so 98.6 km became 98.6 kg.)
|
|
- **Stable layouts are an interface.** Fixed order, things highlighted in place rather
|
|
than removed, so that where something is on screen is worth remembering.
|
|
- **Look before designing.** The first build was the TUI on glass; every good decision
|
|
since came from reacting to something running, usually against a concrete reference
|
|
(NCalc) with taps counted.
|
|
|
|
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
|
|
|
|
**Built: a hamburger drawer, as in NCalc.** Calculator, Programmer, Convert, and
|
|
Settings below a divider, under the app name and the engine version (which crossed JNI
|
|
to be there).
|
|
Opened by the hamburger only; an edge swipe would fight the system back gesture and the
|
|
keys nearest the edge. Each screen also has a one-tap link to the other in its top bar,
|
|
because the drawer is two taps and switching between calculating and converting is the
|
|
common move. The app reopens on whichever was last open; Settings is never reopened.
|
|
|
|
**Settings** holds one thing so far: the theme - follow the device (the default),
|
|
light, or dark. The choice is saved, and the system bar icons follow the app's choice
|
|
rather than the device's. The palettes are Material's baseline light and dark schemes,
|
|
not the system's dynamic colours: those were tried and, on the test device, gave the
|
|
keypad's accent bands light containers in dark mode.
|
|
|
|
The original plan, kept for the record: a bottom navigation bar with 4 destinations,
|
|
**Standard**, **Programmer**, **Financial**, **Convert**. Financial is not built, and
|
|
will be a drawer entry when it is. Programmer has no top-bar link: calculating and
|
|
converting are the common move between screens, and the drawer covers the rest.
|
|
|
|
```
|
|
┌─────────────────────────────────────────┐
|
|
│ ┌─────────────────────────────────┐ │
|
|
│ │ │ │
|
|
│ │ Mode Content Area │ │
|
|
│ │ │ │
|
|
│ └─────────────────────────────────┘ │
|
|
│ │
|
|
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
|
│ 🔢 Standard 💻 Programmer 💰 Finance 🔄 Convert │
|
|
└─────────────────────────────────────────┘
|
|
```
|
|
|
|
### 9.2 Standard Calculator Screen
|
|
|
|
**Decided after the first build, and built.** Touch-first: the keypad is the input, the
|
|
system keyboard is on request, and the answer is visible before `=` is pressed. The
|
|
layout follows NCalc (NCalcLibre on F-Droid), which is the reference for "what this
|
|
should feel like": a display card over a pad, a scientific block on an accent band above
|
|
the digits, and the rarer functions on a panel that slides up over the numbers.
|
|
|
|
```
|
|
+-----------------------------------------+
|
|
| Tally History | <- the only chrome
|
|
| |
|
|
| x / 3 + 1| | <- expression, large, with a cursor;
|
|
| = 4.2255e29 (dim) | operators drawn as glyphs
|
|
|-----------------------------------------|
|
|
| sin cos tan ln log sqrt | <- scientific block, accent band
|
|
| ( ) ^ x^2 n! e |
|
|
| x A B , mod abc |
|
|
|-----------------------------------------|
|
|
| 7 8 9 CLR <x | <- NCalc's numeric block, key for key
|
|
| 4 5 6 * / |
|
|
| 1 2 3 + - |
|
|
| 0 . pi Ans = |
|
|
|-----------------------------------------|
|
|
| more functions | <- slides a panel up over the digits:
|
|
+-----------------------------------------+ asin acos atan atan2 exp log2 log10
|
|
cbrt abs floor ceil round min max tau C
|
|
```
|
|
|
|
- **No system keyboard by default.** Every key inserts plain engine syntax at the cursor
|
|
(`*`, `sqrt(`, `pi`), and the field still takes a tap to move the cursor. `abc` hides
|
|
the pad and raises the system keyboard for what a pad cannot offer - any other
|
|
variable name, functions not on it; Enter or Done means `=`, and dismissing the
|
|
keyboard brings the pad back. Both write the same text, so either can finish what the
|
|
other started.
|
|
- **Variables without a keyboard.** `x`, `A`, `B` (and `C` on the panel) insert their
|
|
name; a long-press stores into them - the expression if one is typed, `Ans` otherwise.
|
|
A calculator's STO, written as the `A = ...` assignment the engine already has.
|
|
- **Live result.** Every edit asks the engine for a *preview* (6.5): the same evaluation,
|
|
with nothing stored. Typing `x = 7` shows `= 7` without assigning `x`, and `Ans` stays
|
|
the last committed answer until `=`. A preview that fails - usually an unfinished
|
|
expression like `2 +` - shows nothing rather than an error, so the screen does not
|
|
flash red on every other keystroke.
|
|
- **`=` commits.** The entry goes into history, the answer becomes the display at full
|
|
size, and the expression that produced it sits above it, dimmed. A key that continues
|
|
an expression (an operator, `^`, `x^2`, `mod`) starts from `Ans`, so `= 14`, then `* 2`,
|
|
means `Ans * 2`. A digit starts fresh. Backspace brings the committed expression back
|
|
for editing. An error on `=` is shown in place and the expression is kept.
|
|
- **The answer is the largest thing on screen,** right-aligned, and shrinks to keep a
|
|
long exact answer on one line: `2^100` is 41 characters with its separators and fits.
|
|
Long-press copies it.
|
|
- **Backspace** deletes before the cursor; **long-press** or `CLR` clears.
|
|
- **`,` inserts `, `.** The engine reads `1,234` as a grouped number, so an argument
|
|
separator needs its space: `max(1,234)` is 1234, `max(1, 234)` is 234.
|
|
- **History is its own screen,** as in NCalc, so the calculator keeps its whole height
|
|
for the display and the pad. Newest first; tap an entry to put its expression back,
|
|
long-press to copy its answer. Results are not inserted as text: they carry separators
|
|
and may be abbreviated, which is display rather than the value. The engine version -
|
|
the first proof that a string crossed JNI - is on its empty state.
|
|
- **No unit conversion.** It is its own screen with pickers (9.6), not something typed
|
|
here. The engine still converts a typed `100 km to mi` (FR-4.1), so it works through
|
|
`abc`, but the screen offers it nowhere.
|
|
- **Evaluation is off the main thread.** A preview of `factorial(5000)` has to cost a
|
|
frame, not an ANR, so every engine call runs on one dedicated thread. One, because a
|
|
session takes no locks (6.1, rule 3): serialising the calls is the caller's job, and a
|
|
single-threaded executor does it - including at teardown, where the session is freed
|
|
by a task queued behind anything still running.
|
|
- **Colours are the system's** (Material You) on Android 12 and later, Material's
|
|
defaults before that; light and dark follow the system.
|
|
|
|
- **It reopens as it was left.** The expression and its cursor, the answer on display,
|
|
the history (newest 500), and - through the engine's own save (6.7) - every variable
|
|
and `Ans`, exactly. Saved on every edit, so even a kill with the app in the
|
|
foreground loses nothing. Clearing history keeps the variables: history is a record
|
|
of what was typed, not where values live. Verified on the emulator: store 7 in A,
|
|
`A x 6 =`, leave `2+` half typed, force-stop in the foreground, relaunch - `2+` is
|
|
there, `2+Ans` previews 44, `A` is 7, and all four entries are in history.
|
|
|
|
Not yet: landscape (9.7), a `sqrt` drawn as a radical in the expression, and any check
|
|
on a physical device.
|
|
|
|
### 9.3 Programmer Mode Screen
|
|
|
|
**Built (first version), for reaction on a device.** Replaces a wireframe that predated
|
|
the TUI's programmer mode and disagreed with it on width, hex format, signedness and
|
|
editing. This one starts from what the TUI actually does (8.2, 8.2.1), the standard
|
|
screen's pad-and-preview model (9.2), and the principles at the top of section 9.
|
|
|
|
```
|
|
+-----------------------------------------+
|
|
| = Programmer |
|
|
| [8][16][32][64][128] signed BE | <- settings strip, fixed positions
|
|
|-----------------------------------------|
|
|
| 0xDEAD << 4 | 3| | <- expression, cursor, no keyboard
|
|
| = 0xDEAD3 (dim) | <- live preview, in the input base
|
|
| DEC(s) 912,083 |
|
|
| DEC(u) 912,083 | <- every row is the shown value
|
|
| [HEX] 00 00 00 00 00 0D EA D3 | <- the input base, highlighted in place
|
|
| OCT 0 000 000 000 000 003 365 323 |
|
|
| ASCII . . . . . . . . |
|
|
| BIN (warning, if any) |
|
|
| 63 0000 0000 0000 0000 | <- bit grid: 16 per row, bit 0 always
|
|
| 47 0000 0000 0000 0000 | bottom right; tap toggles
|
|
| 31 0000 0000 0000 1101 |
|
|
| 15 1110 1010 1101 0011 |
|
|
|-----------------------------------------|
|
|
| AND OR XOR NOT mod | <- operator band
|
|
| << >> >>> ROL ROR |
|
|
|-----------------------------------------|
|
|
| A B C D E F | <- dimmed where the base has no such digit
|
|
| 7 8 9 CLR <x |
|
|
| 4 5 6 * / |
|
|
| 1 2 3 + - |
|
|
| 0 ( ) = |
|
|
+-----------------------------------------+
|
|
```
|
|
|
|
**Two things on screen: an expression and a value.** The value is a register - a
|
|
128-bit pattern, as the TUI's `prog_value` is - and every row and the grid show it.
|
|
The expression is engine syntax, written by the pad, exactly as on the standard screen.
|
|
|
|
- **Live, as on the standard screen.** While the expression is not empty, the rows and
|
|
the grid show its preview. Programmer evaluation reads no environment, so a preview
|
|
is the evaluation. When the expression stops evaluating mid-edit (`0xDEAD <<`), they
|
|
keep the last value it had, dimmed, rather than flashing back to the register: the
|
|
user is still typing and nothing they did changed the value. With nothing typed they
|
|
show the register.
|
|
- **`=` puts the answer in the register.** The expression is kept above the answer,
|
|
dimmed, until the next key, as on the standard screen. An error on `=` is shown in
|
|
place and the expression is kept.
|
|
- **The expression line is never blank while the register holds a value.** With
|
|
nothing typed it shows, dimmed, the expression `=` last put there or, failing that -
|
|
after a bit tap, after CLR took a half-typed expression, or after a restart that only
|
|
had the value - the register itself as a literal in the input base (`0x9CE6...`). It
|
|
is blank only when the register is zero. The first build left it blank in those
|
|
cases, and on the phone that read as the value not having been saved, though it had.
|
|
- **There is no `Ans`** in programmer evaluation (it takes no environment). The pad
|
|
writes the register in instead: whenever that line is showing, an operator starts
|
|
from the value as a literal in the input base (`0xF0 and `), NOT wraps it (`~0xF0`), a
|
|
digit starts fresh, and backspace brings the line back for editing - the standard
|
|
screen's rules after `=`, with a literal where that screen has `Ans`.
|
|
- **A tap on the grid edits what is shown.** With nothing typed it flips a bit of the
|
|
register. With an expression showing its preview, the tap is `=` and then the flip,
|
|
so the flip lands on the value the user was looking at; the line then shows the new
|
|
value. While the grid is dimmed it does not answer taps: what it shows is not a value
|
|
anything holds.
|
|
- **CLR** clears the expression; with the expression already empty, it zeroes the
|
|
register (a calculator's CE then C). Long-press on backspace clears both.
|
|
- **Backspace removes an operator whole.** Operators are written with their spaces
|
|
(` and `, ` << `) because the keyword forms need them; backspace after one deletes all
|
|
of it rather than costing five taps.
|
|
|
|
**The input base is chosen by tapping a row's label** - HEX, DEC(s) or DEC(u) (both
|
|
mean decimal), OCT, or BIN above the grid - and is highlighted in place; nothing moves.
|
|
A digit that starts a number is written with the base's prefix (`0x`, `0o`, `0b`; none
|
|
for decimal), so typing `F F` in hex gives `0xFF`. A digit next to a number continues it,
|
|
whatever the base, so editing an existing literal works. Digits the base does not have
|
|
stay where they are and are dimmed. Everything typed is visible engine syntax, so a
|
|
shift distance typed in hex reads `<< 0x10`, not a silent 16.
|
|
|
|
**Width is a lens, as in the TUI.** Narrowing never masks the register: its rows and
|
|
grid show the low bits, a warning on the BIN line says bits are hidden, and widening
|
|
brings them back. Any edit - a bit, `=`, CLR - commits to the width on screen. The
|
|
engine renders every value: Kotlin sends the register as a masked hex literal and the
|
|
configuration (`configureProgrammer`, ABI 6), and reads the rows and the pattern back.
|
|
The pattern is a new `pattern` field in the programmer JSON - lowercase hex, unsigned,
|
|
masked, no grouping - so Kotlin never parses a display string to learn a value.
|
|
|
|
**Signedness and byte order** are toggles in the strip, as in the TUI: signedness
|
|
decides `>>` and how a decimal literal re-enters; byte order decides the HEX
|
|
and ASCII rows only. Both DEC rows are always shown (FR-2.5).
|
|
|
|
**BIN is the grid.** A 128-bit binary row is four lines on a phone and says exactly what
|
|
the grid says, so there is no BIN row; its label sits above the grid. The grid is 16 bits
|
|
a row, nibble-grouped, with each row's top bit index on the left. Its area is the same
|
|
height at every width: bit 0 is always bottom right, so bit N is in the same place at
|
|
every width that has it, and 128 bits fit by halving the row height.
|
|
|
|
**It reopens as it was left:** width, signedness, byte order, input base, register,
|
|
expression and cursor, and the committed expression on display.
|
|
|
|
**A long-press copies a row** - on its label or its value - as something you could type:
|
|
`0xBEEE`, `0o137356`, `0b1011...`, `48878`, `-18`. Prefixed, ungrouped, at the width on
|
|
screen, and for hex most significant first whatever the byte order, since the byte order
|
|
is a view and the clipboard is the value (the clipboard column of 2, "Programmer Mode
|
|
Formatting"). DEC(s) and DEC(u) each copy their own reading; BIN copies from its label
|
|
above the grid; ASCII has no typed form and copies nothing. Verified on the emulator by
|
|
pasting into the calculator: `0xBEEE` evaluated to 48,878, and an 8-bit DEC(s) copied
|
|
as `-18`.
|
|
|
|
**The rows are one line each, at a size chosen per width,** from the longest value that
|
|
width can produce (octal is the longest: 57 characters at 128 bits), so typing never
|
|
reflows them and nothing below moves. The size is measured with the monospace face
|
|
rather than assumed, which takes the device's font scale into account; the first
|
|
version guessed a glyph width and clipped the last octal digits, because the theme's
|
|
body style adds letter spacing the guess did not include. At 128 bits that size is
|
|
small (about 9 sp for octal); the grid uses the room the shorter rows leave.
|
|
|
|
Verified on the emulator by driving it by label: `D E A D` in hex gives `0xDEAD`; ` << `
|
|
then `4` writes `0xDEAD << 0x4` and previews 912,080 in both DEC rows; a trailing operator dims
|
|
the rows and grid without moving them; backspace takes ` << ` whole; `=`, then AND,
|
|
writes `0xDEAD0 and `; bit taps on the register, and on a preview (`=` then flip); at
|
|
8 bits the warning appears and 64 brings 32,977 back; DEC(s) dims A-F and `200 =` reads
|
|
-56 with `200` above it; little-endian `0xDEADBEEF and 0xFF` reads `EF 00 00 00`; a
|
|
force-stop restores width, byte order and the half-typed expression; light and dark.
|
|
Not verified: a physical device (until the install at the end), TalkBack, landscape.
|
|
|
|
Rejected or deferred, and why:
|
|
- **Editing each row in place** (the TUI's positional editing). The TUI does it with a
|
|
cursor and a keyboard; on glass it means a small target per digit. The input base plus
|
|
a pad does the same job with large keys, and the grid covers per-bit edits.
|
|
- **A separate primary-base display** (the old wireframe's "tap to make primary").
|
|
Folded into the rows: the label is the switch, and the highlight is the state.
|
|
- **History** is not on this screen yet; the committed expression above the answer is
|
|
the only record. The TUI shares one history between modes, which this app's two
|
|
screens do not.
|
|
- **The float view (Ctrl-F), ASCII literals (`'ELF'`) and the system keyboard** are not
|
|
built: none needs the engine to change, and each is easier to place once the screen
|
|
has been used.
|
|
- **Struct layout** (9.4) waits for a C ABI export of the engine's layout (section 3),
|
|
and for the TUI view to settle what an interactive one should do.
|
|
|
|
### 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 Screen
|
|
|
|
**Decided and built, against NCalc's.** NCalc makes a temperature conversion cost four
|
|
steps before a digit: Unit Converter, Temperature, the input unit, then the number - and
|
|
it starts at the converter list every time it opens. The goal here is the opposite: the
|
|
common case costs no navigation at all.
|
|
|
|
```
|
|
+-----------------------------------------+
|
|
| = Convert Calculator |
|
|
| [Search] [Temperature] [Length] [Mass]..| <- search tile that never moves, then
|
|
| [ 37 C ] [180 mi] | EVERY category, most recent first
|
|
| [37] [C v] | <- the value (highlighted = the next
|
|
|-----------------------------------------| digit replaces it); the unit picker
|
|
| C Celsius 37 | <- highlighted in place
|
|
| F Fahrenheit 98.6 | <- every unit of the category, in a
|
|
| K Kelvin 310.15 | FIXED order: tap a row to view from
|
|
| R Rankine 558.27 | it, long-press to copy
|
|
|-----------------------------------------|
|
|
| 7 8 9 <x | <- digits only
|
|
| 4 5 6 CLR |
|
|
| 1 2 3 +/- |
|
|
| 0 . |
|
|
+-----------------------------------------+
|
|
```
|
|
|
|
- **It reopens where it was left,** on the category, value and unit, with the value
|
|
highlighted so the next digit starts a new number.
|
|
- **Each category remembers its own value and unit.** Leave Temperature at `98.6 F` and
|
|
Length at `180 mi` (Seattle to Portland, for telling foreigners), and a tap on either
|
|
lands exactly there. A category never visited starts at 1 of its base unit.
|
|
- **The top row is every category, most recently used first,** scrollable. A tap moves
|
|
that category to the front and the row scrolls back to show it there. A chip shows the
|
|
category and, once visited, the value it will land on - `Temperature` over `37 C` -
|
|
because a value reads as where you left off, where a bare unit would read as the only
|
|
unit on offer.
|
|
- **A search tile leads the row and never moves,** which is what keeps the LRU order
|
|
from feeling like a shuffle: search, or pick from what you used. It searches every unit
|
|
by name, alias or category, focused at once because it was asked for. Before anything
|
|
is typed it lists every category in the row's order: history for a returning user,
|
|
the first-run order (Temperature, Length, Mass, Volume, Speed, then the rest) as
|
|
suggestions for a new one. Picking a unit goes to its category in that unit.
|
|
- **Within a category the order is fixed** - the engine's table order - because with 4 to
|
|
15 units, knowing where a unit sits beats any reordering. The unit on display is
|
|
highlighted where it is, not removed, so a tap moves only the highlight. A recency
|
|
sort here was considered and set aside; it is a small change if fixed order turns out
|
|
to mean hunting.
|
|
- **Direction is one tap.** Tap the C row after typing 98.6 F and the screen views from
|
|
Celsius. Nothing is recomputed - the rows are already the typed value in every unit -
|
|
so viewing from mi and back to km is exact. Typing then edits the number on screen,
|
|
in the unit on screen.
|
|
- **The list moves only when you scroll it.** Each category keeps its own scroll
|
|
position, saved with its value and units and restored without animation, so coming
|
|
back to Length - after another category, or a relaunch - puts the list exactly where
|
|
it was. Choosing a unit, from a row, the dropdown or search, never scrolls: the value
|
|
line already says what was chosen, and someone reading down the column comparing
|
|
values would lose their place. The position is the unit in the top row plus the pixels
|
|
of it scrolled off, anchored by name rather than row number, so a unit the engine adds
|
|
above it does not shift the list onto a different unit; an anchor the engine has
|
|
dropped means the top. Saved when the category is left, the screen goes, or the app
|
|
goes to the background - not on every scroll.
|
|
- **The unit button is a dropdown** of the category's units, opening in place with the
|
|
value still visible: the list is 4 to 15 long, too short to earn a screen of its own,
|
|
which is what the first version gave it. Picking one is a result-row tap. Search stays
|
|
a full screen, because it needs the keyboard and spans every category.
|
|
- **Names come from the engine.** Every unit in `units.zig` carries a required display
|
|
`label` - US spelling, singular, "Meter per second" - and a unit without one does not
|
|
compile. It reaches the app through the catalogue (ABI 4), and the CLI and TUI can
|
|
list the same names. It replaced a Kotlin guess from the aliases, which gave
|
|
"Meterpersecond".
|
|
- **Inexact rows say so** with a leading approx sign: degrees to radians goes through pi.
|
|
- **Fewer digits than the calculator:** ten decimals and twelve significant digits, set
|
|
on the converter's own session.
|
|
- **The rules are plain Kotlin** (`Converter.kt`), unit tested on the JVM; the view model
|
|
only connects them to the engine, the disk and the screen.
|
|
|
|
Tap costs against the previous design: a used category is one tap and lands on its own
|
|
value (it used to be one tap that carried the wrong number across); a specific unit in
|
|
another category is two (category, then row); a never-used category is a scroll and a
|
|
tap, or search.
|
|
|
|
Not yet: currency (needs a network; the app has no permissions), compound units beyond
|
|
the tables, and expressions in the value - the calculator's `abc` route still converts
|
|
`100 km to mi` for that.
|
|
|
|
### 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 file declares what it can fail with, and the sets compose:
|
|
|
|
```zig
|
|
// Parser.zig
|
|
pub const Error = error{
|
|
UnexpectedToken, UnmatchedParen, UnexpectedEnd,
|
|
ExpressionTooLarge, NestingTooDeep, InvalidNumber, UnterminatedString, 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 three, plus whatever the exact path raises
|
|
pub const Error = error{ UnknownUnit, AmbiguousUnit, IncompatibleUnits, OutOfMemory } || number.Error;
|
|
|
|
// financial.zig
|
|
pub const Error = error{
|
|
InsufficientParameters, ConvergenceFailure,
|
|
DomainError, DivisionByZero, OutOfMemory,
|
|
};
|
|
|
|
// struct_layout.zig: what a definition can fail to lay out with
|
|
pub const Error = error{
|
|
EmptyStruct, TooManyMembers, DuplicateMember, TypeUnavailable,
|
|
InvalidArrayLength, MisplacedFlexibleArray, InvalidAlignment, StructTooLarge, OutOfMemory,
|
|
};
|
|
|
|
// CStructParser.zig: what reading C can fail with, sharing the expression parser's
|
|
// names (and so its wording) for the three they have in common
|
|
pub const Error = error{
|
|
UnexpectedToken, UnexpectedEnd, InvalidNumber, UnterminatedComment,
|
|
UnknownType, UnsupportedType, IncompleteType, UnsupportedAttribute,
|
|
BitfieldNotSupported, NestedTypeNotSupported, PreprocessorNotSupported,
|
|
DefinitionTooLong, InvalidArrayLength, InvalidAlignment, TooManyMembers, OutOfMemory,
|
|
};
|
|
|
|
// evaluator.zig: its own, plus every dependency's
|
|
pub const Error = error{
|
|
UnknownFunction, WrongArgumentCount, UnknownVariable,
|
|
AssignmentToConstant, 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 ||
|
|
struct_layout.Error || CStructParser.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 feature that did not
|
|
exist yet, so nothing could return them while `errorPhrase` still gave them wording.
|
|
When the feature was built its errors arrived with it, from the module that raises them,
|
|
and only `StructTooLarge` kept its old name.
|
|
|
|
Error members unify by name in Zig, so the per-file 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 of those sets 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 | `u128` storage for all programmer integers | One representation for every width; `width` masks and interprets rather than deciding storage, which is what makes the width a non-destructive display lens (8.2.1). This said `u64` until 128-bit support landed |
|
|
| 3 | JSON over C ABI boundary | Avoids complex struct marshaling; Kotlin/Swift handle JSON natively; small perf cost acceptable for calculator. Still the plan; not built (section 6) |
|
|
| 4 | A separate parser, for C rather than a DSL | Struct definitions are pasted from C, and the expression tokenizer reads C's numbers wrongly (`010`, `1,000`). Section 3.1 |
|
|
| 5 | ABI as interface/vtable | Adding Windows x64 or ARM is implementing one struct; no changes to layout algorithm. Not built (section 3) |
|
|
| 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. Not built (section 9) |
|
|
| 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. Not built |
|
|
| 12 | Android: live conversion without "Calculate" button | Conversions are instant; eliminating a tap makes the UX feel responsive and direct. Not built |
|
|
| 13 | Display budgets belong to the frontend, not the engine | A terminal, a clipboard and a phone screen want different digit counts, so `Number.render` takes `FormatOptions` and each frontend declares its own (NFR-9.9). The engine held these constants once and the CLI inherited a budget chosen for the TUI |
|
|
| 14 | One list per shared vocabulary, derived where possible | The financial function list, the general function list, the error wording and the mode tab order each exist once and are rendered by whoever needs them. Every one of these was a duplicated list first, and every duplicate had drifted by the time it was found |
|