77 KiB
Tasks: Tally - Cross-Platform Calculator
References
Phase 1: Project Scaffolding & Engine Foundation
Task 1.1: Initialize workspace build structure [DONE]
- Create
.mise.tomldeclaring Zig version (pin to specific release) - Create top-level
build.zigwith 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.zigproducing static lib and shared lib targets - Create
cli/build.ziglinking engine - Create
tui/build.ziglinking engine + libvaxis dependency - Add
.gitignore,README.md(minimal),LICENSE - Verify:
mise installprovisions Zig,zig buildcompiles 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.
Task 1.2: Implement core types module [DONE, later dismantled]
- Create
engine/src/types.zig - Define
Valueunion,Integerstruct,BitWidth,Signedness,Endiannessenums - Define
MultiBaseResultstruct - Define
CalcErrorerror set andErrorInfostruct - Define
Modeenum (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, oct0o, bin0b), 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.zigwith AST node definitions - Create
engine/src/parser.zig - Implement Pratt parser with precedence table from design doc
- Accept
Modeparameter 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
f64for 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
FormattedValuestruct withdisplay(human-readable) andraw(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
displayandrawvariants - 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:Rationaloverstd.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 themax_denominator_bits = 4096demotion cap. 30 tests.- Exposed via
engine.zig(two lines) so the tests run underzig build test. Nothing else is wired: the evaluator is untouched. - Notable implementation points:
toFloatscales 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).toDecimalStringdoes long division, prints terminating expansions exactly, and rounds half-up at the digit budget while reportingexact = false.big.int'ssqrttruncates, sosqrtExactsquares the root back and compares to decide exactness.
- Test gotcha worth remembering: Zig folds float literals at comptime as
comptime_float, so0.1 + 0.2 != 0.3is 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.Numbergainstext: []const u8(the literal's source text). Additive, so the 10 parser assertions onfloat_value/int_valuekeep compiling. The text is needed becausefloat_valuehas ALREADY rounded by the time it exists:0.1cannot be recovered from its binary approximation, so exact evaluation has to re-parse the literal.evaluator.evaluatekeeps itsCalcError!f64signature but now runs onNumberinternally viaevalExact, collapsing to f64 once at the boundary. A scratch arena per evaluation keepsNumberlifetimes trivial and means the caller's allocator (an arena in the CLI, a GPA in the TUI) never holds intermediates.- Exact:
+ - * / %, integer powers,sqrtof perfect squares,abs,floor,ceil,round,factorial,max,min, and all literals. - Inexact: transcendentals, fractional powers,
sqrtof non-squares,cbrt,atan2, two-arglog,rand, and the constantspi/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(thea - b*floor(a/b)definition), and unbounded exactfactorial. - Added to
number.zig: the matching wrappers plusmax/min. rational.Error.ExponentTooLargereached the caller asCalcError.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
Ansare still stored as f64 inEnvironment, soX = 0.1round-trips through a float. Correct forpi/e/tau(irrational), a real limitation for user variables. 9007199254740993and2^53 + 1are computed exactly but cannot survive the f64 return. That is precisely what 2.0c fixes.12 in in ftis NOT fixed: unit conversion never touches the evaluator and its factors are already-rounded f64 values. See Task 2.0e.
- Variables and
- Test-oracle bug found by the instrumented coverage build: Zig's float
@modwith a NEGATIVE divisor returned-2in the normal build and1under the coverage build (comptime folding and the runtime path disagree). A test whose expected value shifts with optimize mode verifies nothing, so themodtest 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) overtesting.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 (initOwnedfreed values its callers also freed), a potential dangling copy (parts passed by value while normalization can reallocate limbs), and struct-literal leaks ininitInt/initZero/initRatiowhere anerrdefercannot cover a value that does not exist yet.floorandfactorialwere also missing a denominatorerrdefer. 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]
Environmentnow storesNumberfor variables andAns, so an assignment keeps its expression's exactness:X = 0.1stores exactly one tenth, andX + 0.2is exactly0.3.evalString/evalStringInfo/EvalInfo.valuereturnNumber, allocated in the caller's allocator. Intermediates stay in a scratch arena; only the final value is copied out.- Added
Rational.cloneWith/Number.cloneWithto copy across allocators. This is required, not convenience: aRationalcarries 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. getVarreturns a borrowed value and documents that callers mustcloneWith;setVarandsetAnscopy, so storing an arena-allocated value is safe.- FIXED A LATENT LIFETIME BUG:
setVarnow 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.formatNumberrenders exactly: exact integers print in full with comma grouping at any magnitude, exact terminating fractions print exactly, repeating expansions round toexact_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/testEvalProgrammercollapse to f64 in one place, so all ~90 pre-existing f64 assertions stayed untouched. Only 4 tests callingevalString/evalStringInfodirectly 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's2^53gate: 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'sx > 170limit was already lifted in 2.0b.) - Verified end-to-end:
9007199254740993,2^53 + 1,2^100,2^128,1e20 + 1,factorial(25),99999999999999999999999999 + 1all exact. No regressions on2 + 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]
9007199254740993round-trips (currently unguarded, and the one bug the f64 boundary cannot fix),2^53 + 1,1/3retained exactly,(1/3) * 3= 1, unboundedfactorial, 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. (@setEvalBranchQuotais needed:parseFloatis loop-heavy at comptime.) Rational.parseacceptsp/qas 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) -> Numberdoes 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'sexacttag 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 miis exactly781250/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/inpath and convert mode) all use the exact path.App.conv_valuebecame aNumberso the live convert view matches the CLI. - ALSO FIXED, found by smoke-testing the whole matrix: the
convertsubcommand only acceptedtoas a separator, soconvert 1 acre in ft2andconvert 98.6F in Cfailed. It now handles both separators with the same backtracking approach asunits.parseRequest, validating candidate readings by resolving the unit names. This also required whole-token numeric validation (isFullyNumeric): the old first-character check accepted98.6Fas 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
FieldDefstructs:{ 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
AbiProfilestruct with function pointers/vtable (field_layout, struct_alignment, tail_padding, pointer_size) - Implement
x86_64_sysvprofile:- 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
packedprofile (all alignment = 1, no padding) - Verify: unit tests matching
paholeoutput 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
StructLayoutResultand 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)androundToCents, half-even by default - Reachable from standard-mode expressions (FR-5.7):
evalFunctioninevaluator.ziggained 3- and 4-argument dispatch, andevalFinancialFnmaps the namescagr,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
Numbertier. 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 UnitCategoryenum (12 categories) withlabel()andbaseUnit()UnitDefstruct (name, aliases, category, to_base_factor, to_base_offset) withtoBase(),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) -> ConvertResultandconvertUnits(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, soK/B/kBkeep their meaning whileKMandCelsiusstill resolveparseRequest: 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 notokeyword 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 testpasses; 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) !Valuepub fn computeStructLayout(definition: []const u8, abi: AbiProfile, endian: Endianness) !StructLayoutResultpub fn computeCagr(start: f64, end: f64, periods: f64) !f64pub fn solveTvm(params: TvmParams) !TvmResultpub fn convert(value: f64, from_unit: []const u8, to_unit: []const u8) !ConvertResultpub 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) andCalcResult(json ptr/len + error ptr/len) extern structs export fn tally_eval(expr_ptr, expr_len, mode, config_ptr, config_len) CalcResultexport fn tally_struct_layout(def_ptr, def_len, abi_ptr, abi_len, endian) CalcResultexport fn tally_convert(value, from_ptr, from_len, to_ptr, to_len) CalcResultexport fn tally_result_free(result) voidexport 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 buildproduces binaries for:x86_64-linux-gnuaarch64-linux-gnux86_64-macosaarch64-macosx86_64-windows-gnu
- Verify
zig buildproduces .so for Android targets:aarch64-linux-androidx86_64-linux-android
- All targets invocable from the same
build.zigvia-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
convertIMPLEMENTED:tally convert <value> <from> [to] <to>, including the glued formtally convert 100km to mi(numeric prefix split from the unit suffix, withe/Eonly treated as an exponent when followed by a digit or sign so5eVparses as 5 eV) - Subcommands (
struct,cagr,tvm) NOT YET IMPLEMENTED - Verify: unit tests for arg parsing;
--helpprints 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:
formatConversionprints100 km = 62.13711922373339 mi, resolving aliases to canonical names - Struct mode: NOT YET IMPLEMENTED
- Financial mode: NOT YET IMPLEMENTED
--jsonflag: 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
CalcErrorto 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 Candtally 100 km to miwork without any subcommand; theconvertsubcommand remains as an explicit alias for scripting- Both
toandinact as separators - Implemented in the engine (
units.parseRequest) rather than the CLI, so the TUI standard mode gets the same behavior: typing100 km to miat 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 indoubles 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, and60 min in hall 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 cagrandtally tvmsubcommands (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 thantvm_pmt(360, 0.5, 200000, 0) tally amort(aliasedamortize) is the exception, because a schedule is a table rather than a value:--monthly,--summary,--exactflags, 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 --monthlyand 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
evalStringInforeturning value +has_nondecimal_literalflag (walks AST viahasNonDecimalLiteralto 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
detailslines; 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 - 0x0fshows 496 plus hex/oct/bin;3.14decimal only;2 + 2decimal 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: boolwithprog_field: ProgFieldenum 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 + biasdecompose(format, bits)->FloatInfo(sign, raw+unbiased exponent, significand, class, value, ULP)- Classifies normal, denormal, zero, infinity, quiet NaN, signaling NaN
reinterpretValuereinterprets 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)
ansopens the float view on the exact f64 bit pattern; integeransuses the integer views - Bit toggling in the grid updates the interpretation live
- Verify:
0x3F800000shows 1.0 (f32), special values classify correctly,3.14shows 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 (
akey), endian toggle (ekey) - "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
TvmParamsconvention; 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.zigas a pureStatestruct, so the behavior is testable without a terminal;Appembeds 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.allocatorrather than an arena, which exposed two pre-existing leaks the whole suite had been hiding:evalStringInfoandevalProgrammerStringnever 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 freeExprat 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.freeExpris 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/prevModeare one ordering used by both directions, tested as inverses - Form fields group digits as they are typed (FR-7.5.1):
200then0shows2,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 * 30shows 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.
Coverage.addModuletakes include paths instead of hardcodingengine/src, resolved to absolute paths because the substring form matched any home directory containing/src/ - TUI coverage went from unmeasured (the view modules had literally zero
instrumented lines) to 98.91%, via the render harness in
src/tui/test_render.zigand 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.compoundRateandfinancial.compoundPeriods: the compound relationship solved for its remaining two variables, both closed form, no solverfinancial.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
1instead (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, andapy(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 tocagr(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.5works) - 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
RegionSetinsrc/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.Actionunion 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 atui_teststarget rooted atsrc/tui.zig. The CLI test target roots atsrc/main.zig, which only reachestui.zigfrommain(), andmain()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,
wrapIndexwrapping/clamping, anddefaultUnitIndicesinvariants - Verify:
zig build testruns 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, withrational.Error(inherited bynumber.zig) already in place. - The unions are
||expressions, not lists:evaluator.Erroris its own four members plus its dependencies', andmessage.Erroris 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 (soevalExactneeded no annotation). number.toCalcErroris gone, and with itunits.mapNumberErrorandevaluator.mapError, which were aliases of it. Those call sites are plaintry.- Four members went away rather than staying unused:
InvalidType,InvalidFieldName,DuplicateFieldNameandStructTooLargebelonged to the struct layout module Phase 4 has not written, anderrorPhrasewas giving all four wording nothing could produce. errors.zigis gone. The wording lives inengine.zig, next to theErrorunion 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 amessage.zigof 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 noelse(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 againstError. 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.zigat 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 view:
int.as(.hex, .{ .endian = .little })returns aViewwith aformatmethod, so callers print it (w.print("hex: {f}\n", .{...})). The notation is an enum and the decoration isOptions{ separators, prefix, endian }, withOptions.plainas the no-separators, prefixed form. View.render(buf)exists for callers that need a slice, such as the TUI, which measures and positions text before drawing. It isWriter.fixedunderneath, so a short buffer iserror.WriteFailed.std.Io.Writer.printIntreplaced the hand-written digit loops for the prefixed forms, including the zero padding (.{ .fill = '0', .width = n }).digits.zigbecamegrouping.zigand holds only the thousands rule, which is the one thing both display paths share. Its digit writers went back toformatter.zigas private helpers, which is where they were before; the module had been two thirds formatter's private helpers travelling with their siblings.formatFloatnow 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.
NOT DONE, and it will be obvious when you read them: formatter.zig's remaining
functions (formatFloat, formatCompactFloat, formatNumber, formatAmount,
formatMoney) still take buffers and return FormattedValue/NumberDisplay pairs.
That conversion reaches every display call site in both frontends, including the
financial forms and the amortization table, so it is deliberately a separate pass.
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.zigandgrouping.zigat 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.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.
Integerrenders 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
Integerconcern and the thousands rule has to stay defined once:formatFloatand the decimal notations both group. That module started asdigits.zigand becamegrouping.zigin 5.16, once it held only the shared rule. Leaving the primitives informatter.zigand importing that fromInteger.zigwould 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.zigkeeps what is not a fixed-width integer: floats, exactNumbers, scientific notation, money, anddisplayWidthFor. It re-exports the two grouping functions the TUI uses on partially typed input.
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.zigandprogrammer.zigall 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.
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:CalcErroranderrorPhrase, 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 (rawandint_typeare its top-level fields). It declaresIntType(width plus signedness),BitWidthandSignedness.IntTypereplaced three shapes of the same pair:Integer{raw, bit_width, signedness},ProgrammerConfig{bit_width, signedness, display_endian}and theDomainadded tobitwise.zigduring the operator unification.Integeris now a pattern plus itsIntType, andbitwise.zigtakes anIntTypedirectly. The engine's ownEndiannessenum is gone in favour ofstd.builtin.Endian, which has the same two members.Baseintotokenizer.zig: a lexical property with two users, the lexer and the AST.ProgrammerConfigintoprogrammer.zigasConfig, holding anIntTypeand the one display preference.Modedeleted 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 afinancialmember 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 ininitand never read.src/tui.zig setModewas 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.initnow 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.zigandbitwise.zigat 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
BitWidthenum rather than a hand-written list, and a comptime check fails the build if the enum changes without the help text being updated. -h/--helpon 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 --helpinstead 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--unsignedgives 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", solog(100,10)lexed aslog(10010)and returned 4.0004, andmax(1,2)becamemax(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 tomax(1234); FR-1.8 now says so. - Exact rendering carry (
rational.zig):roundUpDecimaldropped the carry when the integer part was all nines, so10 - 1/(3*10^20)printed as0.00000000000000000000in the display AND the clipboard. - Exact rendering of small magnitudes (
rational.zig,formatter.zig): values below the 20-digit fractional budget rendered as zero. NewRational.toScientificStringrenders from the rational rather than from already-rendered text, so2^-70shows as8.4703294725430034e-22. - Unchecked float-to-integer conversions (
evaluator.zig,programmer.zig):2^64 and 1,~1e30andtally -p '1e40'aborted the process. NowOverfloworDomainErrorthroughtoFixedWidthBits/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) andmax_nest_depth(128). 4000 terms of1+1+1+...segfaultedevalExact. - The tautological render assertion (
test_render.zig):wellFormedcompared each row's length to the width it was allocated with and could never fail. Replaced byfurniture(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 toNumber.fromFloat(1)so the opening screen read999,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:
roundToCentsis 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 onRoundingMode.- The
psif64 factor is 1 ulp from the correctly rounded ratio because the comptimep/qdivision rounds twice. The exact path is unaffected. The "cannot drift apart" claim inunits.zighas 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 reportConvergenceFailure. Both are now documented onsolveRaterather than half-fixed. - The unit round-trip tests are tautological (
fromBaseis the algebraic inverse oftoBase); 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,oandbare 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), thecarettoken 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/formatMoneyin the engine;main.zigandsrc/tui/financial.zighad a copy each, and all three disagreed about overflow. The formatter returnsnullrather 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.errorPhraseowns the strings, with an exhaustive switch and noelse, so an addedCalcErroris a compile error. The CLI and TUI decorate it at comptime (switch (err) { inline else => ... }). The TUI's private copy had fallen behind and renderedInvalidExpression,ConvergenceFailureandInsufficientParametersas "evaluation error". - One comma-grouping implementation and one scientific renderer.
writeUnsignedWithCommasnow writes plain digits and callswriteGroupedDecimal; newsplitDecimalTextis the only place decimal text is taken apart. The 65-linescientificFromDecimalTextis gone: both magnitude branches callRational.toScientificString. - One negative-sqrt rule, one numeric-error mapping.
Number.sqrtraisesNegativeRoot;number.toCalcErroris the single mapping (evaluator.mapErrorandunits.mapNumberErrorwere two copies).evalSingleArgFnnow distinguishes "unknown name" from "bad argument", sosqrt(-1),asin(2),ln(0),factorial(-1)andlog2(0)reportDomainErrorinstead ofUnknownFunction. - One mode order, one form order.
tui.mode_tabsis indexed by theModetag (checked at comptime) and drives Tab, Shift-Tab and the drawn bar, which were three encodings of one sequence. Form cycling moved intofinancial.State.nextForm/prevForm. That copy had a live bug: theFormtag is au2, so@intFromEnum(form) + 1overflowed and pressing Right on Amortization panicked in a debug build. The existing test only wrapped backwards. - Dead code.
engine.ziglost 20 of 31 curated re-exports (the ones with no caller, includingValueafterNumberreplaced it);types.Valueandtypes.ErrorInfoare gone;Parserlostmode,previous,had_erroranderror_posandTokenizerlostmode, all written and never read, so bothinitsignatures dropped theirModeparameter (parsing does not depend on the mode, only evaluation does);Number.applyFloatFnandEnvironment.history_lenare gone; and the0x/0o/0bprefix-skipping branches in the programmer view could never run, because only formatterrawstrings carry a prefix.
STILL OPEN, in the order I would take them:
-
FIXED.>>is logical in standard mode and arithmetic in programmer mode, shift amounts wrap in one and clamp in the other, andevaluator.zigignores the configured bit width entirely.engine/src/bitwise.zigis the one implementation of the eight fixed-width binary operators plus~and unary minus, parameterised by aDomain(width plus signedness).evaluator.zigandprogrammer.zigboth call it from aninline elseprong, so an operator added toBinaryOpthat 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 readenv.programmer_config.bit_widthwhile the shifts beside it ignored the setting, sotally --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 >> 1is 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 << 64is 0, 8-bit0xFF >>> 20is 0, and a negative value shifted all the way out is all sign bits, so-8 >> 64is -1. - A negative shift or rotate distance is a
DomainError. It used to be reduced modulo the width, so8 >> -1quietly meant8 >> 63. - Rotation stays cyclic, reducing the distance modulo the width, because that is
what a rotation is:
1 rol 65is 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 unsigned0xFF >> 1gave 0xFF instead of 0x7F. - Bits shifted past the width are still discarded rather than wrapped
(
0b1000 << 1is 16);rol/rorremain 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 >>> 1prints 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. - Standard mode is 64-bit signed, fixed. A different width is what programmer
mode is for (FR-2.3).
-
Signed division and modulo in programmer mode use unsigned semantics:
-10 / 2gives 9223372036854775803. -
Multi-base detail lines are computed through an f64 round trip, so
0xDEADBEEFDEADBEEFshowshex: DE AD BE EF DE AD C0 00. The exact value is right there; the display path should use it. -
Cross-tier
max/mincompare through f64 and return the wrong operand when the values differ below f64 resolution. -
Rational.toFloatdouble-rounds (no sticky bit), so results can be 1 ulp off. -
Convert mode's selection zone falls through to the programmer key handler, so Enter never fires there and printable keys mutate
prog_value. -
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.
-
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).
-
Money formatting degrades toFixed by the de-duplication pass above.?and still exits 0 at large magnitudes. -
Assignment parses in prefix position (
1 + x = 2mutatesx), and assignment to a constant name is silently discarded. -
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.
-
engine/src/c_api.zighas 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.
Phase 6: Android App
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.ktwithexternal fundeclarations - Set up
jniLibs/directory structure for arm64-v8a, x86_64 - 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-androidproduces 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) - Kotlin side:
TallyEngine.structLayout(definition, abi, endian): String - Kotlin side:
TallyEngine.convert(value, fromUnit, toUnit): String - JSON deserialization into Kotlin data classes (Value, MultiBaseResult, StructLayoutResult, ConvertResult, etc.)
- Error handling: parse error JSON, surface to UI
- 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
- 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/keywordBinaryOpfallbacks, gated byinfixPrecedence) andorshort-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.tomlas local dev) - Build + test on Linux, macOS, Windows
- Enforce >= 80% engine coverage (fail CI if below)
- Cross-compile all targets
- Build Android .so files
- Use mise to provision toolchains (same
- 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 examplesCONTRIBUTING.md: dev setup, architecture overview, how to add an ABI profile- CLI
--helptext 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 |