tally/.kiro/specs/calculator/tasks.md

2365 lines
145 KiB
Markdown

# Tasks: Tally - Cross-Platform Calculator
## References
- #[[file:.kiro/specs/calculator/requirements.md]]
- #[[file:.kiro/specs/calculator/design.md]]
---
## Phase 1: Project Scaffolding & Engine Foundation
### Task 1.1: Initialize workspace build structure [DONE]
- Create `.mise.toml` declaring Zig version (pin to specific release)
- Create top-level `build.zig` with module declarations for engine, cli, tui
- Add Android cross-compilation targets to `build.zig` (`-Dtarget=aarch64-linux-android`, `-Dtarget=x86_64-linux-android`)
- Create `engine/build.zig` producing static lib and shared lib targets
- Create `cli/build.zig` linking engine
- Create `tui/build.zig` linking engine + libvaxis dependency
- Add `.gitignore`, `README.md` (minimal), `LICENSE`
- Verify: `mise install` provisions Zig, `zig build` compiles without errors on empty main files
NOTE: Actual structure diverged from spec - single binary at `src/main.zig`
(CLI + TUI combined), engine as static lib + shared lib. No separate cli/ or
tui/ build files. kcov-based coverage wired in via `build/Coverage.zig`. The Android
cross-compilation targets in the list above were never added; they are Task 6.1's now.
### Task 1.2: Implement core types module [DONE, later dismantled]
- Create `engine/src/types.zig`
- Define `Value` union, `Integer` struct, `BitWidth`, `Signedness`, `Endianness` enums
- Define `MultiBaseResult` struct
- Define `CalcError` error set and `ErrorInfo` struct
- Define `Mode` enum (standard, programmer, financial)
- Verify: compiles, types are importable from other engine modules
SUPERSEDED by Tasks 5.12 and 5.13. `types.zig` no longer exists: a module named after
a language feature collected unrelated things. `Value`, `ErrorInfo` and `Mode` are gone
entirely (unused, never constructed, and stored-but-never-read respectively), `Integer`
and its enums are `Integer.zig`, `Endianness` is `std.builtin.Endian`, and `CalcError`
is replaced by a per-module error set in each module that can fail.
### Task 1.3: Implement tokenizer [DONE]
- Create `engine/src/tokenizer.zig`
- Token types: numbers (dec, hex `0x`, oct `0o`, bin `0b`), operators, parens, identifiers, comma, semicolon, EOF
- Handle implicit multiplication detection (emit synthetic `*` token)
- Handle multi-character operators: `**`, `<<`, `>>`, `>>>`, `rol`, `ror`
- Support `_` and `,` as digit separators in number literals (e.g., `1_000_000`, `1,000,000`)
- Verify: unit tests for tokenizing expressions in all bases, edge cases (negative numbers, adjacent tokens for implicit mult)
NOTE: Implicit multiplication handled in parser rather than tokenizer.
Comma-as-separator only consumed when followed by a digit (avoids eating
function arg commas).
### Task 1.4: Implement Pratt parser [DONE]
- Create `engine/src/ast.zig` with AST node definitions
- Create `engine/src/parser.zig`
- Implement Pratt parser with precedence table from design doc
- Accept `Mode` parameter to resolve `^` ambiguity (power vs XOR)
- Handle function calls: `name(arg1, arg2, ...)`
- Handle variable assignment: `X = expr`
- Handle implicit multiplication
- Verify: unit tests parsing complex expressions, operator precedence, all bases, function calls, mode-dependent `^`
### Task 1.5: Implement standard mode evaluator [DONE]
- Create `engine/src/evaluator.zig`
- Environment struct with variables, history, config (in evaluator.zig, not separate file)
- Evaluate AST to `f64` for standard mode
- Implement built-in functions: sin, cos, tan, asin, acos, atan, log, ln, sqrt, cbrt, abs, ceil, floor, round, factorial
- Implement constants: pi, e, tau
- Implement variable storage and `Ans`
- Implement history recording
- Verify: unit tests for arithmetic, precedence, functions, variables, history recall
### Task 1.6: Implement programmer mode evaluator [DONE]
- Create `engine/src/programmer.zig`
- All operations on `Integer` (u64 storage, masked to bit_width)
- Implement bitwise operators: AND, OR, XOR, NOT, shifts, rotates
- Implement base conversion output (produce `MultiBaseResult`)
- Implement bit_pattern array generation
- Handle overflow/truncation per configured bit_width
- Signed vs unsigned interpretation
- Verify: unit tests for bitwise ops at all widths, base conversions, overflow behavior, sign extension
### Task 1.7: Implement number display formatter [DONE]
- Create `engine/src/formatter.zig`
- Define `FormattedValue` struct with `display` (human-readable) and `raw` (clipboard) strings
- Decimal formatting: comma-separated groups of 3 (`4,294,967,295`)
- Scientific notation only when value > 10^15 or < 10^-15 or > 15 significant digits
- Hex formatting: underscore per 16-bit word for value view (`0xFFFF_FFFF`), space per byte for memory view (`FF FF FF FF`)
- Binary formatting: space per nibble (`1111 1111 1111 1111`)
- Octal formatting: underscore per 3-digit group (`0o37_777_777_777`)
- All formatters produce both `display` and `raw` variants
- Respect bit width (don't show leading zeros beyond the configured width in short-form contexts)
- Verify: unit tests for all bases at all bit widths, edge cases (0, max values, negative signed values), scientific notation threshold
---
## Phase 2: Struct Layout & Financial Engines
### Task 2.0: Exact numeric model (Rational + Number union) [DONE]
Design: see design.md 2.7. Requirements: NFR-9.
Motivation (all of these were reproducible before this task, and are fixed now):
`0.1 + 0.2` = `0.30000000000000004`, `9007199254740993` silently became
`9.007199254740992e15`, `2^53 + 1` collapsed, `12 in in ft` =
`0.9999999999999998`, `factorial(171)` reported "unknown function".
Sequenced as four reviewable commits so structural churn never mixes with
behavioral change (rationale in design.md 2.7.9), plus 2.0e for the unit factors:
#### 2.0a: Add the types, wired to nothing [DONE]
- `engine/src/rational.zig`: `Rational` over `std.math.big.int.Managed`, always
reduced, `q > 0`. add, sub, mul, div, negate, abs, `powInt` (negative exponents
take the exact reciprocal), `sqrtExact` (null for non-perfect-squares so the
caller can fall back), order/eql, `parseDecimal` (exact: `0.1` -> `1/10`),
`isTerminating`, `denBitCount`, `toFloat`, `toFractionString`,
`toDecimalString`. 55 tests.
- `engine/src/number.zig`: `Number = union(enum) { exact: Rational, inexact: f64 }`
with the contagion rule and the `max_denominator_bits = 4096` demotion cap.
30 tests.
- Exposed via `engine.zig` (two lines) so the tests run under `zig build test`.
Nothing else is wired: the evaluator is untouched.
- Notable implementation points:
- `toFloat` scales the numerator to ~72 significant bits before dividing, so
the result is a single correct rounding rather than a quotient of two
separately-rounded floats (which would lose precision twice and overflow for
large values).
- `toDecimalString` does long division, prints terminating expansions exactly,
and rounds half-up at the digit budget while reporting `exact = false`.
- `big.int`'s `sqrt` truncates, so `sqrtExact` squares the root back and
compares to decide exactness.
- Test gotcha worth remembering: Zig folds float literals at comptime as
`comptime_float`, so `0.1 + 0.2 != 0.3` is FALSE at comptime. Comparisons meant
to demonstrate f64 error must use runtime-known values (`var x: f64 = 0.1; _ = &x;`).
- Verify: 487 tests pass (was 402), zig fmt and zlint clean, existing tests
untouched.
#### 2.0b: Evaluator internals become exact, `evalString` still returns f64 [DONE]
- `ast.Expr.Number` gains `text: []const u8` (the literal's source text). Additive,
so the 10 parser assertions on `float_value`/`int_value` keep compiling. The
text is needed because `float_value` has ALREADY rounded by the time it exists:
`0.1` cannot be recovered from its binary approximation, so exact evaluation has
to re-parse the literal.
- `evaluator.evaluate` keeps its `CalcError!f64` signature but now runs on
`Number` internally via `evalExact`, collapsing to f64 once at the boundary.
A scratch arena per evaluation keeps `Number` lifetimes trivial and means the
caller's allocator (an arena in the CLI, a GPA in the TUI) never holds
intermediates.
- Exact: `+ - * / %`, integer powers, `sqrt` of perfect squares, `abs`, `floor`,
`ceil`, `round`, `factorial`, `max`, `min`, and all literals.
- Inexact: transcendentals, fractional powers, `sqrt` of non-squares, `cbrt`,
`atan2`, two-arg `log`, `rand`, and the constants `pi`/`e`/`tau`.
- Fixed-width integer operators (`& | xor << >> >>> rol ror`, `~`) stay on the
float/integer projection: they are not rational arithmetic.
- Added to `rational.zig`: `floor`, `ceil`, `round`, `mod` (the
`a - b*floor(a/b)` definition), and unbounded exact `factorial`.
- Added to `number.zig`: the matching wrappers plus `max`/`min`.
- `rational.Error.ExponentTooLarge` reached the caller as `CalcError.Overflow`. Both
the mapping and the shared set are gone as of Task 5.13: the name now reaches the
user, as "the exponent is too large to compute".
- ALL 489 pre-existing tests pass unchanged. 522 total now (+33).
- Verified through the unchanged f64 API: `0.1 + 0.2` = `0.3`, `1.1 + 2.2` = `3.3`,
`0.1 * 3` = `0.3`, `0.1+0.2+0.3` = `0.6`, `(0.1+0.2)*10-3` = `0`,
`1e20 + 1 - 1e20` = `1` (f64 gives 0), `1/3*3` = `1`, `factorial(171)` no longer
errors. Unchanged: `2 + 3 * 4`, `sqrt(144)`, `sqrt(2)`, `sin(0)`, `e`,
`0o777 - 0x0f`, `10 % 3`, `0xFF & 0x0F`.
- Known limitations deferred to 2.0c/2.0e:
- Variables and `Ans` are still stored as f64 in `Environment`, so
`X = 0.1` round-trips through a float. Correct for `pi`/`e`/`tau` (irrational),
a real limitation for user variables.
- `9007199254740993` and `2^53 + 1` are computed exactly but cannot survive the
f64 return. That is precisely what 2.0c fixes.
- `12 in in ft` is NOT fixed: unit conversion never touches the evaluator and
its factors are already-rounded f64 values. See Task 2.0e.
- Test-oracle bug found by the instrumented coverage build: Zig's float `@mod`
with a NEGATIVE divisor returned `-2` in the normal build and `1` under the
coverage build (comptime folding and the runtime path disagree). A test whose
expected value shifts with optimize mode verifies nothing, so the `mod` test now
asserts the values the definition requires instead of deriving them from `@mod`.
Worth reporting upstream.
- Allocation-failure sweeps added using `std.testing.FailingAllocator`
(`fail_index` = fail after N allocations) over `testing.allocator`, so leaks are
detected rather than the cleanup lines merely being executed. These found three
real bugs unreachable by ordinary tests: a double free (`initOwned` freed values
its callers also freed), a potential dangling copy (parts passed by value while
normalization can reallocate limbs), and struct-literal leaks in `initInt` /
`initZero` / `initRatio` where an `errdefer` cannot cover a value that does not
exist yet. `floor` and `factorial` were also missing a denominator `errdefer`.
See design.md 2.7.10. Uncovered lines in the numeric modules went 25 -> 8.
#### 2.0c: `evalString` and the display path move to `Number` [DONE]
- `Environment` now stores `Number` for variables and `Ans`, so an assignment
keeps its expression's exactness: `X = 0.1` stores exactly one tenth, and
`X + 0.2` is exactly `0.3`.
- `evalString` / `evalStringInfo` / `EvalInfo.value` return `Number`, allocated in
the caller's allocator. Intermediates stay in a scratch arena; only the final
value is copied out.
- Added `Rational.cloneWith` / `Number.cloneWith` to copy across allocators. This
is required, not convenience: a `Rational` carries its allocator inside its
limbs, so a value stored in the environment must be COPIED into an evaluation's
scratch arena, never shared, or one side frees memory the other still uses.
- `getVar` returns a borrowed value and documents that callers must `cloneWith`;
`setVar` and `setAns` copy, so storing an arena-allocated value is safe.
- FIXED A LATENT LIFETIME BUG: `setVar` now duplicates the variable NAME. It
points into the expression source, which the TUI frees on Ctrl-L, so the map
previously retained dangling keys. Covered by a test that frees the source
before reading the variable back.
- `formatter.formatNumber` renders exactly: exact integers print in full with
comma grouping at any magnitude, exact terminating fractions print exactly,
repeating expansions round to `exact_fraction_digits` (20) and are flagged
approximate, and inexact values use the float rules and are always flagged.
- Test churn absorbed in the helpers: `testEval` / `testEvalProgrammer` collapse
to f64 in one place, so all ~90 pre-existing f64 assertions stayed untouched.
Only 4 tests calling `evalString` / `evalStringInfo` directly needed a
`.toFloat(alloc)`.
- Rewrote the tokenizer's "huge decimal" test: its premise (that the value cannot
be exact) is false now that the evaluator re-parses literal text into a
rational. The u64 limit it pins belongs to the tokenizer layer only.
- Clarified rather than deleted `formatFloat`'s `2^53` gate: past that bound an
f64 no longer distinguishes consecutive integers, so printing one as an exact
integer would assert precision it lacks. Exact values bypass it entirely.
(`factorial`'s `x > 170` limit was already lifted in 2.0b.)
- Verified end-to-end: `9007199254740993`, `2^53 + 1`, `2^100`, `2^128`,
`1e20 + 1`, `factorial(25)`, `99999999999999999999999999 + 1` all exact. No
regressions on `2 + 3 * 4`, `10 / 4`, `0.1 + 0.2`, `sqrt(2)`, `pi`, `sin(0)`,
`1,000 * 2.3`, `10 % 3`, `1/0`, conversions, or the multi-base view.
- 564 tests pass (was 533). Coverage 99.56%.
REQUIREMENTS CONFLICT FOUND, RESOLVED IN NFR-7: the existing rule "scientific
notation when |value| > 10^15" directly contradicts NFR-9.1. `9007199254740993`
is ~9.007e15, so the old rule renders it as `9.007199254740993e15`, exactly the
bug NFR-9.1 forbids. That bound was never a readability rule; it is f64's
integer-precision cliff (2^53 ~ 9.007e15), which does not apply to exact values.
NFR-7 now splits the rule by tier: inexact values keep 10^15, exact values get a
readability cap on integer digits (`max_display_integer_digits`) above which the
DISPLAY abbreviates to scientific notation while the clipboard/`raw` form keeps
every digit. Without a cap, `factorial(171)` printed 310 digits and
`1.5e300 * 10` printed 301.
THE CAP IS PROVISIONALLY 40 (smallest round number above 2^128) AND NEEDS REVIEW:
it is not derived from any measured preference.
#### 2.0d: Exactness tests only the `Number` API can express [DONE, folded into 2.0c]
- `9007199254740993` round-trips (currently unguarded, and the one bug the f64
boundary cannot fix), `2^53 + 1`, `1/3` retained exactly, `(1/3) * 3` = 1,
unbounded `factorial`, contagion, demotion at the denominator cap.
Out of scope: arbitrary-precision transcendentals (the inexact tier is `f64`
because that is the precision we can justify; see design.md 2.7.8 for why f128 is
NOT the upgrade path), and any change to programmer mode's `u128` semantics or the
IEEE 754 float view.
### Task 2.0e: Exact unit conversion factors [DONE]
Discovered while implementing 2.0b: making the evaluator exact did NOT fix
`12 in in ft` = `0.9999999999999998`, because unit conversion never goes through
the evaluator. `UnitDef.to_base_factor` was an `f64`, so `0.0254` was already the
binary approximation before `convertUnits` did its own multiply and divide.
- Factors and offsets are now written as EXACT TEXT and the f64 fields are
DERIVED from them at comptime by `define`. One source of truth per unit, so the
exact and approximate forms cannot drift apart the way two hand-written fields
would. (`@setEvalBranchQuota` is needed: `parseFloat` is loop-heavy at comptime.)
- `Rational.parse` accepts `p/q` as well as decimals, because several exact
factors have no terminating decimal form: Fahrenheit scales by exactly 5/9, a
knot is exactly 1852/3600 m/s, a torr exactly 101325/760 Pa.
- `convertExactUnits(allocator, value: Number, from, to) -> Number` does the
conversion in the exact tier, falling back to floats when either unit is
defined via pi (the angle units and the parsec) or when the value is already
inexact. The result's `exact` tag therefore reports whether the conversion was
lossless.
- Verified: `12 in = 1 ft`, `98.6 F = 37 C`, `3.6 km/h = 1 m/s`,
`1 kn = 1.852 km/h`, `1 acre = 43,560 ft2`, `1 atm = 760 torr`,
`1 GiB = 1,024 MiB`, `-40 C = -40 F`, `0 C = 273.15 K`.
- Non-terminating conversions stay exact as VALUES: `100 km to mi` is exactly
`781250/12573`, rendered as a rounded decimal and flagged approximate, with the
exact fraction available.
- The invariant test is much stronger now: every unit pair within a category
round-trips EXACTLY rather than within a tolerance, which catches a mistyped
table entry that a tolerance would hide.
- CLI and TUI (both the standard-mode `to`/`in` path and convert mode) all use
the exact path. `App.conv_value` became a `Number` so the live convert view
matches the CLI.
- ALSO FIXED, found by smoke-testing the whole matrix: the `convert` subcommand
only accepted `to` as a separator, so `convert 1 acre in ft2` and
`convert 98.6F in C` failed. It now handles both separators with the same
backtracking approach as `units.parseRequest`, validating candidate readings by
resolving the unit names. This also required whole-token numeric validation
(`isFullyNumeric`): the old first-character check accepted `98.6F` as a bare
value, which then failed in the number parser instead of being retried as a
glued value+unit.
- 596 tests pass (was 564). Coverage 99.40%.
### Task 2.1: Implement struct DSL tokenizer and parser
- Create `engine/src/struct_layout.zig`
- Tokenize: `struct`, `{`, `}`, `;`, type names, identifiers, `[`, `]`, numbers (for arrays)
- Parse into a list of `FieldDef` structs: `{ name, type, array_count? }`
- Support types: u8, u16, u32, u64, i8, i16, i32, i64, f32, f64, bool, ptr
- Verify: unit tests parsing valid structs, error messages for malformed input
### Task 2.2: Implement ABI profile interface and x86-64 System V profile
- Define `AbiProfile` struct with function pointers/vtable (field_layout, struct_alignment, tail_padding, pointer_size)
- Implement `x86_64_sysv` profile:
- u8/i8/bool -> size 1, align 1
- u16/i16 -> size 2, align 2
- u32/i32/f32 -> size 4, align 4
- u64/i64/f64/ptr -> size 8, align 8
- Struct alignment = max of field alignments
- Tail padding to struct alignment boundary
- Implement `packed` profile (all alignment = 1, no padding)
- Verify: unit tests matching `pahole` output for known structs
### Task 2.3: Implement layout computation
- Implement `compute_layout(fields, abi) -> StructLayoutResult`
- Generate per-field: offset, size, alignment, padding_before
- Generate byte-level map (`[]ByteInfo`) for visualization
- Compute totals: size, alignment, total padding bytes, padding percentage
- Compute "packed comparison" size
- Verify: unit tests with structs of varying complexity, compare against known C compiler layouts
### Task 2.4: Implement endianness display
- Given a `StructLayoutResult` and a set of field values (optional), show byte ordering
- Little-endian (default): LSB at lowest address
- Big-endian: MSB at lowest address
- This affects the byte-map visualization labels, not the layout computation itself
- Verify: unit test showing a u32 value's bytes in both endiannesses
### Task 2.5: Implement financial module [DONE]
- Created `engine/src/financial.zig`
- CAGR: `(end/start)^(1/n) - 1`
- Compound interest (future value): `PV * (1 + r/m)^(m*t)`; present value as its inverse
- TVM solver over `TvmParams`/`TvmSolution`: exactly one of the five variables is
null and gets solved for
- Closed-form for FV, PV, PMT; logarithmic for N
- Newton for I/Y, max 1000 iterations, tolerance 1e-10
- `TvmVariable.label()` gives the calculator names (N, I/Y, PV, PMT, FV)
- Amortization (FR-5.6): `amortizationPayment`, `amortizationEntry`,
`amortizationSchedule`, `amortizationTotals`, sharing one cursor so a single row
cannot disagree with the same row in a table. Final period settles the balance
exactly; overlarge payments end early; underfunded terms balloon
- Money rounding: `roundToScale(value, decimals, mode)` and `roundToCents`,
half-even by default
- Reachable from standard-mode expressions (FR-5.7): `evalFunction` in
`evaluator.zig` gained 3- and 4-argument dispatch, and `evalFinancialFn` maps
the names `cagr`, `fv`, `pv`, `tvm_pmt`/`tvm_fv`/`tvm_pv`/`tvm_n`/`tvm_rate`,
`amort_payment`, `amort_interest`/`_principal`/`_balance`,
`amort_total_interest`/`_total_paid`
- CLI `tally amort <principal> <rate> <periods> [payment]` with `--monthly`,
`--summary`, `--exact` (FR-6.11), since a table is not an expression value
- DESIGN CHANGE from the original spec: f64 rather than the exact `Number` tier.
Every formula needs a non-integer power or a logarithm, so nothing exact
survives; money is handled by explicit rounding instead (design.md 5.6)
- DEFERRED from the original spec: the step-by-step formula string. The
expression-function route made the formula visible in the input itself, and the
breakdown belongs with the TUI financial form (Task 5.6)
- 60 financial tests plus 12 evaluator tests and 10 CLI tests; financial.zig is
100% covered apart from an unreachable test-sweep guard
### Task 2.6: Implement unit conversion engine [DONE]
- Created `engine/src/units.zig`
- `UnitCategory` enum (12 categories) with `label()` and `baseUnit()`
- `UnitDef` struct (name, aliases, category, to_base_factor, to_base_offset) with
`toBase()`, `fromBase()`, `isLinear()`
- Conversion tables for all 12 categories: length, mass, temperature, time,
digital storage, speed, area, volume, energy, pressure, data rate, angle
- `convert(value, from_name, to_name) -> ConvertResult` and
`convertUnits(value, from, to) -> f64`
- Temperature is the only affine category (F/K/R need offsets); base is Celsius
- `findUnit`: exact case-sensitive match first, case-insensitive fallback, so
`K`/`B`/`kB` keep their meaning while `KM` and `Celsius` still resolve
- `parseRequest`: recognizes free-form `<value> <unit> to <unit>` text so
conversion is plain expression input, not a special command (see Task 4.4).
Returns null with no `to` keyword so callers fall through to normal evaluation.
- DESIGN CHANGE from the original spec: only the to-base mapping is stored and
the inverse is derived in `fromBase()`, instead of storing a second
factor/offset pair. One source of truth per unit means a table entry cannot
disagree with its own reverse (see design.md 4.1)
- 100 unit tests: known-answer conversions per category, temperature offsets and
the -40 crossover, alias resolution, error paths, request parsing (glued
values, expression values, longest-suffix and token-boundary rules), plus
invariant tests (every unit round-trips, every within-category pair
round-trips, base units are identity, names unique, no zero factors)
- Verify: `zig build test` passes; CLI spot-checks match reference values
(5280 ft/mi, 1024 MiB/GiB, 14.6959 psi/atm, 1 c = 1079252848.8 km/h)
---
## Phase 3: Engine Public API & C ABI
### Task 3.1: Implement engine public API
- Create `engine/src/engine.zig`
- `pub fn evaluate(env: *Environment, input: []const u8) !Value`
- `pub fn computeStructLayout(definition: []const u8, abi: AbiProfile, endian: Endianness) !StructLayoutResult`
- `pub fn computeCagr(start: f64, end: f64, periods: f64) !f64`
- `pub fn solveTvm(params: TvmParams) !TvmResult`
- `pub fn convert(value: f64, from_unit: []const u8, to_unit: []const u8) !ConvertResult`
- `pub fn formatMultiBase(integer: Integer) MultiBaseResult`
- Verify: integration tests exercising the full pipeline (string in -> result out)
### Task 3.2: Implement C API
- Create `engine/src/c_api.zig`
- Define `CalcString` (ptr + len) and `CalcResult` (json ptr/len + error ptr/len) extern structs
- `export fn tally_eval(expr_ptr, expr_len, mode, config_ptr, config_len) CalcResult`
- `export fn tally_struct_layout(def_ptr, def_len, abi_ptr, abi_len, endian) CalcResult`
- `export fn tally_convert(value, from_ptr, from_len, to_ptr, to_len) CalcResult`
- `export fn tally_result_free(result) void`
- `export fn tally_version(out_len) [*]const u8`
- **No null-terminated strings** - all inputs are (ptr, len) pairs
- JSON serialization for all result types
- Input length validation (reject > max expression length)
- Verify: build shared library, write a C test program that calls the API with known inputs and validates JSON output
### Task 3.3: Cross-compilation verification
- Verify `zig build` produces binaries for:
- `x86_64-linux-gnu`
- `aarch64-linux-gnu`
- `x86_64-macos`
- `aarch64-macos`
- `x86_64-windows-gnu`
- Verify `zig build` produces .so for Android targets:
- `aarch64-linux-android`
- `x86_64-linux-android`
- All targets invocable from the same `build.zig` via `-Dtarget=<triple>`
- Verify mise-provisioned Zig handles all targets without additional toolchain installs
- Document any target-specific issues or workarounds needed
---
## Phase 4: CLI Frontend
### Task 4.1: Implement CLI argument parsing [DONE]
- Single binary at `src/main.zig` (not separate cli/)
- Parse args: positional expression (multi-arg joined with spaces), `-p`/`--programmer`, `--help`, `--version`
- Subcommand `convert` IMPLEMENTED: `tally convert <value> <from> [to] <to>`,
including the glued form `tally convert 100km to mi` (numeric prefix split from
the unit suffix, with `e`/`E` only treated as an exponent when followed by a
digit or sign so `5eV` parses as 5 eV)
- Subcommands (`struct`, `cagr`, `tvm`) NOT YET IMPLEMENTED
- Verify: unit tests for arg parsing; `--help` prints usage
NOTE: Multi-arg joining implemented (`tally 2 + 2` works same as `tally "2+2"`).
Remaining subcommands deferred until their engine modules exist.
### Task 4.2: Implement CLI output formatting [DONE]
- Standard mode: print result with comma formatting
- Programmer mode: print multi-base table (dec signed, dec unsigned, hex, bin)
- Convert: `formatConversion` prints `100 km = 62.13711922373339 mi`, resolving
aliases to canonical names
- Struct mode: NOT YET IMPLEMENTED
- Financial mode: NOT YET IMPLEMENTED
- `--json` flag: NOT YET IMPLEMENTED
- Verify: unit tests for evaluate and formatConversion output
### Task 4.3: CLI integration and error handling [DONE]
- Wire arg parser -> engine calls -> output formatter
- Map `CalcError` to user-friendly stderr messages
- Set exit codes: 0 success, 1 error
- Verify: unit tests for error paths
### Task 4.4: Bare-expression unit conversion [DONE]
- `tally 32F in C` and `tally 100 km to mi` work without any subcommand; the
`convert` subcommand remains as an explicit alias for scripting
- Both `to` and `in` act as separators
- Implemented in the engine (`units.parseRequest`) rather than the CLI, so the TUI
standard mode gets the same behavior: typing `100 km to mi` at the prompt
converts and records it in history
- Value may be a full expression (`2*3 kg to lb`), evaluated by the normal
evaluator before conversion
- `in` doubles as the name for inches, so candidate separators are tried
last-to-first and the first valid reading wins: `5 in in cm`, `100 mm in in`,
`1 in in in`, and `60 min in h` all resolve correctly
- Detection is conservative: only a standalone whitespace-delimited separator
triggers it, unit suffixes must start at a token boundary, and no separator
means normal evaluation. Verified no regression on `2 + 3 * 4`, `sqrt(144)`,
`e`, `min(3, 7)`, `sin(0)`, `0o777 - 0x0f`
- 30 request-parsing tests plus 7 CLI-level tests
- Verify: bare conversions, expression values, inch ambiguity, error paths, and
unchanged behavior for ordinary expressions all confirmed by running the binary
### Task 4.5: Financial functions in expressions + amort subcommand [DONE]
- Financial math is reachable from any expression rather than only a mode
(FR-5.7), the same way conversion is: `tally 'cagr(10000, 25000, 5) * 100'`
- SUPERSEDES the originally planned `tally cagr` and `tally tvm` subcommands
(FR-6.6, FR-6.7). A subcommand per formula buys nothing once the functions
compose inside expressions, and flag-per-variable parsing is more to type than
`tvm_pmt(360, 0.5, 200000, 0)`
- `tally amort` (aliased `amortize`) is the exception, because a schedule is a
table rather than a value: `--monthly`, `--summary`, `--exact` flags, money
formatted with thousands separators and two decimals
- Domain errors from loan terms get a message that names the likely cause instead
of a bare "domain error"
- Help text lists the financial functions and both subcommands
- 10 CLI tests (arg parsing, flags, money formatting, table shape, error path)
- Verify: ran `tally amort 200000 6 360 --monthly` and confirmed the first row
(1,199.10 / 1,000.00 / 199.10 / 199,800.90), the balloon and early-payoff
variants, the zero-rate case, and a total interest of 231,677.04 against an
independent calculation
---
## Phase 5: TUI Frontend
### Task 5.1: Set up TUI skeleton [DONE]
- Single binary at `src/main.zig` + `src/tui.zig` (not separate tui/)
- libvaxis vxfw.App with custom Widget (eventHandler + drawFn)
- No args launches TUI; with args runs CLI
- Molokai color theme
- Verify: TUI launches, responds to Ctrl-C (quit)
### Task 5.2: Implement standard mode TUI view [DONE]
- Expression input via vxfw.TextField (gap buffer)
- Result display in scrollable history panel
- History navigation with up/down arrows (saves/restores current input)
- Ctrl-L clears history
- ? shows help overlay (keybindings, functions, constants, operators)
- Variables persist across expressions (assignment + Ans)
- Verify: can type expressions, see results, browse history
### Task 5.2.1: Inline multi-base result in standard mode [DONE]
- Engine: added `evalStringInfo` returning value + `has_nondecimal_literal` flag
(walks AST via `hasNonDecimalLiteral` to detect any hex/oct/bin number literal)
- Added `BitWidth.smallestFor(value)` helper
- CLI: when flag is true and result is a non-negative integer, shows hex/oct/bin below decimal
- TUI: history entries carry optional `details` lines; drawHistory reworked to
render variable-height entries (flat line list, newest anchored at bottom)
- Uses smallest standard bit width that holds the value
- Does NOT change evaluation (still f64, `^` still power) - display only
- Fractional/negative results show decimal only
- Verify: `0o777 - 0x0f` shows 496 plus hex/oct/bin; `3.14` decimal only;
`2 + 2` decimal only; `0xFF / 2` = 127.5 decimal only
### Task 5.3: Implement programmer mode TUI view - expression & base display [DONE]
- Programmer mode accessible via Tab key
- Multi-base result display (dec signed, unsigned, hex, oct, bin, ASCII)
- Bit grid with navigation (arrows) and toggling (Space)
- Width toggle (Ctrl-W cycles 8->16->32->64->128)
- Backtick toggles focus between input and value zone (replaced the original
Ctrl-B plan)
- Expression evaluation through programmer engine
- Editable fields delivered by Task 5.3.1
### Task 5.3.1: Programmer mode editable fields rework [DONE]
- Replaced `bit_grid_active: bool` with `prog_field: ProgField` enum tracking focused field
- Up/Down arrows navigate between fields (bits, dec_signed, dec_unsigned, hex, oct, bin, expression)
- When a text field is focused, the input line shows the field label as prompt (e.g. "HEX> ")
- Enter in a non-expression field parses input as that base and updates prog_value
- Bit grid: left/right navigates bits, space toggles, up/down exits to adjacent field when at grid edge
- Focused field is visually highlighted (label gets inverse color)
- Removed Ctrl-B (field navigation replaces it)
- Expression field works as before (full programmer-mode expression evaluation)
- ASCII row under HEX (read-only, xxd-style, aligned column-for-column)
- Endianness toggle (Ctrl-E): flips HEX and ASCII byte order, header shows LE/BE.
Default big-endian so HEX reads as the number (matching DEC/OCT/BIN); LE is the
opt-in x86-64 memory-layout view. DEC/OCT/BIN and the hex `raw`/clipboard form
are unaffected. Covered by formatter unit tests (BE and LE for hex and ascii).
- Verify: can arrow between fields, type hex value and see all fields update, toggle bits
### Task 5.3.2: Implement text/ASCII literal parsing [DONE]
- Added single-quote string token to the tokenizer (`'hello'`)
- Parser recognizes string literals as a new AST node type
- Programmer evaluator packs ASCII bytes into u128 value (BE packing: first char -> MSB)
- Standard evaluator also supports it (returns f64)
- Enforces max length = bit_width / 8 (Overflow error if too many chars)
- Rejects non-ASCII characters (> 0x7F)
- 128-bit width also added (u64 -> u128 throughout engine)
- Verify: `'A'` = 0x41, `'ELF'` = 0x454C46, overflow on too-long strings
### Task 5.3.3: Implement IEEE 754 float interpretation [DONE]
- Engine module `engine/src/float_interp.zig`:
- `FloatFormat` (f32/f64) with total/exponent/significand bit counts + bias
- `decompose(format, bits)` -> `FloatInfo` (sign, raw+unbiased exponent,
significand, class, value, ULP)
- Classifies normal, denormal, zero, infinity, quiet NaN, signaling NaN
- `reinterpretValue` reinterprets low bits as the float value (exact f32->f64)
- `nearestBits(format, value)` finds the closest representable pattern and
reports whether rounding occurred (detectable for f32)
- ULP computed from the exponent (2^(unbiased - significand_bits); subnormals
step by the smallest positive subnormal); NaN for inf/NaN
- 21 unit tests: 0x3F800000=1.0, special values, denormals, ULP, decimal
rounding (3.14 rounds in f32), overflow to inf, NaN handling, round trips
- TUI overlay (`src/tui/float_view.zig`, toggled with Ctrl-F in programmer mode):
- Color-coded bit grid: sign=pink, exponent=cyan, significand=green
- Shows value, classification, formula ((-1)^s x 2^e x mantissa), ULP, and
raw fields (sign, biased/unbiased exponent + bias, significand hex)
- Ctrl-W toggles f32/f64 and auto-snaps the bit width (32/64)
- Typing a decimal float (e.g. `3.14`) stores its nearest bit pattern and
notes rounding; non-float input falls back to the integer expression engine
- Auto-entry: Tab into programmer mode with a non-integer (or inf/NaN) `ans`
opens the float view on the exact f64 bit pattern; integer `ans` uses the
integer views
- Bit toggling in the grid updates the interpretation live
- Verify: `0x3F800000` shows 1.0 (f32), special values classify correctly,
`3.14` shows nearest representation with rounding note
### Task 5.4: Implement programmer mode TUI view - bit grid [DONE - merged into 5.3]
- Bit grid widget: 8 bits per group, groups separated by space
- Arrow key navigation (left/right moves cursor across bits, up/down between rows for 32/64-bit)
- Space/Enter toggles the bit under cursor
- Bit changes immediately reflected in all base displays
- Typing a value in the expression input updates the grid
- Toggling bits updates the expression/result displays
- Verify: can navigate grid, toggle bits, see values change in real-time
### Task 5.5: Implement struct visualizer TUI view
- Create `tui/src/views/struct_viz.zig`
- Text input for struct definition (multi-line mini editor or single-line with parsing)
- Live-updating field table (offset, size, align, padding)
- Memory map visualization (byte grid with color-coded fields and padding)
- ABI toggle (`a` key), endian toggle (`e` key)
- "Packed comparison" shown at bottom
- Transition: from programmer mode, press a key (e.g., `S`) to enter struct sub-view, Esc to return
- Verify: can enter struct definition, see layout update, toggle ABI/endian
### Task 5.6: Implement financial mode TUI view [DONE]
- Created `src/tui/financial.zig`; financial is now a top-level mode and Tab
cycles Standard -> Programmer -> Financial -> Convert
- Four forms behind one chip row: CAGR, Compound, TVM, Amortization, with a sixth
END/BGN row on TVM for annuity due
- TVM and compound solve for whichever field is left blank, matching the engine's
`TvmParams` convention; a blank solve-for field renders `[solve]` and an
unanswerable form says what it is waiting for instead of doing nothing
- Amortization shows payment and totals plus a scrollable schedule (FR-7.5):
PgUp/PgDn from either zone and the mouse wheel. The scroll offset is clamped at
draw time, so a resize or a changed period count cannot leave a stale offset
showing an empty table
- Two entry routes: digits typed in the form zone go into the focused field, and
the shared input line evaluates an expression into it (`30 * 12` -> 360), which
keeps the full expression language available inside a field
- Results are live, so financial mode is the one mode that appends nothing to
history
- Formula breakdown under the result: the general relationship always, plus a
substituted line for CAGR and compound where it fits on one row
- State and solving live in `financial.zig` as a pure `State` struct, so the
behavior is testable without a terminal; `App` embeds one and forwards keys
- DEFERRED from the original spec: nothing. The step-by-step breakdown deferred
from Task 2.5 is included here
- 43 form-logic tests, 12 rendered-frame tests (a real surface is drawn and the
cells read back, which is what catches a layout that writes nothing or runs off
the bottom), and 11 wiring tests through the actual key and mouse handlers
- Verify: rendered frames inspected for all four forms at 80x24, plus narrow (34
columns) and short (10 rows) terminals; confirmed the first mortgage row reads
1,199.10 / 1,000.00 / 199.10 / 199,800.90 and totals 231,677.04
### Task 5.6.1: Fix AST ownership leaks found while testing 5.6 [DONE]
- The financial-mode wiring tests run on `testing.allocator` rather than an arena,
which exposed two pre-existing leaks the whole suite had been hiding:
- `evalStringInfo` and `evalProgrammerString` never freed the parsed tree at
all, on any path. Every evaluated expression leaked its whole AST. Invisible
until now because the CLI and every existing test pass in an arena
- the parser leaked partial trees on every error path (a parser test comment
even said an arena was needed "so error paths don't leak")
- Fixed by giving the parser proper cleanup (`errdefer freeExpr` at each site
where a child is parsed before a step that can fail, and explicit frees on the
trailing-token and unmatched-paren paths) and having both eval entry points
release the tree they own. `freeExpr` is now public, since callers need it
- This mattered most in the TUI, which is the only long-running caller: every
expression and every typo grew the arena for the life of the session
- 4 regression tests on `testing.allocator`: 16 malformed inputs, programmer-mode
malformed inputs, successful parses freed exactly once, and 200 repeated
evaluations
### Task 5.6.2: Financial mode UX refinements and frontend coverage [DONE]
- Shift-Tab walks back through the modes (FR-7.1); `nextMode`/`prevMode` are one
ordering used by both directions, tested as inverses
- Form fields group digits as they are typed (FR-7.5.1): `200` then `0` shows
`2,000`. Display-only, reusing the engine formatter's grouping helpers (now
public) rather than a third copy of the logic; the buffer stays ungrouped so it
still parses, and a typed comma is absorbed
- Form fields accept expressions (FR-7.5.2): `12 * 30` shows verbatim with an
`(Enter)` prompt and Enter evaluates it in place. An unevaluated field is
reported as such rather than treated as a blank, which in TVM and compound means
"solve for this"
- Rate fields show a trailing `%` and absorb a typed one (FR-7.5.3)
- Focus styling changed from reversed cyan to a dark selection fill with a yellow
block cursor; the mode's own yellow is reused for the row marker and label
instead of introducing another accent
- Help overlay rewritten as a scrollable line list (FR-7.6.1). The previous
per-section height gating silently dropped the Convert section once Financial
was added above it, which a render test caught
- Coverage now measured for the CLI and TUI, not just the engine (NFR-5): three
reports with disjoint include paths. The coverage helper takes include paths
instead of hardcoding `engine/src`, resolved to absolute paths because the
substring form matched any home directory containing `/src/`
- TUI coverage went from unmeasured (the view files had literally zero
instrumented lines) to 98.91%, via the render harness in
`src/tui/test_render.zig` and event tests through the real handlers
- Verify: 811 tests, engine 99.30%, CLI 95.74%, TUI 98.91%; frames inspected for
every mode at sizes from 20x6 to 200x60
### Task 5.6.3: Solve compound interest for rate and years [DONE]
- `financial.compoundRate` and `financial.compoundPeriods`: the compound
relationship solved for its remaining two variables, both closed form, no solver
- `financial.effectiveAnnualRate` (APY), because a solved rate is NOMINAL and
reporting it alone invites comparison against a quoted effective rate. The form
shows both whenever compounds/year > 1; at annual compounding they coincide and
only one figure appears
- Compounds/year is deliberately not solvable (FR-5.2.1): a solution exists only
between annual and continuous compounding, and a fractional frequency is
meaningless. It is prefilled with `1` instead (FR-7.5.4), which is what keeps
"blank means solve" true for every field the user sees. The prefill is baked into
the comptime initial state, since a draw-time default could not distinguish
"never touched" from "deliberately cleared"
- Expression parity (FR-5.7): `compound_rate`, `compound_years`, and
`apy(nominal, per_year)` in 3- and 4-argument forms, so the form cannot solve
something the expression language cannot
- Solving for rate at annual compounding IS CAGR; both entry points are kept (CAGR
takes periods, not years) and a test asserts they agree so they cannot drift
- 19 engine tests including a four-way round trip through all four variables, 9
form tests, 3 evaluator tests, 3 rendered-frame tests
- Verify: `tally 'compound_rate(10000, 25000, 5)'` gives 20.11244339814313, equal
to `cagr(10000, 25000, 5) * 100`; `apy(18, 12)` gives 19.5618
### Task 5.7: Implement unit conversion TUI view [DONE]
- Created `src/tui/convert.zig`; convert is now a third top-level mode and Tab
cycles Standard -> Programmer -> Convert
- Category chips wrap to the terminal width, selected chip highlighted
- From/To unit columns list every unit in the category with the selection marked
- Live result plus a conversion-factor line (or an "affine conversion" note for
temperature, where no single factor applies)
- Value comes from the shared input line; a bare number is used directly and
anything else is evaluated as a standard expression first (so `2*3.5` works)
- Backtick toggles input/selection zone (same convention as programmer mode);
arrows navigate columns and selections with wrapping; Ctrl-S swaps units
- Changing category resets the unit pair to that category's defaults
- Graceful degradation: if the terminal is too short, shows "... N more"
- Fully mouse-clickable (see Task 5.9)
- Verify: category switching filters units, selection updates the live result,
swap works, conversions match the CLI
### Task 5.9: TUI mouse support [DONE]
- Requirement documented as FR-7.11 (mouse is first-class, with keyboard parity
still required by FR-7.7); architecture in design.md 8.0
- `RegionSet` in `src/tui.zig`: fixed 512-entry table of `{row, col, len, action}`
rebuilt every frame during drawing, so hit targets cannot drift out of sync
with what is displayed. Newest-registered region wins, letting views layer a
row-wide fallback under finer per-digit targets.
- `Action` union covers mode tabs, programmer field focus (with optional bit
cursor), bit toggling, input focus, help dismissal, float overlay + format,
width/endian/signedness toggles, and all convert-mode selections
- Only left button press is acted on, so one click fires exactly one action
- Wired into every view: mode tabs, standard mode input, programmer mode (bit
grid, HEX/OCT digit cursor placement, BIN bit flipping, DEC row focus,
Bits/Signed/Endian labels), float view (bits, format/exit hints), convert mode
(category chips, both unit columns), and the help overlay
- Click semantics follow the data: a bit grid cell and a BIN digit are one bit so
clicking flips them; a HEX nibble or OCT digit spans several bits with no single
meaningful toggle, so clicking places the cursor for typing
- Extracted shared state helpers (`setMode`, `cycleBitWidth`, `toggleEndian`,
`toggleSignedness`, `toggleFloatFormat`, `setConvCategory`, `swapConvUnits`) so
keyboard and mouse paths cannot diverge
- `build.zig`: added a `tui_tests` target rooted at `src/tui.zig`. The CLI test
target roots at `src/main.zig`, which only reaches `tui.zig` from `main()`, and
`main()` is never analyzed in test mode, so TUI tests were being silently
skipped
- 12 unit tests for the pure logic: region hit/miss boundaries, newest-wins
layering, clear, zero-length rejection, budget exhaustion, action payloads,
`wrapIndex` wrapping/clamping, and `defaultUnitIndices` invariants
- Verify: `zig build test` runs the TUI tests (366 total); interactive clicking
needs a manual pass since the overlay is terminal-only
### Task 5.8: TUI polish and keybindings [PARTIAL]
- DONE: `?` help overlay showing keybindings, mouse actions, functions, operators
- DONE: mouse support (see Task 5.9): clicking tabs, bits, digits, config labels,
convert categories/units, and the input line
- DONE: Molokai color theme
- NOT DONE: vi-style input mode (optional: Esc for normal mode, i for insert)
- NOT DONE: mouse wheel scrolling for history
- Verify: help overlay works, mouse interactions work, looks reasonable in 80x24 terminal
### Task 5.13: One error set per module, not one for the engine
Found by asking the same question of `errors.zig` that killed `types.zig`. The file
was a defensible home; the type inside it was not. `CalcError` had 20 members and
every engine function returned it, so `parser.parse` claimed it might return
`ConvergenceFailure`, `UnknownUnit` and `StructTooLarge`, and no caller could switch
on what a parse actually produces.
- Each module now declares what it can fail with: `parser.Error`, `bitwise.Error`,
`units.Error`, `financial.Error`, `evaluator.Error`, `programmer.Error`, with
`rational.Error` (inherited by `number.zig`) already in place.
- The unions are `||` expressions, not lists: `evaluator.Error` is its own four members
plus its dependencies', and `message.Error` is the union of the entry points. The
compiler maintains them, so adding an error to one module propagates with no edit.
- Verified against the compiler before relying on it: error members unify by name
across sets, a set can be derived from a function's return type via `@typeInfo`, and
recursion with an inferred error set resolves (so `evalExact` needed no annotation).
- `number.toCalcError` is gone, and with it `units.mapNumberError` and
`evaluator.mapError`, which were aliases of it. Those call sites are plain `try`.
- Four members went away rather than staying unused: `InvalidType`,
`InvalidFieldName`, `DuplicateFieldName` and `StructTooLarge` belonged to the struct
layout module Phase 4 has not written, and `errorPhrase` was giving all four wording
nothing could produce.
- `errors.zig` is gone. The wording lives in `engine.zig`, next to the `Error` union
it words, and it stays in the engine rather than a frontend because the Android app
will receive these strings across the C ABI. It briefly lived in a `message.zig` of
its own; that file was folded in during review, since nothing in the engine calls it
and a separate file for one table did not pay for itself. `phrase`'s switch has no
`else` (a new error fails to compile until worded), and it cannot word an error the
engine is incapable of returning, because a prong naming one is a type error against
`Error`. That second property is the direction the hand-written set got wrong, and it
needs no assertion: a comptime block asserting it was written, found to be redundant
when tested against the compiler, and removed.
Two user-visible improvements fell out, because the translation being deleted was
flattening the better names: `sqrt(-1)` now says "square root of a negative number"
instead of "domain error", and `2^3000000` says "the exponent is too large to compute"
instead of "overflow". Two tests updated to match.
- Verify: 944 tests pass, fmt and zlint clean, CLI checked across parse, name,
arithmetic, unit and financial errors. `engine.zig` at 95.6% (the two uncovered lines
are a diagnostic branch inside a passing test).
### Task 5.16: Render through a writer, not into a buffer
`Integer`'s renderers took a caller's buffer and indexed it directly, returning a
`display`/`raw` pair that lived at two offsets inside it. That is not how Zig formats
values, and the buffer had no bounds check: too small a buffer walked off the end.
- A value now produces a formatter: `int.fmt(.hex, .{ .endian = .little })` returns a
`Format` with a `format` method, so callers print it (`w.print("hex: {f}\n", .{...})`).
`Integer` also has a primary `format`, so `{f}` on the value itself renders decimal.
The notation is an enum and the decoration is `FormatOptions{ separators, prefix,
endian }`, with `FormatOptions.plain` as the no-separators, prefixed form.
- `Format.render(buf)` exists for callers that need a slice, such as the TUI, which
measures and positions text before drawing. It is `Writer.fixed` underneath, so a
short buffer is `error.WriteFailed`.
- `std.Io.Writer.printInt` replaced the hand-written digit loops for the prefixed
forms, including the zero padding (`.{ .fill = '0', .width = n }`).
- `digits.zig` became `grouping.zig` and holds only the thousands rule, which is the
one thing both display paths share. Its digit writers went back to `formatter.zig`
as private helpers, which is where they were before; the module had been two thirds
formatter's private helpers travelling with their siblings.
- `formatFloat` now writes through fixed writers too, so its small-buffer behaviour is
explicit and tested rather than a length precheck.
Both frontends got shorter: the CLI prints five rows in one `print` call with no
per-base buffers, and the programmer view asks the value for each row.
### Task 5.17: Delete formatter.zig; a value renders itself at the caller's budget
The other half of 5.16, and the answer to "why does `formatter.zig` exist at all".
It held four unrelated jobs, organised by verb rather than by type, and five display
decisions the engine had no business making: `exact_fraction_digits` (20),
`max_display_integer_digits` (40), `scientific_significant_digits` (17), and two
anonymous `1e15`/`1e-15` literals inside `formatFloat`. Android would have inherited
a budget chosen for an 80-column terminal, or reimplemented the rules in Kotlin.
Each job went to the type that owns it:
- **`Number` renders itself.** `render(allocator, FormatOptions) Error!Rendered`,
where `FormatOptions{ fraction_digits, max_integer_digits, significant_digits,
separators }` has NO defaults, so a frontend cannot forget to decide. `main.zig`
and `tui.zig` each declare one; `null` for `max_integer_digits` means never
abbreviate, which is the clipboard form (requirements.md line 237). The old
`NumberDisplay{ display, raw, exact }` triple is gone: display and clipboard are
two option sets, so nothing allocates a clipboard string on every keystroke for a
yank command that does not exist yet.
- **`exact` became `truncated`**, and now describes the text alone: rounded away at
`fraction_digits`, or abbreviated. Whether the VALUE is exact is `n == .exact`,
and the two are independent. The old flag tried to mean both, which is why an
exact 1e50 reported `exact = false` while its own `raw` field held every digit.
A frontend marking a result approximate composes the two facts itself.
- **`financial.zig` got money.** `money(value)` returns a `Money` with `format` and
`render(buf)`. Two decimals and grouping are properties of money, not of a
screen, so there is no budget to pass. The dead `formatAmount` went away with it,
and the null-on-failure contract became `error.WriteFailed`, so the nine CLI
`orelse return unformattableResult()` sites became `catch return`.
- **`Integer.zig` got it as `BitWidth.displayFor`**, the one thing in the file that formatted
nothing: it returns a `BitWidth`, which is `Integer`'s own type.
Two rules stayed in the engine because they are facts about values rather than
preferences about screens: the 2^53 bound, past which an f64's fixed rendering would
invent digits (so an inexact value goes scientific there no matter what the caller's
budget says), and that a value is never handed back rendered as zero.
`FormatOptions` carries two fractional numbers because the callers ask two different
questions, and collapsing them onto one is what left the tiers disagreeing (below):
- `fraction_digits` is where both tiers round. An exact expansion is divided out to
that many places; a float whose shortest round-trip form is longer is rounded to
the same place with `std.fmt.float.render`, trailing zeros trimmed, so a rounded
float and a rounded rational of one value produce identical text.
- `scientific_below_exponent` is where fixed notation stops being worth it, counted
in leading fractional zeros. A result line wants 20 and 15: round at twenty
places, abbreviate below 10^-15. The float view wants 17 and 6: show every digit
an f64 has, but send a subnormal ULP to scientific rather than print 45 places.
That split is what let the abbreviation decision become a single function both arms
call, so the tier a value came from cannot change the shape of the output.
The float arm renders onto stack buffers sized by `std.fmt.float.bufferSize(.decimal,
f64)` (347 bytes) and allocates once, so a caller can hand it a
`FixedBufferAllocator` sized for the text it asked for. `src/tui/float_view.zig` does
exactly that: an 80-byte stack buffer, no heap in the draw path, "?" if it does not
fit.
Behaviour changed in three bands, all deliberate, all making the tiers agree:
- An inexact value in [1e15, 2^53) now prints in full instead of scientific. Those
digits are real; the old `> 1e15` test was a display preference sitting in front
of the representability bound that actually matters.
- An exact value below 10^-15 now abbreviates, where before it printed fixed text
until the expansion rounded to literal zero at 10^-20. That was the band where the
tiers visibly disagreed: `1/3000000000000000000` read as `0.00000000000000000033`,
two significant digits out of twenty, while a float of the same size read as
`3.3333333333333334e-19` and kept seventeen. The tier that knew more showed less.
- An inexact value inside the window but longer than the budget is now rounded rather
than abbreviated, which is what the exact tier always did. `pi * 1e-14` reads
`0.00000000000003141593` from either tier.
Also found while checking callers: nothing consumes the `raw` form of an integer
rendering, and there is no clipboard code in the TUI at all. The prefixed form is kept
because design 2.6 specifies a clipboard representation and FR-6.9's neighbourhood
plans a `--raw` flag, but today it is exercised only by tests.
- Verify: 933 tests pass, fmt and zlint clean, `Integer.zig` and `grouping.zig` at
100% line coverage, engine 99.44%. CLI output byte-identical across all five rows,
both byte orders, ASCII packing and the multi-base standard-mode view.
### Task 5.29: One word, and the name it was hiding in
Entry 17 covered `build.zig`, `build/Coverage.zig` and `build/download_kcov.zig`, and
found nothing to change in what they do. The only fix is vocabulary, and it went one
level deeper than a comment.
`Coverage.addModule` did not add anything of the sort: it builds a test executable from
a root and registers a kcov run plus a threshold check over it. What it adds is a
report, so it is `addReport` now, its `root_module` parameter is `root`, and the three
call sites in `build.zig` read `cov.addReport(engine_cov, "tally-engine", ...)`. Two
doc comments that described reports as modules were reworded to say reports, and
`build.zig`'s header comment above the engine now names the engine.
`b.addModule` and `root_module` in the `addTest` call stay: those are Zig's own API,
not ours to rename.
- Verify: 942 tests pass, fmt and zlint clean, `zig build coverage` still produces all
three reports (98.87% for the CLI on that run, with the engine and TUI unchanged).
### Task 5.28: The views stop drawing what they cannot fit
Entry 16 covered the five view files. The common fault was the same one Task 5.27
found in the binary row, in its other form: content drawn without asking whether there
was room for it.
**The float overlay put the exponent row on the input line.** Its panel is eight rows
under a two-row grid, and it drew them whatever the height. The furniture is drawn
last, so the separator and status covered their rows entirely, but the prompt occupies
only two columns: at 100x16 the input line read
```
> xp: 0 biased -> -1022 unbiased (bias 1023)
```
The overlay now reports what it needs, as the integer view already did (FR-7.3.1), and
this one is FR-7.3.3. `drawInput` also clears its row before drawing the prompt, so no
view can leave a fragment beside it again. The furniture check could not see this: it
tests the prompt marker, and the marker was exactly where it belonged.
**Convert mode did the same, more quietly.** The result, the factor and the column
headers were drawn unconditionally; at 100x12 "From"/"To" ended up next to the prompt
as `> rom ... To`, and at 60x10 the result line did. Every row is now bounded by the
content region, and the out-of-memory path draws a notice and the furniture instead of
returning with no prompt.
**The factor line disagreed with the result above it.** It divided the two base
factors in f64 while the result used the exact path, so centimetres to inches read
`0.39370078740157477` as a factor and `0.393700787401574803150` as a result, three
lines apart. It now converts one of the source unit through the same code as
everything else. The old fallback on a failed conversion was worse than the error it
hid: it drew the unconverted input as the answer.
**The help overlay's function list was wrong and is now derived.** It named seventeen
functions; the engine has answered to twenty-one since `log2`, `log10`, `cbrt` and
`atan2` were added, so the only way to find them was to read `evaluator.zig`. The
overlay renders `evaluator.math_function_names`, which is every built-in that is not
one of `financial.expression_functions`, wrapped to the width at comptime. `rand` is
excluded while it returns 0 rather than a random number, and an evaluator test asserts
every built-in is in exactly one of the two lists or is that single exclusion, so the
next function added cannot be silently undocumented (FR-7.6.2). This is the same
treatment Task 5.25 gave the financial list, finishing the job.
**Financial mode asked for the same answer three times.** `state.outcome()` was called
by the draw entry point, by `drawOutcome` and by `drawSchedule`, and for a 360-period
loan each call runs `amortizationTotals`, which walks the whole schedule. The outcome
is now computed once and passed down, which also removed `drawOutcome`'s unused `app`
and `width` parameters and `drawSchedule`'s unused `width`. `drawHelp` lost a `width`
it ignored, and `errorText` stopped being public: nothing outside the file called it.
- Verify: 942 tests pass, 4 of them new, fmt and zlint clean. float_view.zig and
programmer.zig at 100% line coverage, financial.zig 99.6%, help.zig 97.3%,
convert.zig 97.2% (the uncovered rows are the out-of-memory notice and two failure
diagnostics). TUI total 98.41%, engine 99.29%. Checked by hand: the help overlay's
Functions section now reads `abs floor ceil round sqrt factorial max min sin cos tan
asin acos atan cbrt` / `exp ln log2 log10 log atan2`, and the float overlay at
100x16 shows `too short for the 64-bit float view: needs 19 rows, has 16` with a
clean prompt row.
### Task 5.27: The check that could fail, and the two faults it found
`test_render.zig` opens by explaining that an earlier `wellFormed(rows, width)` was a
tautology: every row is allocated at exactly `width`, so the assertion could not fail.
It then lists three things that are detectable, one of which was right-edge clipping,
via `rowsReachingRightEdge` and `noTextClipped`. Neither had a caller outside its own
test. Wiring them into the render tests explained why.
**The binary row was cut off on a default terminal.** 64 bits grouped in fours is 79
characters starting at column 11, so the row needs 90 columns. At 80 it lost its
right-hand end, which is the low bits: the value read as wrong rather than as
incomplete, with no indication either way. 128-bit needed 170 columns and had been
unreadable since it was added. There was a height guard for this view and no width
equivalent.
The row now continues onto further rows, halving until it fits: 64 bits at 80 columns
is two rows of 32, the same grouping the bit grid above uses, and a 90-column terminal
gets the single row back. The label is on the first row; the others are the same field
with the same click targets and the same cursor, which took a `DigitRun { before,
total }` through `registerDigitRegions` and `drawFieldWithCursor` so a digit's bit
position stays a property of the value rather than of the row it landed on. The height
guard counts the binary rows it is about to draw, so a short terminal still reports
what it needs. `drawFieldWithCursor` also lost a `total_bits` parameter it never read.
**Every status line lost its last hint mid-word.** Programmer mode's is 101
characters; at 80 columns it ended on "Ctrl", and convert's ended on "Tab:m" at 60.
`draw.hintRow` writes as many whole hints as fit and drops the rest, and each mode now
orders its hints so the keys that leave the current zone survive. It also leaves the
final column clear, which is what makes the clipping check exact: any row but the
separator reaching the right edge is now a fault rather than a maybe. The
`too short for the {d}-bit view` notice was shortened for the same reason - a notice
that is itself clipped is no better than the layout it reports on.
**The rest of the entry.** `draw.zig` now writes cells through vaxis's own
`Surface.writeCell`, which does the bounds check the three helpers each hand-rolled,
and `fillRow` and `writeStr` go through `writeChar`, so nothing in the TUI turns a row
and column into an index any more. The 128 one-byte pointers behind `charGrapheme`
became one 128-byte array, which also drops a `@setEvalBranchQuota`. The file had no
tests of its own: clipping at the right edge, dropping writes outside the surface, and
substituting a space for a byte above 127 are the assumptions every view is built on,
and all three are now asserted directly.
That substitution turned out to be reachable rather than defensive: the input line
draws `TextField` bytes one at a time, so a pasted accented character became a cell
per UTF-8 byte, drawn blank, and then failed to tokenize. `handleKey` now drops key
events carrying non-ASCII text (FR-7.9.2), which is what the programmer value zone
already did for itself.
`flatten` became private, `noTextClipped` takes the number of full-width rows to
expect rather than hardcoding one, and `fault`/`expectSound` replace the pair of
assertions each render test used to make, reporting the offending rows instead of a
bare false. The stale comment above the render tests, which still claimed they
asserted the frame stayed rectangular, is gone.
- Verify: 938 tests pass, 16 of them new (the last being a click on the second row of
a wrapped binary field, which is the one place the new digit bookkeeping could be
wrong without any frame looking wrong: bit 0 is the last digit of the last row). draw.zig and programmer.zig are at 100% line
coverage, up from 96.2% and 100%; test_render.zig is 92.3%, the uncovered lines
being the failure reports that only run when a test fails. TUI total 98.56%. Proving
the check can fail: disabling the binary split makes two render tests fail with the
clipped row printed, including one that had been passing while the row was clipped.
`main.zig` held both ends of the program: which arguments mean what, and how a
finished figure reads on a terminal. One file decided `--bits` spellings and money
column widths. The output half is now `src/cli/format.zig`, a sibling in spirit to
`src/tui/`, and `main.zig` is argument parsing plus the one function that touches a
stream. 913 lines and 669, from 1631.
What moved: `evaluate`/`evaluateWith`, the conversion and amortization renderers, the
programmer and multi-base result rows, the error decoration, `Mode`, and this
frontend's `display_format`. What stayed: `ParsedArgs`, `parseArgs` and the
subcommand parsers, the usage and help text, and `main`.
Since the file is named `format.zig`, the prefix those functions carried is gone:
`format.conversion`, `format.amortization`, `format.evaluateWith`. `CliResult`
became `format.Result` for the same reason, and `amortErrorMessage`,
`unformattableResult` and `oomResult` became `amortizationError`, `unformattable`
and `oom`. `decoratedError` is the one output function the argument layer also
needs, because `readConversion` reports engine errors while parsing.
The two-argument `evaluate` did not survive the move. It defaulted the programmer
configuration and forwarded to `evaluateWith`, and once `main` began passing a
configuration through, only the tests called it - the same shape as the
`evalProgrammer` forwarder deleted in entry 9. Its callers name the default.
`standardMultiBase` gained the tests it never had. It renders the hex, octal and
binary rows a standard-mode answer carries when the expression was written in another
base, and nothing exercised it: `tally '0xFF + 1'` prints four lines that no test had
ever looked at. There are now assertions on the whole block for a hex, a binary and an
octal expression, on the width following the value rather than a setting (10 gets one
byte, 256 gets two), on a negative and a fractional answer staying decimal because
neither has a bit pattern to show, and on a buffer too small for the rows being an
error rather than half a table.
None of this is shared with the TUI, deliberately. `src/tui/financial.zig` draws a
windowed slice of the same schedule at narrower widths into a surface; this writes the
whole table into an allocated buffer. What they do share is the engine's `Money` and
`Number` rendering, so the figures agree even though the tables do not.
- Verify: 929 tests pass, 926 of them the same 926 as before the move (24 moved with
their subjects, 3 are new and none were deleted), fmt and zlint clean. `build.zig`'s
CLI coverage report includes `src/cli`, so the split does not hide a file:
`main.zig` 99.4%, `format.zig` 98.3%. What is left uncovered there is one error path
per output shape - a programmer-mode evaluation error, a result too long for the
buffer, the programmer rows overflowing it, a non-domain amortization error, and
`oom` - none of which any test reaches yet.
### Task 5.25: The CLI stops reimplementing the engine
`main.zig` had grown four copies of things the engine already owned. All four are
gone; the file is 105 lines shorter and does less.
**A second conversion-request parser.** `isSeparatorWord`, `interpretConvertTokens`
and `splitValueAndUnit` reimplemented `units.parseRequest` over argv boundaries: find
`to`/`in`, split the value from a trailing unit, resolve both names. One grammar, two
implementations, and they disagreed - `units.parseRequest` had learned
`Error.AmbiguousUnit` in Task 5.22 while the CLI still picked the first table entry.
`parseConvertArgs` now joins argv with spaces and reads the result through
`units.parseRequest`. The subcommand allows the separator to be omitted
(`convert 100 km mi`), which a bare expression does not, so the text is read twice:
as written, and with `" to "` inserted before the last token. A `.conversion` from
either reading wins over any error, which is what makes `convert 1 in cm` work - `in`
is the source unit there, not the separator, and the first reading is the one that
sees it.
`ParsedArgs.conversion` therefore carries a value and two resolved `UnitDef`s rather
than three strings. The value text is duped because the request borrows from the
joined buffer, which is freed on return; the units are values whose names are static.
`formatConversion` takes the resolved units, so its own `findUnit` calls and
`UnknownUnit` path are gone.
**Two numeric-text predicates.** `looksNumeric` and `isFullyNumeric` decided whether
an argument was a value, with rules of their own for signs, separators and suffixes.
Whether text is a number is `Number.parse`'s answer, and `formatConversion` was going
to ask it anyway, so `readConversion` asks first and the error simply arrives earlier.
`grouping.isNumericText` was the other candidate and is wrong here: it rejects the
FR-1.8 separators `1_000` and `1,000`.
**A one-line error forwarder.** `errorMessage` called `decoratedError` and nothing
else. Its nine callers call `decoratedError`, whose doc comment absorbed what both
had to say.
**Its own list of financial functions.** The CLI help named nine of the seventeen
functions in FR-5.7, the TUI overlay named eleven in different words, and neither was
checked against the evaluator. `financial.expression_functions` now holds the name,
signature and one-line summary of each, `financial.help_lines` renders them into an
aligned column at comptime, and both frontends print that. An evaluator test asserts
every documented name resolves to a `Builtin` at the arity its signature advertises,
which is the only place the two can be compared - `Builtin` is private.
- Verify: 926 tests pass (five new, eight deleted with the helpers they covered), fmt
and zlint clean. `tally --help` lists all seventeen functions; `convert 100 km to
mi`, `convert 100 km mi`, `convert 1 in cm`, `convert 1 in in cm`, `convert 100 mm
in in` and `convert 98.6F in C` all still convert, and `convert abc km mi` and
`convert 1 smoots m` still report the number and the unit respectively.
### Task 5.24: The engine's surface is what someone imports
`engine.zig` re-exported thirteen modules; six had no importer outside the engine.
`Rational`, `tokenizer`, `ast`, `bitwise` and `Parser` had none at all, and `number`
was reached only through the `Number` alias. A frontend evaluates with `evalString`
rather than building a tree, and computes on `Number` rather than on the rational tier
underneath it, so none of those were ever going to be asked for.
What kept them compiling is `test { testing.refAllDecls(@This()); }`, which analyses
every declaration in the file. That is a useful idiom and it is also why nothing
flagged six dead exports: they were referenced, just not by anyone outside.
`number` and `Parser` stay as private imports, the first for the `Number` alias and
the second for the test that walks `Parser.Error`. The rest are gone from the surface
and still reachable by path from inside the engine. Test count is unchanged at 929,
which confirms the re-exports were not what pulled those files' tests into the binary:
each is imported by a module that remains, so it is part of the compilation either
way.
`c_api.zig` was reviewed in the same pass and left alone. Its three exports
(`tally_eval`, `tally_result_free`, `tally_version`) are a reasonable shape on the
surface, but it has no tests, appears in no coverage report because no test target
builds the shared library, and predates Task 5.17's removal of engine-side display
defaults. It will be rewritten against a real caller when the Android app lands; open
items 12 and 15 record the test gap and the `FormatOptions` question.
### Task 5.23: One answer per loan, and a principal column in whole cents
Open item 7 named two amortization defects. Both are real, and neither is the
mechanism the item blamed.
**The "covers the interest" check lived in the wrong place.** It sat in
`AmortizationCursor.init`, so `amortizationPayment` would happily return a payment
for a loan that has no schedule:
```
amort_payment(200000, 0.5, 12000) 1,000 now: error: domain error
amort_total_interest(200000, 0.5, 12000) domain error unchanged
```
Two functions, one loan, two answers. The check moved into `amortizationPayment`,
which is where "the level payment implied by a loan" either exists or does not, and
the cursor now trusts it. `levelPayment` and `firstInterest` split out so the check
reads as one line.
That loan is worth understanding: at 0.5% over 12000 periods the true level payment
exceeds the first period's interest by about 1e-23, which no f64 holds, so the two
are equal in binary before any rounding happens. Rejecting it is correct at every
scale a double can express, and the rounding the open item blamed never enters into
it. A payment one cent above the interest is still accepted and still ends in a
balloon, which `AmortizationCursor.next` supports deliberately.
**The principal column was not a whole number of cents.** `principal = payment -
interest` is a binary subtraction of two cent-scale values, and lands an ulp off one:
```
amort_principal(200000, 0.5, 360, 1) 199.0999999999999 now: 199.1
```
312 of the 1440 figures in a 360-period schedule were off. The money formatter prints
`{d:.2}` so the CLI table looked right, which is why this survived; as an expression
result it did not. `next` now scales the principal explicitly rather than inheriting
roundness from its operands, and a test asserts every figure in a rounded schedule
survives a round trip through the cent scale.
### Task 5.22: Bits are not bytes, and a fold that could mean two units says so
Two findings from the `units.zig` review, one a silent wrong answer.
**The prefixed bit units did not exist.** `digital_storage` had `bit` and the byte
ladder, but no `kb`/`Mb`/`Gb`, so `Mb` matched nothing exactly, fell through to the
case-insensitive pass, and resolved to `MB`:
```
100 Mb to MB -> 100 MB = 100 MB now: 100 Mb = 12.5 MB
1 kb to B -> 1 kB = 1,000 B now: 1 kb = 125 B
```
A megabit-to-megabyte question came back as an identity, and the echoed input said
`MB` where the user had written `Mb`. `findUnit`'s doc comment acknowledged the class
of problem and told the reader that "a user who means megabits has to write `Mbit`",
which was not true: no such unit existed. `b`, `kb`, `Mb`, `Gb`, `Tb` and `Pb` are now
in the table with their long forms, and since a bit is an eighth of a byte every
factor stays exact. The binary bit forms (`Kib`, `Mib`) are still absent, which is
deliberate: nothing asked for them and they are rarely written.
**A fold onto two units is an error, not a guess.** The case-insensitive pass
returned the first match and stopped, so a spelling that folded onto two units the
tables distinguish by case got whichever came first in the file. In `data_rate` that
is an eightfold error:
```
100 MBPS to Mbps -> 100 Mbps = 100 Mbps now: error: ambiguous unit name
100 mB to MB -> 100 MB = 100 MB now: error: ambiguous unit name
```
`resolve` now returns `unit`, `unknown` or `ambiguous`, and the pass has to find
exactly one unit; two aliases of one unit are not a conflict, two units are. The new
`AmbiguousUnit` error carries "ambiguous unit name: case tells these units apart".
`findUnit` keeps its `?UnitDef` signature for the internal scans and returns null for
both failures, while `convert` and `parseRequest` report which. `splitTrailingUnit`
returns the error rather than skipping the candidate, because skipping would go
looking for a shorter suffix and answer a different question with no error at all.
The forgiving input the fold exists for is untouched: `100 KM to mi`,
`32 F to celsius`, `1 KIB to B`, `5 in in cm` and `100 mm in in` all still resolve.
### Task 5.21: One table of built-in functions
`evalFunction` dispatched through a chain of `if (mem.eql(u8, name, ...))` blocks
grouped by argument count, spilling into `evalSingleArgFn` and `evalFinancialFn`,
both of which returned `Error!?f64` where null meant "not my name". Four consequences:
- **A wrong argument count was reported as a wrong name.** `log(2)` matched nothing in
the one-argument group, fell through to `evalSingleArgFn`, which does not know
`log` either, and came out as "unknown function" about a function that exists. Same
for `max(3)`, `sqrt(4, 9)`, `cagr(1, 2)` and every three-argument call to a
four-argument TVM function. One test asserted this behaviour and was named after
it.
- Adding a function meant choosing between three places and knowing why.
- Nothing could enumerate the built-ins, so `src/tui/help.zig`, `main.zig`'s help
text and FR-5.7 each list them by hand with nothing checking that the three agree.
- The dispatch and the domain checks were entangled: a caller could not ask whether a
name existed without also running it.
There is now a `Builtin` enum whose members *are* the names, so
`std.meta.stringToEnum` is the lookup and no string list exists to drift. `arityOf`
gives each one its `{ min, max }`, checked before any argument is evaluated, so a
misspelled name is `UnknownFunction` and a miscounted call is `WrongArgumentCount`
("wrong number of arguments"). `log` is the one built-in whose min and max differ for
overloading rather than an optional argument: `log(x)` is log10 and `log(x, base)` is
the general form.
The implementation split follows the exactness boundary rather than the argument
count: `exactBuiltin` handles the eight that keep an exact operand exact (`abs`,
`floor`, `ceil`, `round`, `sqrt`, `factorial`, `max`, `min`) and takes `Number`
operands; `floatBuiltin` handles the rest over operands collapsed once to f64, which
is honest because every one of them escapes the rationals by definition (design.md
2.7.4). `floatBuiltin`'s switch names the exact group in its `unreachable` arm, so a
built-in added to the enum without being classified fails to compile.
**The table exposed three functions with no tests.** `tan`, `atan` and `cbrt` were
never exercised, and the old shape hid it: `mem.eql(u8, name, "tan")` ran on every
single-argument call, so coverage counted the line even though `@tan` never executed.
One switch arm per function gave each its own line, the gap appeared, and the tests
are now there.
A test walks the enum and calls every built-in with one argument too many, so a
member whose arity entry disagrees with what its implementation reads cannot pass
unnoticed.
### Task 5.20: Exact operands survive the fixed-width operators
Three findings from the `evaluator.zig` review.
**`pub fn evaluate` was dead.** The last f64-returning expression API in the engine,
superseded by `evalString` when Task 2.0c landed, and called by nothing once both
frontends moved over. Its own doc comment said as much.
**A built-in name is no longer an assignment target.** `getVar` answers `pi`, `e`,
`tau` and `Ans` before consulting the variable map, so `env.setVar("pi", 3)` wrote to
a slot nothing would ever read again:
```
$ tally 'pi = 3'
3 <- and pi is still 3.14159...
```
The constants are now one list that both `getVar` and a new `Environment.isBuiltIn`
consult, so a name cannot be readable as a constant and writable as a variable at the
same time, and `evalExact` rejects the assignment with `AssignmentToConstant`
("cannot assign to a built-in constant"). Open item 10 is fully closed, and FR-1.6 no
longer documents the defect as current behaviour.
`Ans` went into the same set, which closes the same hole for it but misnames it: it is
a pseudovariable the engine rewrites on every evaluation, not a constant. Open item 16
and FR-1.6.1 track separating the two concepts.
**The fixed-width operators no longer round exact operands.** Standard mode projects
onto a 64-bit two's complement integer to run `& | xor << >> >>> rol ror ~`, and both
ends of that projection went through f64:
```
$ tally '2^62 or 1' 4.611686018427388e18 should be 4611686018427387905
$ tally '(2^53 + 1) and -1' 9.007199254740992e15 should be 9007199254740993
```
The first is the result side (an i64 answer widened to f64 loses everything past
2^53), the second is the operand side (`2^53 + 1` has no f64 form). Both now take an
exact path: `standardOperand` converts an operand that is already an exact integer
directly with `Number.asExactInt`, and reports whether it managed it;
`fromStandardInt` returns `Number.fromInt` when nothing was rounded and
`Number.fromFloat` when something was.
That second half is what makes it correct rather than merely wider. `Number`'s
contagion rule is that exact means no rounding anywhere in the value's history, so a
result computed from an operand that had to go through f64 (`0.5 and 1`, `pi and 1`,
`(1/3) or 0`) still comes back inexact. Returning `fromInt` unconditionally would have
been an exactness claim the value had not earned. The width bounds are unchanged:
`2^64 and 1` and `2^63 and -1` are overflows, not truncations, because an exact
integer that does not fit the width falls to the f64 path and fails there.
### Task 5.19: One operator table; assignment is a statement; Parser.zig
`parser.zig` became `Parser.zig`, file-as-struct, since everything in it serves the
parser and its external surface is exactly `Parser.init`, `parse`, `Error` and
`freeExpr`. The importers now read `Parser.Error` and `Parser.freeExpr` rather than
going through a `parser_mod` alias, and `engine.zig`'s re-export follows the name.
The fields sit just above the functions that use them rather than at the very top,
because the precedence table below reads as the language's binding order and belongs
in front of the code that consumes it.
Four switches encoded one operator table: token kind to operator, token kind to
precedence, keyword to operator, keyword to precedence, plus `if (op == .pow)` inline
for right-associativity. Each pair had to agree and nothing enforced it. A kind with
a precedence but no operator advanced past the token and then reported "unexpected
token"; a keyword added to one switch and not the other bound at the wrong level.
`infix_table` now lists each operator once as `{ trigger, op, prec, assoc }`, where
the trigger is a token kind or a keyword's text. `parseExpr` asks `infixOp()` for the
entry, so `parseInfix` receives an operator it already has and has no failure case
left. `keywordBinaryOp`, `keywordPrec`, `infixPrecedence` and `tokenToBinaryOp` are
gone, and with them the `_ = self` in the last one and the redundant `kind` parameter
in the third (it took a kind while reading `self.current` for the keyword arm, so the
two could disagree). A test walks the table and parses one expression per entry.
**Assignment moved to statement level.** It was recognised in `parsePrefix`, so it
could appear wherever an operand could:
```
$ tally '1 + x = 2'
3 <- assigned 2 to x, then added
$ tally 'f(x = 1)' <- an argument list with a side effect
```
`parse` now handles `name = expr` itself, with a one-token lookahead (a copy of the
tokenizer, which is just a position in a string). Both cases above are parse errors,
and open item 10's first half is closed. Chained assignment (`x = y = 3`) goes with
it; nothing in the requirements asks for it.
**An unterminated string literal is reported.** `readStringLiteral` returned the span
without its closing quote, and the parser stripped the first and last byte to get the
content, so `'AB` parsed as the literal `'A'` and `'` alone reported "invalid number".
The scan knows whether it found the quote, so it emits `unterminated_string` and the
parser reports `UnterminatedString`. The `len < 2` guard is now an assert.
**`InvalidExpression` split into `ExpressionTooLarge` and `NestingTooDeep`.** Both
limits reported the same error, so "invalid expression" was all either could say. The
messages are now "the expression has too many terms" and "the expression is nested too
deeply".
### Task 5.18: A literal is its text; each reader parses it at its own precision
The tokenizer computed two lossy representations of every numeric literal up front,
in a fixed 128-byte buffer: an `f64` and a `?u64`. Both went into the AST node
alongside the source text, and every limit in the pipeline came from that decision.
Five reachable defects, one cause:
```
600-digit literal error: invalid number now: too many digits
130-character literal error: invalid number now: parses exactly
1e100001 inf now: exponent too large
0x1FFFFFFFFFFFFFFFF, programmer mode error: invalid number now: the value
u128 max in any base, programmer mode error / overflow now: the value
```
`Rational.parseDecimal` accepts 512 digits and reports `TooManyDigits`, and no
frontend could reach either, because a literal over 128 characters died in the
tokenizer first with the wrong message. `int_value` was a `u64` in a mode whose widest
type is 128 bits, so a 17-nibble hex literal was "invalid number". And
`evaluator.literalToNumber` swallowed every `Number.parse` error and fell back to the
f64, so `1e100001` printed `inf` instead of saying the exponent was out of range.
- `ast.Expr.Number` is now `{ base, text }`. The text is the only lossless form of a
literal, so it is the only one kept.
- `Base` gained `parseDigits`, which walks the literal's own text skipping separators
and accumulating into a `u128` with checked arithmetic. No buffer, so length is not
a limit; `Overflow` past 128 bits, which is the widest pattern `Integer` holds.
- The exact tier parses decimals with `Number.parse` and propagates the error.
- Programmer mode parses decimals through the exact tier too, then truncates toward
zero. It has no float path at all now, which removes the `@intFromFloat` range
guards (illegal behaviour past u128, which is what aborted `tally -p '1e40'`) and
makes the truncation exact: `1e30` used to arrive as
1000000000000000019884624838656.
- Validity moved into `readNumber`, which knows it consumed no digits after a `0x` or
an `e`. `TokenKind.invalid_number` carries that, so the parser still says "invalid
number" for `0x` and "unexpected token" for `$` without re-deriving either from the
text.
- `Token` carries the base the scan recognised, so nothing downstream re-reads the
prefix.
`parseNumber`, `NumberValue`, the 128-byte buffer, the u64 cap and the f64 fallback
are all gone.
A latent crash surfaced once literals could be that wide. `Integer.signedValue`
consulted `signedness` and `@intCast` the pattern when it said unsigned, so a 128-bit
unsigned value with the top bit set panicked ("integer does not fit in destination
type") as soon as anything printed it, which every display path does. It was
unreachable only because such a literal could not be entered. Reading a pattern as
two's complement is a `@bitCast` and cannot fail, and it is what
`Notation.decimal_signed` documented all along: the design mockups show
`DEC 4,294,967,295` beside `SIGN -1`, two readings of one pattern, where the old
implementation made those rows identical for every unsigned value. `isNegative` still
consults `signedness`, because that is the arithmetic question the shift operators
ask.
### Task 5.15: A value renders itself
`formatter.zig` had six functions taking `(buf, value: u128, bit_width, endian)`,
which is an `Integer` taken apart. Both frontends proved it: each constructed an
`Integer` and then handed the pieces over separately, so nothing stopped a pattern
being rendered at a width it did not come from.
- `Integer` renders itself rather than being taken apart by six formatter functions.
The first version took a buffer per base and returned a display/raw pair; Task 5.16
replaced that with views and a writer, so read that entry for the shape the code has
now. The 27 tests moved here with the renderers.
- The primitives both display paths need went into a module of their own, since
grouping arbitrary decimal text is not an `Integer` concern and the thousands rule
has to stay defined once: `formatFloat` and the decimal notations both group. That
module started as `digits.zig` and became `grouping.zig` in 5.16, once it held only
the shared rule. Leaving the primitives in `formatter.zig` and importing that from
`Integer.zig` would also have compiled (Zig allows files to import each other; lazy
evaluation only objects to a genuine dependency cycle), but it would point the
low-level type at the high-level display module.
- `formatter.zig` keeps what is not a fixed-width integer: floats, exact `Number`s,
scientific notation, money, and the display width. It re-exports the two grouping
functions the TUI uses on partially typed input. (Task 5.17 later distributed all
of that and deleted the file.)
Also in this task, the nested `IntType` went away: see design 2.5. `bitwise.apply`
takes two `Integer`s and returns one, `programmer.evalExpr` threads `Integer` instead
of bare `u128`, and `Config` holds width and signedness flat.
- Verify: 939 tests pass, fmt and zlint clean, `Integer.zig`, the primitives module,
`bitwise.zig` and `programmer.zig` all at 100% line coverage, engine 99.45%. CLI
checked across every base row, both byte orders, both signedness readings, ASCII
packing, and the shift semantics.
### Task 5.14: File-as-struct for the types that are types
`Integer.zig` and `Rational.zig` are named TitleCase and the file *is* the type: the
fields sit at container level, `@This()` names it, and the auxiliary declarations
(`IntType`, `BitWidth`, `Signedness`; `Error`, `DecimalResult`) are nested inside.
`git mv` kept the history. `Parser.zig` joined them in Task 5.19: everything else in
that file (`Error`, the precedence table, `freeExpr`) exists to serve the parser, and
its whole external surface is `Parser.init`, `parse`, `Error` and `freeExpr`.
`number.zig` stays lowercase. `Number` is a tagged union and a Zig file is always a
struct container, so the file can only be that type by wrapping the union in a field.
That would put a `.value` hop in front of 53 tag tests, and the exact/inexact tag is
the type's whole identity, so the wrapper would cost more than the naming consistency
buys. Recorded here so the asymmetry reads as a decision rather than an oversight.
### Task 5.12: Break up types.zig
`types.zig` was named after a language feature rather than a concept, so it
accumulated four unrelated groups with almost no overlap in who used them. Found by
asking, during review, why the file existed at all. Each group went to the module
that owns it and the file is gone:
- `engine/src/errors.zig`: `CalcError` and `errorPhrase`, the most widely depended-on
thing in the old file (nine engine modules, all three frontend files).
- `engine/src/Integer.zig`: the fixed-width integer model, TitleCase because the file
is the type (`raw` and `int_type` are its top-level fields). It declares `IntType`
(width plus signedness), `BitWidth` and `Signedness`. `IntType` replaced three
shapes of the same pair: `Integer{raw, bit_width, signedness}`,
`ProgrammerConfig{bit_width, signedness, display_endian}` and the `Domain` added to
`bitwise.zig` during the operator unification. `Integer` is now a pattern plus its
`IntType`, and `bitwise.zig` takes an `IntType` directly. (Task 5.16 went one step
further and dissolved `IntType` too: `width` and `signedness` are fields of
`Integer` directly, and `bitwise.zig` takes whole `Integer` values, so the pair
never travels without the bits it describes.) The engine's own
`Endianness` enum is gone in favour of `std.builtin.Endian`, which has the same two
members.
- `Base` into `tokenizer.zig`: a lexical property with two users, the lexer and the
AST.
- `ProgrammerConfig` into `programmer.zig` as `Config`, holding an `IntType` and the
one display preference.
- `Mode` deleted from the engine. The frontends each own theirs, and they differ: the
CLI has two members, the TUI four (including Convert), and the engine's had a
`financial` member it could never act on.
Two more pieces of dead state fell out, both the same pattern as the `Parser` and
`Tokenizer` fields removed in the de-duplication pass, and both hidden because a
frontend was writing to them:
- `Environment.mode`, assigned in `init` and never read. `src/tui.zig setMode` was
writing it on every mode change.
- `Environment.programmer_config`, dead as of the operator unification, when `~`
stopped being the one operator that read a configured width in standard mode.
`Environment.init` now takes only an allocator.
`engine.zig` defines nothing and only re-exports, which is why the error set did not
move there: every engine module would have had to import the root to name its own
error type.
- Verify: 942 tests pass, fmt and zlint clean, `integer.zig` and `bitwise.zig` at
100% line coverage, CLI output unchanged across arithmetic, programmer flags,
conversion and the new shift semantics.
### Task 5.11: CLI programmer-mode flags and subcommand help
The CLI could reach programmer mode with `-p` but not configure it, so the width,
signedness and byte order the TUI exposes were unreachable from the command line.
- `--bits <8|16|32|64|128>` (FR-6.3), `--signed`/`--unsigned` (FR-6.12) and
`--endian <big|little>` (FR-6.13). Each accepts the separated and `=` spellings
and implies `-p`, since standard mode is fixed at 64-bit signed.
- The accepted widths come from the `BitWidth` enum rather than a hand-written list,
and a comptime check fails the build if the enum changes without the help text
being updated.
- `-h`/`--help` on every subcommand, on stdout with exit code 0 (FR-6.14). The usage
text used to be reachable only by getting the arguments wrong.
- An unrecognized long flag is now an error naming `tally --help` instead of being
joined into the expression (FR-6.15): `tally --bit 8 '1'` reported "unexpected
token", which pointed at the wrong thing.
- Verify: 938 tests pass, including width/signedness/endianness reaching the
formatted result and every rejected flag spelling. Checked by hand:
`tally --bits 8 '0xFF + 1'` is 0, `--bits 8 --signed '0xFF >> 1'` is -1 while
`--unsigned` gives 127, `--endian little '0xDEAD'` reverses the hex row.
NOT DONE, still open from FR-6: `--base` (FR-6.4), `--json` (FR-6.9), and the
`struct`, `cagr` and `tvm` subcommands (FR-6.5 to FR-6.7).
### Task 5.10: Act on the full-codebase review [PARTIAL]
A four-part review of the whole codebase (numeric core, language layer, domain
modules, frontends plus build) produced 29 wrong-answer or crash findings, 10
groups of tests giving false confidence, and a set of documentation mismatches.
This task records what was fixed, what was deliberately not, and what remains.
FIXED:
- **Comma digit separator** (`tokenizer.zig`): a comma now groups digits only when
exactly three follow. The old rule was "a digit follows", so `log(100,10)` lexed
as `log(10010)` and returned 4.0004, and `max(1,2)` became `max(12)`. Every FR-5.7
financial call was unreachable without a space after each comma. Base literals no
longer accept commas at all (they group with spaces and underscores, FR-1.8).
`max(1,234)` is inherently ambiguous and resolves to `max(1234)`; FR-1.8 now says
so.
- **Exact rendering carry** (`rational.zig`): `roundUpDecimal` dropped the carry
when the integer part was all nines, so `10 - 1/(3*10^20)` printed as
`0.00000000000000000000` in the display AND the clipboard.
- **Exact rendering of small magnitudes** (`rational.zig`, `formatter.zig`): values
below the 20-digit fractional budget rendered as zero. New
`Rational.toScientificString` renders from the rational rather than from
already-rendered text, so `2^-70` shows as `8.4703294725430034e-22`.
- **Unchecked float-to-integer conversions** (`evaluator.zig`, `programmer.zig`):
`2^64 and 1`, `~1e30` and `tally -p '1e40'` aborted the process. Now `Overflow`
or `DomainError` through `toFixedWidthBits`/`shiftAmount`.
- **`minInt(i128)` negation** (`formatter.zig`): panicked when formatting a 128-bit
value with only the sign bit set, reachable from the TUI. Now negates in the
unsigned domain.
- **Unbounded recursion** (`parser.zig`): `Parser.max_nodes` (1000) and
`max_nest_depth` (128). 4000 terms of `1+1+1+...` segfaulted `evalExact`.
- **The tautological render assertion** (`test_render.zig`): `wellFormed` compared
each row's length to the width it was allocated with and could never fail.
Replaced by `furniture(rows).intact()`, which checks the separator, prompt and
status rows. That immediately caught three layout bugs, now also fixed: the
programmer view drawing over the prompt on a short terminal (it now reports how
many rows it needs), the convert view's overflow notice erasing the unit row
above it and undercounting, and the TUI's convert value defaulting to
`Number.fromFloat(1)` so the opening screen read `999,999,999.9999999 nm`.
- Strengthened the tests the review named as coverage theater: float-view
classifications are asserted by name, the convert overflow count is asserted
exactly, programmer base rows are asserted by row index, and the financial "draws
without panicking" test now asserts every field label.
DELIBERATELY NOT FIXED, documentation corrected instead:
- `roundToCents` is half-even on the binary value, which differs from decimal
half-even on 573 of 10000 decimal ties. Fixing it means a decimal type in the
money path; the no-bias property FR-5.8 exists for still holds. Documented on
`RoundingMode`.
- The `psi` f64 factor is 1 ulp from the correctly rounded ratio because the
comptime `p/q` division rounds twice. The exact path is unaffected. The
"cannot drift apart" claim in `units.zig` has been qualified.
- The rate solver picks whichever of several roots its seed order reaches first,
and its scale-relative tolerance does not cover noise proportional to
`(1+r)^n`, so some well-posed inputs report `ConvergenceFailure`. Both are now
documented on `solveRate` rather than half-fixed.
- The unit round-trip tests are tautological (`fromBase` is the algebraic inverse
of `toBase`); they are kept because they do catch asymmetric edits to the affine
temperature conversions, and the comment claiming a stronger guarantee is gone.
- Shift-by-width-or-more clamps rather than yielding zero. It is a policy choice
(C, Zig, x86 and Java all differ); the decision belongs with the operator
unification below.
- FR-7.8 (base quick-switch keys) is removed rather than implemented: `d`, `h`,
`o` and `b` are digits in the editable value fields.
- FR-1.6 (variables limited to `A-F, X, Y, Z`) and FR-2.8 (little-endian default)
now follow the implementation, which is right in both cases.
- Documentation corrected where it described behaviour the code does not have:
`ast.zig`'s ownership header (it claimed arena allocation with no per-node
deallocation, the opposite of the truth, which is how the AST leaks happened),
the `caret` token comment, design 2.3 and 2.5 (stale AST and Environment
shapes), design 7's programmer and conversion output, design 8.1's footer, and
design 8.0/8.0.0's overclaims about hit regions and the render harness.
DE-DUPLICATION PASS (done before the human review, since duplicated logic is what
the review would have spent its time on):
- **One money formatter.** `formatter.formatAmount`/`formatMoney` in the engine;
`main.zig` and `src/tui/financial.zig` had a copy each, and all three disagreed
about overflow. The formatter returns `null` rather than `"?"`, so the CLI now
reports "an amount in this schedule is too large to format" with exit status 1
where it used to print a table of question marks and exit 0 (open item 9 below,
now closed).
- **One error phrase table.** `types.errorPhrase` owns the strings, with an
exhaustive switch and no `else`, so an added `CalcError` is a compile error. The
CLI and TUI decorate it at comptime (`switch (err) { inline else => ... }`). The
TUI's private copy had fallen behind and rendered `InvalidExpression`,
`ConvergenceFailure` and `InsufficientParameters` as "evaluation error".
- **One comma-grouping implementation and one scientific renderer.**
`writeUnsignedWithCommas` now writes plain digits and calls `writeGroupedDecimal`;
new `splitDecimalText` is the only place decimal text is taken apart. The 65-line
`scientificFromDecimalText` is gone: both magnitude branches call
`Rational.toScientificString`.
- **One negative-sqrt rule, one numeric-error mapping.** `Number.sqrt` raises
`NegativeRoot`; `number.toCalcError` is the single mapping (`evaluator.mapError`
and `units.mapNumberError` were two copies). `evalSingleArgFn` now distinguishes
"unknown name" from "bad argument", so `sqrt(-1)`, `asin(2)`, `ln(0)`,
`factorial(-1)` and `log2(0)` report `DomainError` instead of `UnknownFunction`.
- **One mode order, one form order.** `tui.mode_tabs` is indexed by the `Mode` tag
(checked at comptime) and drives Tab, Shift-Tab and the drawn bar, which were
three encodings of one sequence. Form cycling moved into
`financial.State.nextForm`/`prevForm`. That copy had a live bug: the `Form` tag is
a `u2`, so `@intFromEnum(form) + 1` overflowed and pressing Right on Amortization
panicked in a debug build. The existing test only wrapped backwards.
- **Dead code.** `engine.zig` lost 20 of 31 curated re-exports (the ones with no
caller, including `Value` after `Number` replaced it); `types.Value` and
`types.ErrorInfo` are gone; `Parser` lost `mode`, `previous`, `had_error` and
`error_pos` and `Tokenizer` lost `mode`, all written and never read, so both
`init` signatures dropped their `Mode` parameter (parsing does not depend on the
mode, only evaluation does); `Number.applyFloatFn` and `Environment.history_len`
are gone; and the `0x`/`0o`/`0b` prefix-skipping branches in the programmer view
could never run, because only formatter `raw` strings carry a prefix.
STILL OPEN, in the order I would take them:
As of the end of the file-by-file review: 18 items recorded, 7 struck as fixed, 11
live. Four of the live ones are engine correctness (1 through 5 below, minus the struck
one), four are display or interaction defects in the TUI, and the C ABI's unfinished
business is now only the JNI layer and the financial payloads (design 6.4 and Task 6.0),
since items 12 and 15 were closed when the boundary was written. Item 18 is a list of
its own: the findings from `src/tui.zig`, recorded rather than fixed at the point the
review reached that file.
1. ~~`>>` is logical in standard mode and arithmetic in programmer mode, shift
amounts wrap in one and clamp in the other, and `evaluator.zig` ignores the
configured bit width entirely.~~ FIXED. `engine/src/bitwise.zig` is the one
implementation of the eight fixed-width binary operators plus `~` and unary
minus, over values that carry their own width and signedness (a `Domain`
parameter at the time, then `IntType`, and now the `Integer` values themselves;
see Tasks 5.12 and 5.16). `evaluator.zig` and
`programmer.zig` both call it from an `inline else` prong, so an operator added
to `BinaryOp` that belongs there is a compile error rather than a silent fall
through. Decisions taken, and the behaviour changes they caused:
- Standard mode is 64-bit signed, fixed. A different width is what programmer
mode is for (FR-2.3). `~` used to read `env.programmer_config.bit_width` while
the shifts beside it ignored the setting, so `tally --bits 8 '~0'` gave 255 for
the complement and 64-bit answers for everything else.
- `>>` fills with the sign bit in both modes, `>>>` with zeros. Standard-mode
`-8 >> 1` is now -4 rather than 9223372036854775804.
- A shift distance at or beyond the width shifts everything out rather than
wrapping modulo 64 (standard) or clamping to `width - 1` (programmer):
`1 << 64` is 0, 8-bit `0xFF >>> 20` is 0, and a negative value shifted all the
way out is all sign bits, so `-8 >> 64` is -1.
- A negative shift or rotate distance is a `DomainError`. It used to be reduced
modulo the width, so `8 >> -1` quietly meant `8 >> 63`.
- Rotation stays cyclic, reducing the distance modulo the width, because that is
what a rotation is: `1 rol 65` is 2.
- An unsigned domain has no sign to extend, so `>>` is a zero fill there. The old
programmer-mode code looked at the top bit regardless of the configured
signedness, so 8-bit unsigned `0xFF >> 1` gave 0xFF instead of 0x7F.
- Bits shifted past the width are still discarded rather than wrapped
(`0b1000 << 1` is 16); `rol`/`ror` remain the operators that wrap. Zig's
saturating `<<|` was considered as a meaning for `<<<` and rejected: `>>>` is
the zero-filling variant of `>>`, so `<<<` would have to be the zero-filling
variant of `<<`, which is `<<` itself.
Standard mode still projects operands through f64 before operating and converts
the result back, so a result above 2^53 is rounded: `-8 >>> 1` prints
9.223372036854776e18 rather than 9223372036854775804. Making the fixed-width path
read and return exact integers is a separate change, worth doing with item 3
below, and it would alter how large results display.
2. Signed division and modulo in programmer mode use unsigned semantics:
`-10 / 2` gives 9223372036854775803.
3. Multi-base detail lines are computed through an f64 round trip, so
`0xDEADBEEFDEADBEEF` shows `hex: DE AD BE EF DE AD C0 00`. The exact value is
right there; the display path should use it.
4. Cross-tier `max`/`min` compare through f64 and return the wrong operand when
the values differ below f64 resolution.
5. `Rational.toFloat` double-rounds (no sticky bit), so results can be 1 ulp off.
6. Convert mode's selection zone falls through to the programmer key handler, so
Enter never fires there and printable keys mutate `prog_value`.
7. ~~Amortization rejects valid loans because the derived payment rounds down to
cents before the "covers the interest" check; the principal column is not
rounded at all.~~ Fixed by Task 5.23, though not as the item describes. The
rounding cannot cause a wrong rejection: for it to change the answer the payment
has to be within half a cent of the interest, and at that distance the interest
rounds to the same cent, so nothing reaches principal and the loan genuinely does
not amortize at the scale the schedule is computed in. What was actually wrong is
recorded in the task: the check sat in the cursor rather than in
`amortizationPayment`, so one entry point answered where another errored, and the
principal column really was unrounded.
8. History display caps at 512 flattened lines built oldest-first, so results stop
appearing after roughly 102 detailed entries. History memory is never reclaimed
(Ctrl-L frees into an arena).
9. ~~Money formatting degrades to `?` and still exits 0 at large magnitudes.~~
Fixed by the de-duplication pass above.
10. ~~Assignment parses in prefix position (`1 + x = 2` mutates `x`)~~, fixed by Task
5.19; ~~assignment to a constant name is silently discarded~~, fixed by Task 5.20.
11. ~~Literals longer than 128 characters are rejected by a fixed tokenizer buffer,
and non-decimal literals are capped at 64 bits, both below what the exact tier
supports.~~ Fixed by Task 5.18.
12. ~~`engine/src/c_api.zig` has no tests and appears in no coverage report, because
no test target builds the shared library.~~ Fixed by Task 6.0: two test layers now
cover it (Zig tests against `std.testing.allocator`, and a C program linked through
`include/tally.h`), and it has its own coverage report at 98.4%.
13. NFR-9.9 ends with "the exact fraction available", and no frontend ever asks for
it. `Rational.toFractionString` works and is tested, but its only callers are
tests: nothing in the TUI or the CLI renders `1/3` as a fraction. Wiring it
needs a display decision that does not exist yet (integers and inexact values
have no useful fraction form, so the frontend has to choose when to show the
line at all). Recorded rather than left as unused layers of forwarding code.
14. Nothing consumes `Rendered.truncated` yet, and no frontend has a yank command,
so the two halves of NFR-9.9's "marked approximate, with the exact fraction
available" and requirements.md line 237's clipboard form are engine-side only.
`Number.render` reports the fact and takes clipboard options; the TUI has yet
to draw an indicator or copy anything.
15. ~~`c_api.zig` renders nothing today, so Phase 6 has to decide how a
`Number.FormatOptions` crosses the FFI boundary.~~ Decided and built in Task 6.0:
the display budget is five fields on `tally_config`, set on the session, and the
caller reads the defaults back rather than guessing them. A test asserts a
four-fraction-digit, separator-free configuration reaches the rendering, so the
budget provably belongs to the frontend (NFR-9.9) rather than to the C layer.
16. **The last answer should be a pseudovariable in its own right** (FR-1.6.1), and
the engine does not model it as one. Task 5.20 stopped `Ans = 5` from being
silently discarded, but it did so by folding `Ans` in with `pi`, `e` and `tau`
behind one `Environment.isBuiltIn`, and the error it raises is
`AssignmentToConstant`, phrased "cannot assign to a built-in constant". `Ans` is
not a constant: it changes on every evaluation. Two things follow, neither done:
- The name and the message. A pseudovariable that the engine writes and the user
only reads wants its own error (or a shared one worded to cover both), so
`Ans = 5` does not claim `Ans` is constant.
- The read path. `getVar` special-cases the two spellings inline, ahead of the
variable map, next to the constant table. A pseudovariable is a third kind of
name and reads as one only if it is declared as one.
Scope is the immediately preceding answer and nothing else. No `Ans2`, no indexed
history: earlier results stay FR-1.7's replay, so the set is one name and the
third kind of name has exactly one member.
17. **The colour palette is a hardcoded theme.** `src/tui/draw.zig` declares `C`, a
namespace of eleven Molokai colours as literal RGB triples, and every view reads
from it directly (`C.cyan`, `C.pink`, `C.sel`). Nothing is wrong with Molokai as
the default, but it is the only possibility: there is no way to ship a light
theme, follow a terminal's own colours, or let a user pick.
What the entry-14 review left in place is the shape that makes this awkward. `C`
is a container full of `pub const`s, so a colour is resolved at compile time at
every call site. A theme has to be a value the app holds and passes, or reads
through one accessor, before any of it can change at run time. The names
themselves are also the palette's rather than the interface's: views ask for
`C.pink` where what they mean is "an error", and `C.yellow` where they mean "the
focused field". A theme that has to answer to `pink` cannot be a theme.
So the work is two steps, in order: name the roles the views actually use
(error, focus, selection, muted, accent, and so on), then make the mapping from
role to colour a value rather than a constant. Only then does the choice of
palette become data - a default, a file, or a flag. Doing the second without the
first just moves the same eleven names behind a pointer.
18. **Entry 15 review findings for `src/tui.zig`**, recorded rather than acted on. In
rough order of weight:
- **A double free on OOM in `submitConvert`.** `value` is guarded by an
`errdefer value.deinit()`, and the conversion-failure branch also calls
`value.deinit()` explicitly before two `try`s. If either the `dupe` or the
`history.append` fails, the errdefer fires on an already-freed value. The fix is
the ownership-transfer pattern: `var adopted = false; defer if (!adopted)
value.deinit();`, with the flag set where `conv_value` takes it.
- **`ProgField.expression` is a dead focus stop.** Up/Down in the value zone cycles
`bin -> expression -> bits`, but no row is drawn for it, no key does anything in
it (`handleValueInput` has an empty prong, `fieldBitStep` returns 0), and nothing
highlights it, so the user gets a keystroke that appears to do nothing. Either
drop it from the enum or make it mean what its name says and hand focus back to
the input line, which is what backtick and `focus_input` already do.
- **The decimal fields cannot be corrected or signed.** The value zone handles no
backspace, so a mistyped digit in DEC(s)/DEC(u) can only be fixed by retyping the
whole value, and `handleValueInput` treats both decimal fields as unsigned
multiply-and-add: typing into DEC(s) while it shows a negative number operates on
the two's complement bits and produces nonsense. There is also no way to type a
minus sign. FR-7.3 already carries a note about the value zone's `Enter:set`
hint; this is the same area.
- **Two copies of the bit grid's geometry.** `handleValueZoneKey` computes
`bits_per_row = if (width > 32) 32 else width` for Up/Down, and
`programmer.zig`'s `drawBitGrid` computes the same thing for drawing. Change the
grid to 16 per row and the arrows would silently disagree with the display.
- **Two copies of the float-view toggle.** `applyAction`'s `.toggle_float` and
`handleKey`'s Ctrl-F both flip `float_view_active`, call `syncFloatWidth` and set
`prog_field = .bits`; the keyboard path then re-clamps the bit cursor, which
`syncFloatWidth` has already done, and `loadAnsIntoProgrammer` re-clamps it a
third time. One `toggleFloatView` method would hold it.
- **The file header is stale.** It says "Split into sub-modules" and lists three of
the six files it imports, missing `float_view`, `convert` and `financial`.
`display_format`'s doc comment points at a `clipboard_format` that does not exist
(open item 14: nothing yanks yet). `App.env` and `init` spell
`engine.evaluator.Environment` where the `engine.Environment` alias exists.
- **`drawHistory` is open item 8's other half.** Fixing it is now a small change
rather than a redesign: fill the line window newest-first, bounded by the rows
actually visible, instead of flattening oldest-first into 512 slots and slicing
the tail. The cap then limits how far back a very tall terminal can show, not how
long a session can run before new results stop appearing.
- **The multi-base breakdown exists twice.** `submitStandard` builds
`hex:`/`oct:`/`bin:` detail lines behind `has_nondecimal_literal` and a
displayable-integer test written inline; `cli/format.zig` has the same three
labels behind `isDisplayableInt`. Same words, same predicate, two sinks. FR-5.7's
sub-bullet settled the equivalent question for the financial function list.
- **`display_format` is duplicated** between `tui.zig` and `cli/format.zig`: five
identical numbers with the same NFR-9.9 rationale in both. Per-frontend choice is
the requirement, so this may be right as it stands, but it should be a decision
rather than a coincidence.
- Cosmetics: `modeIndex` wraps `@intFromEnum` for two callers six lines below it;
`Mode` is private while `Action`, `nextMode` and `prevMode` are public and name
it; `loadAnsIntoProgrammer` bounds its "fits an integer" test with the literals
`-9223372036854775808.0` and `18446744073709551616.0`; and `handleKey` is a
200-line if-chain mixing global keys, mode-specific keys and zone dispatch, which
reads fine today but is where a new binding will end up in the wrong order.
19. **Nothing enforces the freestanding-library rule that design 6.3 depends on.** The
Android libraries must have zero `DT_NEEDED`, zero undefined dynamic symbols and no
TLS segment, or they fail in `dlopen` before any code runs. That property was restored
by hand and is checked by hand, and three things make it likely to regress silently:
any `std` call can reach for a libc symbol on a path that never executes, `BIND_NOW`
is set so even an unreferenced PLT entry is resolved at load, and the one instance
found so far reproduced on **arm64-v8a only** - so an x86_64 emulator, which is what
CI would use, passes while phones fail. It wants a build step that parses the emitted
`.so` with `std.elf` and fails on a non-empty undefined-symbol set, per ABI, rather
than a `readelf` invocation in a shell. That step is also the natural home for the
16 KiB `LOAD` alignment check, which is currently only a Play submission rule nobody
verifies locally.
---
## Phase 6: Android App
### Task 6.0: Finish the C ABI [DONE for the C layer; JNI still open]
The boundary is written, tested and cross-compiling. Three of design 6.1's four questions
are answered in code: the session is a handle, the memory model is 6.2's (session-owned
buffer, borrowed results, no `tally_result_free`), and `FormatOptions` cross as fields on
`Config`. The fourth - what the JSON carries - is answered for standard mode, programmer
mode, conversion and the units catalogue, and still open for the financial and float
views.
**Still to do before Kotlin:**
- The JNI translation layer, once design 6.4's three options are chosen between. Keep it
thin: convert, call the C ABI, copy the result into JVM memory. That copy is what makes
the borrowed-result rule safe without an app developer having to remember it.
- Financial and float-view payloads, which need form specs and a schedule shape. Better
decided against a screen than in advance.
- Toolchain through mise when the Kotlin side starts: JDK, Gradle, Android SDK, plus the
NDK if 6.4 picks the `jni.zig` route. Pinned in `.mise.toml`, each one checked to
resolve, nothing installed system-wide.
- Loading it for real: `System.loadLibrary`, symbol resolution from a JVM, and a
page-backed allocator under Android's memory pressure. The compile half is proven; none
of the runtime half is.
**What the ABI looks like.** Eight exports in `engine/src/c_api.zig`, implementing design
6.1 and 6.2: a session handle that owns the memory, results borrowed from the session's
buffer until its next call, and no `tally_result_free` at all. Standard mode, programmer
mode, unit conversion (the `to` keyword works from standard mode exactly as it does in
the CLI, FR-4.1) and a units catalogue for pickers. Results are JSON written by
`std.json`; errors carry `engine.phrase`'s wording, so a frontend that adds its own error
table is duplicating one that exists.
**Three lifetimes, as designed.** `Environment` on the session allocator, an arena per
call reset with `retain_capacity`, and a result buffer cleared rather than freed. A test
runs a hundred evaluations and asserts the result buffer's capacity never moves after the
first, which is the "steady state is near zero allocation" claim made concrete.
**Two test layers, because they catch different things.**
- Zig tests build sessions with `std.testing.allocator`, so the seam is leak-checked: a
session that forgets its arena fails here rather than on a phone. Thirteen tests cover
the session contract (`x = 2^100`, then `x + 1`, then `Ans / 2`, exact all the way
through), borrowed-result reuse, error wording, conversion, the multi-base rows,
programmer rows at a configured width and byte order, config round-tripping, the
catalogue, every malformed-call path, and the exported constructor that uses
`smp_allocator` rather than the test allocator.
- `engine/test/c_abi_test.c` links against the real library through the real
`include/tally.h` and is run by `zig build test`. This is the only layer that can catch
a header disagreeing with the library: struct field order and symbol names are
invisible from Zig. It is compiled `-std=c99 -Wall -Wextra -Werror`.
**`zig build android`** produces `zig-out/android/{arm64-v8a,x86_64,armeabi-v7a}/libtally.so`,
which is the layout Gradle's `jniLibs` expects, so packaging stays a copy and Gradle
never invokes Zig. ReleaseSmall always: ~300KB per ABI, and every one has zero
`DT_NEEDED` entries. A debug build is twenty times that and has no business in an APK.
- Verify: 956 tests pass plus the C program, fmt and zlint clean, ASCII clean.
Coverage adds a fourth report: engine 99.42%, CLI 98.87%, TUI 98.41%, ABI 98.41%, with
the ABI's five uncovered lines all out-of-memory paths. `zig build` still installs the
host library and now the header with it.
### Task 6.1: Set up Android project structure
- Create `android/` directory with Gradle project (Kotlin DSL)
- Configure for Compose, minimum SDK 26 (Android 8.0), Material 3
- Create JNI bridge class `TallyEngine.kt` with `external fun` declarations
- Set up `jniLibs/` directory structure for arm64-v8a, x86_64
- Add the Android targets to `build.zig` (`-Dtarget=aarch64-linux-android`,
`-Dtarget=x86_64-linux-android`); Task 1.1 listed this and it was never done
- Native .so files produced by `zig build -Dtarget=aarch64-linux-android` (etc.) - Gradle does not invoke Zig, it consumes prebuilt artifacts
- Set up bottom navigation bar (Standard, Programmer, Financial, Convert)
- Verify: `zig build -Dtarget=aarch64-linux-android` produces libtally.so; Gradle project builds and empty app launches with nav bar
### Task 6.2: Implement JNI bridge
- Kotlin side: `TallyEngine.evaluate(expr, mode, config): String` (returns JSON), against
whatever session shape Task 6.0 settled on
- Kotlin side: `TallyEngine.convert(value, fromUnit, toUnit): String`
- JSON deserialization into Kotlin data classes. Note that `MultiBaseResult` and
`StructLayoutResult`, named here originally, do not exist in the engine: the first
was deliberately dropped (design 2.2) because a result does not carry five
renderings of itself, and the second belongs to unbuilt FR-3. The shapes to
deserialize are whatever Task 6.0's JSON defines.
- Error handling: parse error JSON, surface to UI. The words come from
`engine.phrase`, so Kotlin should not add its own table
- Verify: unit tests calling native functions with known inputs, validating deserialized results
### Task 6.3: Implement standard calculator screen
- Compose UI: expression display at top (scrollable, natural format), live result preview
- Button grid: digits, operators, functions (swipeable function row for sin/cos/log/etc.)
- History peek below expression (last 2-3 results), pull-down for full history
- ViewModel: manages expression state, calls engine, stores history
- Long-press `=` to copy result, tap history items to recall
- Verify: can perform calculations, see results, scroll history
### Task 6.1: Set up Android project structure [DONE, unverified on a device]
`android/` is a Gradle project with one module: Compose, Material 3, minSdk 26,
`namespace`/`applicationId` `dev.lerch.tally`. `jniLibs.srcDirs` points at
`zig-out/android`, so `zig build android` output is packaged with no copy step and Gradle
never invokes Zig. `abiFilters` lists exactly the three ABIs that step produces.
`zig build android` now roots at `jni.zig` rather than `c_api.zig`, so the Android library
carries both the `Java_*` entry points and the C ABI, and sets
`link_z_max_page_size = 16384`: Android 15's 16KB pages are a Play requirement, and the
default would load fine on a 4KB device and be rejected at submission.
The toolchain is pinned in `.mise.toml` (`java = "temurin-21"`, `gradle = "8.14.5"`,
`android-sdk = "23.0"`), and `mise install` is necessary but not sufficient: it provides
`sdkmanager` but no SDK to build against, and the packages `sdkmanager` downloads land
inside the mise install directory where mise does not track them. Four mise tasks close
that gap - `android-sdk`, `android-emulator`, `android-avd`, `android-run` - so every step
is one command and the versioned packages are pinned. `platform-tools` and `emulator` have
no version in their package ids and are the one thing that stays unpinned. No NDK, by
design. The whole procedure, including deploying over USB and over wireless adb, is
`android/SETUP.md`.
The emulator image worth using is `system-images;android-35;google_apis_ps16k;x86_64`,
because its 16 KiB pages are what `link_z_max_page_size` is for. The AVD lives in
`.tmp/avd` via `ANDROID_AVD_HOME` rather than `~/.android`, which keeps it out of the home
directory and also routes around a dangling `~/.android/avd` symlink on this machine that
makes `avdmanager create` fail with nothing but a path in the error.
- Verified: `gradle assembleDebug` builds a debug APK with all three `libtally.so` files
packaged (`lib/arm64-v8a`, `lib/x86_64`, `lib/armeabi-v7a`). AGP 8.7.3, Kotlin 2.0.21
with the Compose plugin, and Compose BOM 2024.10.01 all resolve against Gradle 8.14.5.
One compile error was found and fixed on the way: Material 3's `TopAppBar` is still an
experimental API and needs an opt-in.
- Verified on an emulator: the app installs, launches, and evaluates. The 10 instrumented
tests pass (`gradle connectedAndroidTest`). Getting there found a real loading bug that
no host test could have caught - see design 6.3 - because the first APK crashed in
`dlopen` on a missing `__tls_get_addr`.
- Not verified: the Gradle wrapper is not committed, and nothing has run on a physical
device. arm64-v8a is the gap that matters, since it is the ABI phones use and the one
where the page-size bug appeared.
### Task 6.2: Implement JNI bridge [DONE for the skeleton]
`engine/src/jni.zig`, the only Android-specific code in the tree, hand-written against a
function table declared by index because the NDK is not in the picture (design 6.4). Seven
entry points: session create and free, eval, unit catalogue, version, ABI version, and
`JNI_OnLoad`, which proves the table against the running JVM and fails
`System.loadLibrary` if it does not behave.
Kotlin side: `TallyEngine` (the `external fun`s and an ABI check), `TallySession` (an
`AutoCloseable` handle, so a use-after-close is a Kotlin exception rather than a native
crash), and `TallyResult`, which parses the JSON with `org.json` - Android ships it, and
the payload is small enough that a serialization dependency would buy nothing.
Kotlin does no arithmetic, formats no numbers and words no errors. `MultiBaseResult` and
`StructLayoutResult`, which this task originally told us to deserialize, do not exist: the
first was deliberately dropped (design 2.2) and the second belongs to unbuilt FR-3.
- Verify: 965 tests pass on the host, including seven JNI tests against a synthetic
function table, and `jni.zig` is at 100% line coverage in its own report.
- And the part that was supposed to need a device does not: `zig build jvm-test` proves
the four function-table indices against a real JVM on the desktop, because JNI's table
is fixed by the specification and not by the platform. `System.loadLibrary` runs
`JNI_OnLoad`, then every entry point is driven from Java. Shifting one index by a slot
makes the step fail, so the check is known to work rather than assumed to.
### Task 6.3: Implement standard calculator screen [DONE; redesigned after the first build]
**The first version** was an expression field, the system keyboard, an `=` button and a
monospace scrollback. It proved the boundary end to end on a device and was nearly
unusable: the TUI's prompt moved onto glass, which section 9 had ruled out in so many
words. It is recorded here because the redesign is a list of answers to its failures.
**What it is now** is design 9.2: an NCalc-style pad with no system keyboard by default,
a live result from the engine's new preview (design 6.5), `=` committing and continuing
from `Ans`, variable keys whose long-press stores into them, an `abc` key for the system
keyboard, a slide-up panel for the rarer functions, and history on its own screen. Kotlin
in `MainActivity.kt` (screen, display, history), `Keypad.kt` (keys and layout) and
`TallyViewModel.kt` (state, and every engine call on one dedicated thread).
The engine half, done first and test-driven:
- `engine.previewStringInfo`, taking a `*const Environment`. Assignment is a statement
(Task 5.19), so `evalStringInfo` now unwraps the root assignment itself and the
recursive evaluator takes the environment as `const` - it can no longer store
anything, and a preview that mutates the session is a compile error, not a test. Five
evaluator tests under `std.testing.allocator`, including a hand-built nested
assignment that is refused.
- `tally_preview` in the C ABI, same signature as `tally_eval`, sharing its argument
checks; `abi_version` 2, `TALLY_ABI_VERSION 2u`, `EXPECTED_ABI_VERSION = 2`. Three
`c_api` tests, a C ABI check through the real header, a JNI test against the synthetic
table, and `jvm-test` against a real JVM.
- A side effect worth having: a failed copy no longer leaves an assignment half-made,
because the variable is stored only after the value has been copied out.
- Verified on an emulator (Android 15, x86_64, 16 KiB pages), by driving the pad:
`2+3*4` previews `= 14` before `=`; `=` shows `14` with the expression above it; `*`
then `2` reads `Ans*2` and previews `= 28`; `abc` hides the pad and opens an input
connection, `r = 5` previews `= 5`, and Enter commits and brings the pad back; the
panel slides over the digits and back; long-press `A` after `77` stores it, and `A+2`
previews `= 79`; History lists entries newest first, tapping one recalls it, and
system back returns to the calculator; `2^100` shows all 31 digits on one line.
- 11 instrumented tests pass, including the new preview test. 974 host tests pass.
- One bug found by driving it and fixed: the field wraps, so a keyboard's Enter arrived
as a newline instead of as Done. A newline from the keyboard now means `=`.
- Not verified: a physical device; a real on-screen keyboard (the emulator's IME showed
its hardware-keyboard toolbar, so the inset-based "keyboard dismissed, bring the pad
back" path was not exercised - Enter was); TalkBack, though every symbol key carries a
spoken label; and dark mode, which follows the system but was not looked at.
**Persistence, after the converter showed what it should feel like:** the calculator
was the screen that forgot - expression, answer, history, variables and `Ans` all gone
when the process died. Now all of it comes back (design 9.2, 6.7).
- Engine: `tally_session_save` / `tally_session_load`, exact for both tiers (fraction
text with every digit, f64 bits), all-or-nothing, ABI 5. `Rational.parseFraction` is
the uncapped inverse of `toFractionString`. Nine Zig tests including two
allocation-failure sweeps; a C round trip through the header; JNI against the
synthetic table and a real JVM. 992 host tests.
- App: `CalcState.kt`, plain Kotlin with six JVM tests (22 in all) - history keeps the
engine's own JSON per entry, so a restored entry is parsed exactly as a fresh one;
the newest 500 are kept; damaged lines are dropped, not guessed at; the cursor is
clamped into the text. One more instrumented test (13).
- Verified on the emulator: store 7 in A, `A x 6 =`, leave `2+` typed, force-stop in
the foreground, relaunch: `2+` is back, `2+Ans` previews 44, `A` is 7, history holds
all four entries.
- Decided: clearing history keeps variables and `Ans`. Replaying history on launch was
the alternative, and rejected - see design 6.7.
### Task 6.4: Implement programmer mode screen [DONE, first version for reaction]
Design 9.3, rewritten from the TUI's actual programmer mode and the standard screen's
pad-and-preview model; the old plan below this line (segmented 8-64, tappable primary
base, base prefix keys, struct layout button) was superseded.
- Engine (design 6.8, ABI 6): `pattern` in the programmer result, and JNI
`configureProgrammer`. 995 host tests, the C ABI check, `jvm-test`; the three Android
libraries still have no undefined symbols, TLS or `DT_NEEDED`.
- Kotlin: `Programmer.kt` (the rules: base prefixes, operator-whole backspace, `=` and
continuing from the register as a literal, bit taps, the width lens, CLR then C,
save/restore), 11 JVM tests, with a mutation check (a flip that ignores the width
lens fails them). `ProgrammerViewModel.kt` (own session and thread, newest-wins
previews, dimming held between keystrokes), `ProgrammerScreen.kt`, a drawer entry,
`Action.Digit`/`Action.Prefix` and dimmed keys in `KeyBlock`. One instrumented test
(14 pass).
- Verified on the emulator: see design 9.3.
- Follow-up from first use on the phone: a long-press on a row's label or value copies
it as a typed literal (design 9.3). And the expression line no longer goes blank while
the register holds a value: it falls back to the register as a literal, which an
operator continues from and backspace brings back to edit. Reported as "the value is
not saving"; the phone's saved state showed it was saved, and the blank line was what
made it look lost. Two more JVM tests (13), one replaying the exact state the phone
had saved; a mutation check (the old blank line) fails two of them.
- Not built: history, the float view, ASCII literals and the system keyboard, struct
layout (FR-3).
Superseded plan:
- Compose UI: segmented control for bit width (8/16/32/64)
- Value display cards: DEC, HEX, OCT, BIN, signed/unsigned (tappable to switch primary input base)
- Bit grid: custom Canvas composable, tappable cells with bit index labels, haptic on toggle
- Operator button row: AND, OR, XOR, NOT, <<, >>, ROL, ROR
- Hex digit buttons (A-F) + numeric keypad + base prefix buttons (0x, 0b)
- All displays update instantly on bit toggle or expression entry
- FAB/toolbar button navigating to struct layout sub-screen
- Verify: bit toggling works, base displays update, expressions evaluate
### Task 6.5: Implement struct visualizer screen (BLOCKED: FR-3 is unbuilt)
- Navigation: accessed from programmer mode via toolbar button
- Form-based struct builder: type dropdown + name field per row, add/remove/reorder fields
- Toggle for raw DSL text input (for power users / copy-paste from code)
- Memory map: custom Canvas composable with color-coded byte bar, tappable segments highlighting field info
- Summary card: total size, alignment, padding bytes/percentage, packed comparison
- ABI dropdown selector, endian dropdown selector
- Verify: can add fields visually, see layout update, toggle between form and raw DSL
### Task 6.6: Implement financial calculator screen
- Compose UI: scrollable chip row for function selection (CAGR, TVM, Compound Interest, Present Value)
- Card-based input form adapting to selected function
- For TVM: 5 labeled input fields, user leaves one blank to indicate solve target
- Compute button, result card with animated appearance
- Expandable formula breakdown section
- Long-press result to copy
- Verify: all financial calculations produce correct results, formula display works
### Task 6.7: Implement unit conversion screen [DONE]
Design 9.6, built as a deliberate departure from NCalc, whose converter costs Unit
Converter, then Temperature, then the input unit, before a digit - and starts there on
every launch. Here the app reopens on the converter exactly as it was left, and the
common case is: open, type, read.
- Engine: `tally_convert` (design 6.6) returns a value in every unit of its category in
one call, previewed so nothing is stored; `abi_version` 3. Four `c_api` tests (every
temperature unit exact from `100 C`, an expression value that stores nothing, an
inexact angle row, errors and malformed calls), a C ABI check through the header, JNI
tests for `convert` and `configureDisplay` against the synthetic table, and `jvm-test`.
980 host tests; the three Android libraries still have zero undefined symbols.
- Kotlin: `ConvertScreen.kt`, `ConvertViewModel.kt` (state, its own session and
thread), `Units.kt` (the catalogue and table parsed), `AppPrefs.kt` (theme, last
screen, converter state, recents). One instrumented test added; 12 pass.
- Verified on the emulator by driving it: relaunch after a force-stop opens on Convert
with the saved value, unit and dark theme; tapping the F row then typing `98.6` gives
37 C, exact; the km chip carries the number across categories; 98.6 km, tap mi, tap
km is exactly 98.6 (it was 98.6000000000 and 98,599,999,999,998.1824 nm before the
source unit was kept separate from the shown one - a bug caught by looking); the
picker shows every unit grouped by category.
- Not verified: a physical device, TalkBack, and the light theme on the converter after
the palette change.
Also in this pass, from the same request: the calculator panel's labels are now
"functions" and "numbers"; a hamburger drawer (Calculator, Convert, Settings) as in
NCalc; a theme setting - follow the device by default, or light or dark - saved and
applied to the system bars too.
**Redesigned after first use (design 9.6):** the top row became every category in
most-recently-used order behind a fixed search tile; each category remembers its own
value and units; the result list is in fixed table order with the shown unit
highlighted in place; the unit button opens the category's units only. The rules moved
into `Converter.kt`, plain Kotlin with 12 JVM unit tests (`gradle testDebugUnitTest`, no
device) - the first Kotlin tests that do not need one. A mutation check (reinstating the
old backspace bug) fails the suite, so they test what they claim. That old bug: after
tapping a row, backspace edited the typed value read as the shown unit - 98.6 km, tap
mi, backspace gave `98.` miles.
- Verified on the emulator, driven by label: 98.6 F and 180 mi each restored by one tap
on their category; tapping the C row highlights it in place with nothing moving; the
Temperature chip then reads `37 C`; search for `knot` finds Speed > kn and lands on
1 kn; a force-stop restores all of it. 12 instrumented tests pass.
- Migrates the single value an older build saved (unit tested; not exercised on a
device, since the instrumented run uninstalls the app first).
- Follow-up from first use on the phone: the unit button became a dropdown (the
separate screen was the wrong weight for 4 to 15 units), and every unit gained a
required engine-side display name (`label` in `units.zig`, in the catalogue at ABI 4),
replacing the Kotlin guess that produced "Meterpersecond". A test in `units.zig` holds
every label to non-empty, capitalised, printable ASCII and unique within its
category; 983 host tests, 12 JVM, 12 instrumented.
- Then per-category scroll position (design 9.6): the unit in the top row plus pixel
offset, in the category's saved state, optional on load so older saves still work.
Nothing but the user's finger moves the list - auto-scrolling to the chosen unit was
proposed and rejected, because it fights reading down the column. Four more JVM tests
(16), with a mutation check: reinstating the old `edited`, which rebuilt the memory
from scratch, drops the position on the first keystroke and fails the suite. On the
emulator the first fully visible row came back at the same pixel after a dropdown
choice, typing, a category switch, and a background plus force-stop.
### Task 6.8: Android polish
- Material 3 theming: DONE with Task 6.7 as light, dark and follow-the-device, on
Material's baseline palettes. Dynamic colour (Android 12+) was tried and dropped: it
made the keypad's bands light in dark mode. Open item 17 (TUI theming) is separate.
- Launcher icon: DONE. Tally marks - four strokes and the closing diagonal - on the
baseline primary purple, as an adaptive vector icon (minSdk 26, so no bitmaps) with a
monochrome layer for Android 13+ themed icons. Artwork inside the 66dp safe circle.
Installed on a Galaxy S23+; seen on the launcher by the user, not by me.
- Tablet layout: two-pane (input left, results/visualization right)
- Landscape orientation: adapted layouts (wider bit grid, side-by-side panels)
- Haptic feedback on bit toggle and button presses
- Screen rotation state preservation
- App icon, splash screen
- Verify: looks correct on phone and tablet, handles rotation, haptics fire
---
## Phase 7: Testing & Release
### Task 7.1: Comprehensive engine test suite [DONE]
- **Target: >= 80% line coverage on engine code** -- ACHIEVED: 99.69% (1607/1612 lines)
- 222 unit tests passing across engine + CLI modules (208 in the engine coverage run)
- Tokenizer tests: all bases, underscores/commas/spaces separators, string literals,
invalid/unrecognized characters, bare `<`/`>`, lone dot, huge-decimal float fallback
- Parser tests: precedence/associativity, unary ops, function calls (incl. unmatched
paren in args), keyword operators, assignment, error paths, infix-position identifier
- Evaluator tests: arithmetic, precedence, functions, variables/Ans, string literals in
standard mode (ASCII packing + non-ASCII rejection), zero/two/three-arg unknown fns, rand()
- Programmer tests: bitwise/shift/rotate at all widths, two's-complement negation, ASCII
literal packing + overflow, float-literal truncation, shift-amount >= width clamping
- Formatter tests: all bases, all bit widths, scientific notation threshold boundaries
- Coverage measured via `zig build coverage` (kcov-based)
- Remaining 5 uncovered lines are unreachable defensive branches (parser `tokenToBinaryOp`/
`keywordBinaryOp` fallbacks, gated by `infixPrecedence`) and `or` short-circuits inside
test assertions -- not worth contorting the code to reach
- Verify: coverage report shows 99.69% on engine modules (well above the >= 80% gate)
### Task 7.2: Integration tests
- CLI end-to-end tests (run binary, check stdout/stderr/exit code)
- TUI smoke tests (script key sequences, verify screen output via libvaxis test mode if available)
- Android instrumented tests for JNI bridge
### Task 7.3: CI/CD setup [DONE, unverified on the runners]
`.forgejo/workflows/build.yaml`, after the repositories in `~/src` that already run on
git.lerch.org (zfin's publish-to-generic-registry, flexilib and release-tracker's HSM
signing). Four jobs: engine (fmt, zlint, build, test, jvm-test, release builds for
x86_64/aarch64 Linux musl and aarch64 macOS), android (`zig build android`, the library
audit, JVM unit tests, debug and unsigned release APKs with lint's release checks), sign
(master only, on `ubuntu-latest-with-hsm`) and publish (master only, generic registry
under the short SHA and `latest`). ntfy notifications on every job, as elsewhere.
- **APK signing with the HSM:** `.forgejo/actions/hsm-apksign`, a docker action shaped
like action-hsm-sign (sibling container, pcscd socket, uhubctl), whose signer is the
pkcs11 image's recipe plus Debian's apksigner over SunPKCS11. Rebuilt from the recipe
rather than `FROM git.lerch.org/lobo/pkcs11:1`, which is the 2023 Debian 11 build whose
security archive has left the mirrors. Two things found by running it: apksigner's
`--provider-class` is refused by the module system on Java 17, so the JDK's own
SunPKCS11 entry is configured through `java.security.properties` instead; and Java only
offers a PKCS#11 key with a certificate, so `make-cert` writes a self-signed one to the
token once (README, "Signing"). `mise run apk-signer-test` runs the image against
SoftHSM: make-cert, sign, `apksigner verify` (v2 and v3), and the APK's signer key equal
to the token's public key. The SoftHSM-signed release APK installed and ran on the
emulator. Debian's `apksigner` package depends on the desktop JRE (X11, GTK, Mesa: 115
packages); its jar and wrapper are unpacked over the headless JRE instead (42 packages,
649 MB to 361 MB), and apt's dependency check still passes. `android/audit-libs.sh` (and `mise run android-audit`) is the symbol audit
that had been done by hand; it fails on a libc-linked library, checked.
- **Deviations from the plan above:** toolchains come from setup-zig (the version from
`build.zig.zon`) and pinned, SHA-256-checked downloads of zlint and Gradle, not mise:
mise-action hands the job's token to mise as `GITHUB_TOKEN`, and on Forgejo that is not
a GitHub token, which mise's GitHub-release backends (zlint, prek) would send to
GitHub's API. Other platforms are
cross-compiled, not built on their own runners. The coverage gate stays in pre-commit
only (wttr.in has the same step disabled in CI; not investigated). No AAB.
- **Not verified:** nothing has run on the Forgejo runners yet; the HSM and the host's
pcscd are exercised only by the first master build; `make-cert` must be run once on the
HSM host before it.
Original plan:
- Forgejo Actions workflow (at `git.lerch.org/lobo/tally`):
- Use mise to provision toolchains (same `.mise.toml` as local dev)
- Build + test on Linux, macOS, Windows
- Enforce >= 80% engine coverage (fail CI if below)
- Cross-compile all targets
- Build Android .so files
- Release workflow: produce platform binaries as Forgejo release artifacts
- Android: produce signed APK/AAB
### Task 7.4: Documentation
- `README.md`: DONE - overview, the three frontends, install and signature verification,
building, build steps and mise tasks, layout and the decisions behind it, tests, CI/CD
and the one-time signing setup.
- `CONTRIBUTING.md`: dev setup, architecture overview, how to add an ABI profile
- CLI `--help` text finalized
- Struct DSL syntax reference (in README or separate doc)
---
## Dependency Graph
```
Phase 1 (foundation) --> Phase 2 (struct + financial) --> Phase 3 (API)
|
+---------------+---------------+
v v v
Phase 4 (CLI) Phase 5 (TUI) Phase 6 (Android)
| | |
+---------------+---------------+
v
Phase 7 (Testing & Release)
```
Phases 4, 5, and 6 can be worked on in parallel once Phase 3 is complete.
---
## Estimated Effort
| Phase | Effort | Notes |
|-------|--------|-------|
| Phase 1 | 2-3 days | Core parsing + eval is the most algorithmic work |
| Phase 2 | 2-3 days | Struct layout, financial formulas, unit conversion tables |
| Phase 3 | 1 day | Thin wrappers + cross-compile verification |
| Phase 4 | 1 day | Straightforward CLI, most logic in engine |
| Phase 5 | 3-4 days | TUI widgets, bit grid interaction, struct viz, convert view |
| Phase 6 | 4-5 days | Android project setup, Compose UI (5 screens), JNI, polish |
| Phase 7 | 2 days | Testing infra, CI, docs |
| **Total** | **~15-19 days** | For a single developer working focused |