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

778 lines
44 KiB
Markdown

# Tasks: Tally - Cross-Platform Calculator
## References
- #[[file:.kiro/specs/calculator/requirements.md]]
- #[[file:.kiro/specs/calculator/design.md]]
---
## Phase 1: Project Scaffolding & Engine Foundation
### Task 1.1: Initialize workspace build structure [DONE]
- Create `.mise.toml` declaring Zig version (pin to specific release)
- Create top-level `build.zig` with module declarations for engine, cli, tui
- Add Android cross-compilation targets to `build.zig` (`-Dtarget=aarch64-linux-android`, `-Dtarget=x86_64-linux-android`)
- Create `engine/build.zig` producing static lib and shared lib targets
- Create `cli/build.zig` linking engine
- Create `tui/build.zig` linking engine + libvaxis dependency
- Add `.gitignore`, `README.md` (minimal), `LICENSE`
- Verify: `mise install` provisions Zig, `zig build` compiles without errors on empty main files
NOTE: Actual structure diverged from spec - single binary at `src/main.zig`
(CLI + TUI combined), engine as static lib + shared lib. No separate cli/ or
tui/ build files. kcov-based coverage wired in via `build/Coverage.zig`.
### Task 1.2: Implement core types module [DONE]
- 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
### 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) [NOT STARTED]
Design: see design.md 2.7. Requirements: NFR-9.
Motivation (all reproducible today): `0.1 + 0.2` = `0.30000000000000004`,
`9007199254740993` silently becomes `9.007199254740992e15`, `2^53 + 1` collapses,
`12 in in ft` = `0.9999999999999998`, `factorial(171)` reports "unknown function".
Sequenced as four reviewable commits so structural churn never mixes with
behavioral change (rationale in design.md 2.7.9):
#### 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` maps to `CalcError.Overflow`.
- 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
- Create `engine/src/financial.zig`
- Implement CAGR: `(end/start)^(1/n) - 1`
- Implement compound interest (future value): `PV * (1 + r/m)^(m*t)`
- Implement present value: `FV / (1 + r/m)^(m*t)`
- Implement TVM solver:
- Closed-form for FV, PV, PMT, N
- Newton-Raphson for I/Y (rate) with convergence check, max 1000 iterations, tolerance 1e-10
- Return step-by-step formula string alongside numeric result
- Verify: unit tests against known financial calculation results (textbook examples)
### 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
---
## 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 [PARTIAL]
- Programmer mode accessible via Tab key
- Multi-base result display (dec signed, unsigned, hex, oct, bin)
- Bit grid with navigation (arrows) and toggling (Space)
- Width toggle (Ctrl-W cycles 8->16->32->64)
- Ctrl-B toggles focus between input and bit grid
- Expression evaluation through programmer engine
- DONE: basic passive display + expression input + bit grid toggling
- NOT DONE: editable fields (see 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
- Create `tui/src/views/financial.zig`
- Form-style interface: labeled input fields for each financial function
- Sub-mode selector: CAGR, Compound Interest, TVM
- For TVM: 5 input fields, leave one empty to solve
- Result display with formula breakdown
- Verify: can input values, compute results, see formula steps
### 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
---
## 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.kt` with `external fun` declarations
- 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-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)
- 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`/
`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 |