tally/.kiro/specs/calculator/tasks.md
2026-09-24 08:19:59 -07:00

125 KiB

Tasks: Tally - Cross-Platform Calculator

References


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 UnitDefs 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 Numbers, 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 Integers 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, 5 struck as fixed, 13 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 three are the C ABI's unfinished business (12, 15, and the session-handle question in design 6.1), which is what the Android app will meet first. 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. Deferred until Phase 6 gives it a caller; recorded here so it is a known gap rather than an oversight.

  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. It must be parameters the caller passes, not a default the C layer invents, or Android inherits a budget chosen for an 80-column terminal (Task 5.17). Task 6.2 covers the bridge; this is the display half of it.

  16. The last answer should be a pseudovariable in its own right (FR-1.6.1), and the engine does not model it as one. Task 5.20 stopped Ans = 5 from being silently discarded, but it did so by folding Ans in with pi, e and tau behind one Environment.isBuiltIn, and the error it raises is AssignmentToConstant, phrased "cannot assign to a built-in constant". Ans is not a constant: it changes on every evaluation. 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 consts, 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 trys. 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.

Phase 6: Android App

Task 6.0: Finish the C ABI (PREREQUISITE, not started)

Nothing in Phase 6 can be verified until the library answers. c_api.zig is the root of libtally.so and has three exports: tally_version works, tally_eval and tally_result_free have // TODO: implement bodies, and no test builds the library, so it appears in no coverage report (open items 12 and 15).

  • Toolchain first, and all of it through mise: pin the JDK, Gradle and the Android SDK (plus the NDK if design 6.4 picks the jni.zig route) in .mise.toml, and check each one resolves before anything depends on it. Nothing gets installed system-wide.

  • Decide the four open questions in design 6.1, in this order: session lifetime (an opaque handle, or a calculator with no variables and no Ans), memory ownership (design 6.2 recommends a session-owned buffer with borrowed results and no tally_result_free), who supplies FormatOptions, and what the JSON carries.

  • Confirm the ground first: DONE for the compile half. Both Android ABIs build a dependency-free ~230KB shared object containing the whole engine, with no NDK (design 6.2). What remains is loading it: System.loadLibrary, symbol resolution from a JVM, and the Kotlin binding choice in design 6.4.

  • Implement tally_eval and tally_result_free against those decisions, including which allocator the library owns and how a caller frees what it is handed.

  • Add the units and financial enumerations Android needs for its pickers, so the category list, the unit list per category and the form field specs come from the engine rather than being retyped in Kotlin.

  • Write engine/src/jni.zig: the Java_* entry points, compiled only for Android behind a build option (design 6.3). Keep it thin - convert, call the C ABI, copy the result into JVM memory - because that copy is what makes the borrowed-result rule in 6.2 safe without asking an app developer to remember it.

  • Hand-write include/tally.h and have a test compile against the real exports, and add tally_abi_version() beside tally_version() (design 6.3).

  • Test the ABI from Zig, calling the exports the way JNI will: a test target that builds the shared library and exercises each export, including the free path under a failing allocator.

  • Verify: zig build test covers c_api.zig, it appears in a coverage report, and a round trip (x = 5, then x * 2) works across the boundary.

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.4: Implement programmer mode screen

  • 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

  • Compose UI: scrollable category chip row at top
  • Large editable number input (top), from-unit selector dropdown
  • Swap button (animated rotation) between from/to
  • Live result display (bottom), to-unit selector dropdown
  • Numeric keypad (no expression input needed - just digits, decimal, sign)
  • Unit selectors open searchable bottom sheets
  • Conversion updates live as user types (no "calculate" button)
  • Verify: live conversion works, category switching filters units, swap works

Task 6.8: Android polish

  • Material 3 theming (dynamic color on Android 12+)
  • 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

  • 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: project overview, build instructions, usage examples
  • 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