1075 lines
64 KiB
Markdown
1075 lines
64 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) [DONE]
|
|
Design: see design.md 2.7. Requirements: NFR-9.
|
|
|
|
Motivation (all of these were reproducible before this task, and are fixed now):
|
|
`0.1 + 0.2` = `0.30000000000000004`, `9007199254740993` silently became
|
|
`9.007199254740992e15`, `2^53 + 1` collapsed, `12 in in ft` =
|
|
`0.9999999999999998`, `factorial(171)` reported "unknown function".
|
|
|
|
Sequenced as four reviewable commits so structural churn never mixes with
|
|
behavioral change (rationale in design.md 2.7.9), plus 2.0e for the unit factors:
|
|
|
|
#### 2.0a: Add the types, wired to nothing [DONE]
|
|
- `engine/src/rational.zig`: `Rational` over `std.math.big.int.Managed`, always
|
|
reduced, `q > 0`. add, sub, mul, div, negate, abs, `powInt` (negative exponents
|
|
take the exact reciprocal), `sqrtExact` (null for non-perfect-squares so the
|
|
caller can fall back), order/eql, `parseDecimal` (exact: `0.1` -> `1/10`),
|
|
`isTerminating`, `denBitCount`, `toFloat`, `toFractionString`,
|
|
`toDecimalString`. 55 tests.
|
|
- `engine/src/number.zig`: `Number = union(enum) { exact: Rational, inexact: f64 }`
|
|
with the contagion rule and the `max_denominator_bits = 4096` demotion cap.
|
|
30 tests.
|
|
- Exposed via `engine.zig` (two lines) so the tests run under `zig build test`.
|
|
Nothing else is wired: the evaluator is untouched.
|
|
- Notable implementation points:
|
|
- `toFloat` scales the numerator to ~72 significant bits before dividing, so
|
|
the result is a single correct rounding rather than a quotient of two
|
|
separately-rounded floats (which would lose precision twice and overflow for
|
|
large values).
|
|
- `toDecimalString` does long division, prints terminating expansions exactly,
|
|
and rounds half-up at the digit budget while reporting `exact = false`.
|
|
- `big.int`'s `sqrt` truncates, so `sqrtExact` squares the root back and
|
|
compares to decide exactness.
|
|
- Test gotcha worth remembering: Zig folds float literals at comptime as
|
|
`comptime_float`, so `0.1 + 0.2 != 0.3` is FALSE at comptime. Comparisons meant
|
|
to demonstrate f64 error must use runtime-known values (`var x: f64 = 0.1; _ = &x;`).
|
|
- Verify: 487 tests pass (was 402), zig fmt and zlint clean, existing tests
|
|
untouched.
|
|
|
|
#### 2.0b: Evaluator internals become exact, `evalString` still returns f64 [DONE]
|
|
- `ast.Expr.Number` gains `text: []const u8` (the literal's source text). Additive,
|
|
so the 10 parser assertions on `float_value`/`int_value` keep compiling. The
|
|
text is needed because `float_value` has ALREADY rounded by the time it exists:
|
|
`0.1` cannot be recovered from its binary approximation, so exact evaluation has
|
|
to re-parse the literal.
|
|
- `evaluator.evaluate` keeps its `CalcError!f64` signature but now runs on
|
|
`Number` internally via `evalExact`, collapsing to f64 once at the boundary.
|
|
A scratch arena per evaluation keeps `Number` lifetimes trivial and means the
|
|
caller's allocator (an arena in the CLI, a GPA in the TUI) never holds
|
|
intermediates.
|
|
- Exact: `+ - * / %`, integer powers, `sqrt` of perfect squares, `abs`, `floor`,
|
|
`ceil`, `round`, `factorial`, `max`, `min`, and all literals.
|
|
- Inexact: transcendentals, fractional powers, `sqrt` of non-squares, `cbrt`,
|
|
`atan2`, two-arg `log`, `rand`, and the constants `pi`/`e`/`tau`.
|
|
- Fixed-width integer operators (`& | xor << >> >>> rol ror`, `~`) stay on the
|
|
float/integer projection: they are not rational arithmetic.
|
|
- Added to `rational.zig`: `floor`, `ceil`, `round`, `mod` (the
|
|
`a - b*floor(a/b)` definition), and unbounded exact `factorial`.
|
|
- Added to `number.zig`: the matching wrappers plus `max`/`min`.
|
|
- `rational.Error.ExponentTooLarge` 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 [DONE]
|
|
- Created `engine/src/financial.zig`
|
|
- CAGR: `(end/start)^(1/n) - 1`
|
|
- Compound interest (future value): `PV * (1 + r/m)^(m*t)`; present value as its inverse
|
|
- TVM solver over `TvmParams`/`TvmSolution`: exactly one of the five variables is
|
|
null and gets solved for
|
|
- Closed-form for FV, PV, PMT; logarithmic for N
|
|
- Newton for I/Y, max 1000 iterations, tolerance 1e-10
|
|
- `TvmVariable.label()` gives the calculator names (N, I/Y, PV, PMT, FV)
|
|
- Amortization (FR-5.6): `amortizationPayment`, `amortizationEntry`,
|
|
`amortizationSchedule`, `amortizationTotals`, sharing one cursor so a single row
|
|
cannot disagree with the same row in a table. Final period settles the balance
|
|
exactly; overlarge payments end early; underfunded terms balloon
|
|
- Money rounding: `roundToScale(value, decimals, mode)` and `roundToCents`,
|
|
half-even by default
|
|
- Reachable from standard-mode expressions (FR-5.7): `evalFunction` in
|
|
`evaluator.zig` gained 3- and 4-argument dispatch, and `evalFinancialFn` maps
|
|
the names `cagr`, `fv`, `pv`, `tvm_pmt`/`tvm_fv`/`tvm_pv`/`tvm_n`/`tvm_rate`,
|
|
`amort_payment`, `amort_interest`/`_principal`/`_balance`,
|
|
`amort_total_interest`/`_total_paid`
|
|
- CLI `tally amort <principal> <rate> <periods> [payment]` with `--monthly`,
|
|
`--summary`, `--exact` (FR-6.11), since a table is not an expression value
|
|
- DESIGN CHANGE from the original spec: f64 rather than the exact `Number` tier.
|
|
Every formula needs a non-integer power or a logarithm, so nothing exact
|
|
survives; money is handled by explicit rounding instead (design.md 5.6)
|
|
- DEFERRED from the original spec: the step-by-step formula string. The
|
|
expression-function route made the formula visible in the input itself, and the
|
|
breakdown belongs with the TUI financial form (Task 5.6)
|
|
- 60 financial tests plus 12 evaluator tests and 10 CLI tests; financial.zig is
|
|
100% covered apart from an unreachable test-sweep guard
|
|
|
|
### Task 2.6: Implement unit conversion engine [DONE]
|
|
- Created `engine/src/units.zig`
|
|
- `UnitCategory` enum (12 categories) with `label()` and `baseUnit()`
|
|
- `UnitDef` struct (name, aliases, category, to_base_factor, to_base_offset) with
|
|
`toBase()`, `fromBase()`, `isLinear()`
|
|
- Conversion tables for all 12 categories: length, mass, temperature, time,
|
|
digital storage, speed, area, volume, energy, pressure, data rate, angle
|
|
- `convert(value, from_name, to_name) -> ConvertResult` and
|
|
`convertUnits(value, from, to) -> f64`
|
|
- Temperature is the only affine category (F/K/R need offsets); base is Celsius
|
|
- `findUnit`: exact case-sensitive match first, case-insensitive fallback, so
|
|
`K`/`B`/`kB` keep their meaning while `KM` and `Celsius` still resolve
|
|
- `parseRequest`: recognizes free-form `<value> <unit> to <unit>` text so
|
|
conversion is plain expression input, not a special command (see Task 4.4).
|
|
Returns null with no `to` keyword so callers fall through to normal evaluation.
|
|
- DESIGN CHANGE from the original spec: only the to-base mapping is stored and
|
|
the inverse is derived in `fromBase()`, instead of storing a second
|
|
factor/offset pair. One source of truth per unit means a table entry cannot
|
|
disagree with its own reverse (see design.md 4.1)
|
|
- 100 unit tests: known-answer conversions per category, temperature offsets and
|
|
the -40 crossover, alias resolution, error paths, request parsing (glued
|
|
values, expression values, longest-suffix and token-boundary rules), plus
|
|
invariant tests (every unit round-trips, every within-category pair
|
|
round-trips, base units are identity, names unique, no zero factors)
|
|
- Verify: `zig build test` passes; CLI spot-checks match reference values
|
|
(5280 ft/mi, 1024 MiB/GiB, 14.6959 psi/atm, 1 c = 1079252848.8 km/h)
|
|
|
|
---
|
|
|
|
## Phase 3: Engine Public API & C ABI
|
|
|
|
### Task 3.1: Implement engine public API
|
|
- Create `engine/src/engine.zig`
|
|
- `pub fn evaluate(env: *Environment, input: []const u8) !Value`
|
|
- `pub fn computeStructLayout(definition: []const u8, abi: AbiProfile, endian: Endianness) !StructLayoutResult`
|
|
- `pub fn computeCagr(start: f64, end: f64, periods: f64) !f64`
|
|
- `pub fn solveTvm(params: TvmParams) !TvmResult`
|
|
- `pub fn convert(value: f64, from_unit: []const u8, to_unit: []const u8) !ConvertResult`
|
|
- `pub fn formatMultiBase(integer: Integer) MultiBaseResult`
|
|
- Verify: integration tests exercising the full pipeline (string in -> result out)
|
|
|
|
### Task 3.2: Implement C API
|
|
- Create `engine/src/c_api.zig`
|
|
- Define `CalcString` (ptr + len) and `CalcResult` (json ptr/len + error ptr/len) extern structs
|
|
- `export fn tally_eval(expr_ptr, expr_len, mode, config_ptr, config_len) CalcResult`
|
|
- `export fn tally_struct_layout(def_ptr, def_len, abi_ptr, abi_len, endian) CalcResult`
|
|
- `export fn tally_convert(value, from_ptr, from_len, to_ptr, to_len) CalcResult`
|
|
- `export fn tally_result_free(result) void`
|
|
- `export fn tally_version(out_len) [*]const u8`
|
|
- **No null-terminated strings** - all inputs are (ptr, len) pairs
|
|
- JSON serialization for all result types
|
|
- Input length validation (reject > max expression length)
|
|
- Verify: build shared library, write a C test program that calls the API with known inputs and validates JSON output
|
|
|
|
### Task 3.3: Cross-compilation verification
|
|
- Verify `zig build` produces binaries for:
|
|
- `x86_64-linux-gnu`
|
|
- `aarch64-linux-gnu`
|
|
- `x86_64-macos`
|
|
- `aarch64-macos`
|
|
- `x86_64-windows-gnu`
|
|
- Verify `zig build` produces .so for Android targets:
|
|
- `aarch64-linux-android`
|
|
- `x86_64-linux-android`
|
|
- All targets invocable from the same `build.zig` via `-Dtarget=<triple>`
|
|
- Verify mise-provisioned Zig handles all targets without additional toolchain installs
|
|
- Document any target-specific issues or workarounds needed
|
|
|
|
---
|
|
|
|
## Phase 4: CLI Frontend
|
|
|
|
### Task 4.1: Implement CLI argument parsing [DONE]
|
|
- Single binary at `src/main.zig` (not separate cli/)
|
|
- Parse args: positional expression (multi-arg joined with spaces), `-p`/`--programmer`, `--help`, `--version`
|
|
- Subcommand `convert` IMPLEMENTED: `tally convert <value> <from> [to] <to>`,
|
|
including the glued form `tally convert 100km to mi` (numeric prefix split from
|
|
the unit suffix, with `e`/`E` only treated as an exponent when followed by a
|
|
digit or sign so `5eV` parses as 5 eV)
|
|
- Subcommands (`struct`, `cagr`, `tvm`) NOT YET IMPLEMENTED
|
|
- Verify: unit tests for arg parsing; `--help` prints usage
|
|
|
|
NOTE: Multi-arg joining implemented (`tally 2 + 2` works same as `tally "2+2"`).
|
|
Remaining subcommands deferred until their engine modules exist.
|
|
|
|
### Task 4.2: Implement CLI output formatting [DONE]
|
|
- Standard mode: print result with comma formatting
|
|
- Programmer mode: print multi-base table (dec signed, dec unsigned, hex, bin)
|
|
- Convert: `formatConversion` prints `100 km = 62.13711922373339 mi`, resolving
|
|
aliases to canonical names
|
|
- Struct mode: NOT YET IMPLEMENTED
|
|
- Financial mode: NOT YET IMPLEMENTED
|
|
- `--json` flag: NOT YET IMPLEMENTED
|
|
- Verify: unit tests for evaluate and formatConversion output
|
|
|
|
### Task 4.3: CLI integration and error handling [DONE]
|
|
- Wire arg parser -> engine calls -> output formatter
|
|
- Map `CalcError` to user-friendly stderr messages
|
|
- Set exit codes: 0 success, 1 error
|
|
- Verify: unit tests for error paths
|
|
|
|
|
|
### Task 4.4: Bare-expression unit conversion [DONE]
|
|
- `tally 32F in C` and `tally 100 km to mi` work without any subcommand; the
|
|
`convert` subcommand remains as an explicit alias for scripting
|
|
- Both `to` and `in` act as separators
|
|
- Implemented in the engine (`units.parseRequest`) rather than the CLI, so the TUI
|
|
standard mode gets the same behavior: typing `100 km to mi` at the prompt
|
|
converts and records it in history
|
|
- Value may be a full expression (`2*3 kg to lb`), evaluated by the normal
|
|
evaluator before conversion
|
|
- `in` doubles as the name for inches, so candidate separators are tried
|
|
last-to-first and the first valid reading wins: `5 in in cm`, `100 mm in in`,
|
|
`1 in in in`, and `60 min in h` all resolve correctly
|
|
- Detection is conservative: only a standalone whitespace-delimited separator
|
|
triggers it, unit suffixes must start at a token boundary, and no separator
|
|
means normal evaluation. Verified no regression on `2 + 3 * 4`, `sqrt(144)`,
|
|
`e`, `min(3, 7)`, `sin(0)`, `0o777 - 0x0f`
|
|
- 30 request-parsing tests plus 7 CLI-level tests
|
|
- Verify: bare conversions, expression values, inch ambiguity, error paths, and
|
|
unchanged behavior for ordinary expressions all confirmed by running the binary
|
|
|
|
### Task 4.5: Financial functions in expressions + amort subcommand [DONE]
|
|
- Financial math is reachable from any expression rather than only a mode
|
|
(FR-5.7), the same way conversion is: `tally 'cagr(10000, 25000, 5) * 100'`
|
|
- SUPERSEDES the originally planned `tally cagr` and `tally tvm` subcommands
|
|
(FR-6.6, FR-6.7). A subcommand per formula buys nothing once the functions
|
|
compose inside expressions, and flag-per-variable parsing is more to type than
|
|
`tvm_pmt(360, 0.5, 200000, 0)`
|
|
- `tally amort` (aliased `amortize`) is the exception, because a schedule is a
|
|
table rather than a value: `--monthly`, `--summary`, `--exact` flags, money
|
|
formatted with thousands separators and two decimals
|
|
- Domain errors from loan terms get a message that names the likely cause instead
|
|
of a bare "domain error"
|
|
- Help text lists the financial functions and both subcommands
|
|
- 10 CLI tests (arg parsing, flags, money formatting, table shape, error path)
|
|
- Verify: ran `tally amort 200000 6 360 --monthly` and confirmed the first row
|
|
(1,199.10 / 1,000.00 / 199.10 / 199,800.90), the balloon and early-payoff
|
|
variants, the zero-rate case, and a total interest of 231,677.04 against an
|
|
independent calculation
|
|
|
|
---
|
|
|
|
## Phase 5: TUI Frontend
|
|
|
|
### Task 5.1: Set up TUI skeleton [DONE]
|
|
- Single binary at `src/main.zig` + `src/tui.zig` (not separate tui/)
|
|
- libvaxis vxfw.App with custom Widget (eventHandler + drawFn)
|
|
- No args launches TUI; with args runs CLI
|
|
- Molokai color theme
|
|
- Verify: TUI launches, responds to Ctrl-C (quit)
|
|
|
|
### Task 5.2: Implement standard mode TUI view [DONE]
|
|
- Expression input via vxfw.TextField (gap buffer)
|
|
- Result display in scrollable history panel
|
|
- History navigation with up/down arrows (saves/restores current input)
|
|
- Ctrl-L clears history
|
|
- ? shows help overlay (keybindings, functions, constants, operators)
|
|
- Variables persist across expressions (assignment + Ans)
|
|
- Verify: can type expressions, see results, browse history
|
|
|
|
### Task 5.2.1: Inline multi-base result in standard mode [DONE]
|
|
- Engine: added `evalStringInfo` returning value + `has_nondecimal_literal` flag
|
|
(walks AST via `hasNonDecimalLiteral` to detect any hex/oct/bin number literal)
|
|
- Added `BitWidth.smallestFor(value)` helper
|
|
- CLI: when flag is true and result is a non-negative integer, shows hex/oct/bin below decimal
|
|
- TUI: history entries carry optional `details` lines; drawHistory reworked to
|
|
render variable-height entries (flat line list, newest anchored at bottom)
|
|
- Uses smallest standard bit width that holds the value
|
|
- Does NOT change evaluation (still f64, `^` still power) - display only
|
|
- Fractional/negative results show decimal only
|
|
- Verify: `0o777 - 0x0f` shows 496 plus hex/oct/bin; `3.14` decimal only;
|
|
`2 + 2` decimal only; `0xFF / 2` = 127.5 decimal only
|
|
|
|
### Task 5.3: Implement programmer mode TUI view - expression & base display [DONE]
|
|
- Programmer mode accessible via Tab key
|
|
- Multi-base result display (dec signed, unsigned, hex, oct, bin, ASCII)
|
|
- Bit grid with navigation (arrows) and toggling (Space)
|
|
- Width toggle (Ctrl-W cycles 8->16->32->64->128)
|
|
- Backtick toggles focus between input and value zone (replaced the original
|
|
Ctrl-B plan)
|
|
- Expression evaluation through programmer engine
|
|
- Editable fields delivered by Task 5.3.1
|
|
|
|
### Task 5.3.1: Programmer mode editable fields rework [DONE]
|
|
- Replaced `bit_grid_active: bool` with `prog_field: ProgField` enum tracking focused field
|
|
- Up/Down arrows navigate between fields (bits, dec_signed, dec_unsigned, hex, oct, bin, expression)
|
|
- When a text field is focused, the input line shows the field label as prompt (e.g. "HEX> ")
|
|
- Enter in a non-expression field parses input as that base and updates prog_value
|
|
- Bit grid: left/right navigates bits, space toggles, up/down exits to adjacent field when at grid edge
|
|
- Focused field is visually highlighted (label gets inverse color)
|
|
- Removed Ctrl-B (field navigation replaces it)
|
|
- Expression field works as before (full programmer-mode expression evaluation)
|
|
- ASCII row under HEX (read-only, xxd-style, aligned column-for-column)
|
|
- Endianness toggle (Ctrl-E): flips HEX and ASCII byte order, header shows LE/BE.
|
|
Default big-endian so HEX reads as the number (matching DEC/OCT/BIN); LE is the
|
|
opt-in x86-64 memory-layout view. DEC/OCT/BIN and the hex `raw`/clipboard form
|
|
are unaffected. Covered by formatter unit tests (BE and LE for hex and ascii).
|
|
- Verify: can arrow between fields, type hex value and see all fields update, toggle bits
|
|
|
|
### Task 5.3.2: Implement text/ASCII literal parsing [DONE]
|
|
- Added single-quote string token to the tokenizer (`'hello'`)
|
|
- Parser recognizes string literals as a new AST node type
|
|
- Programmer evaluator packs ASCII bytes into u128 value (BE packing: first char -> MSB)
|
|
- Standard evaluator also supports it (returns f64)
|
|
- Enforces max length = bit_width / 8 (Overflow error if too many chars)
|
|
- Rejects non-ASCII characters (> 0x7F)
|
|
- 128-bit width also added (u64 -> u128 throughout engine)
|
|
- Verify: `'A'` = 0x41, `'ELF'` = 0x454C46, overflow on too-long strings
|
|
|
|
### Task 5.3.3: Implement IEEE 754 float interpretation [DONE]
|
|
- Engine module `engine/src/float_interp.zig`:
|
|
- `FloatFormat` (f32/f64) with total/exponent/significand bit counts + bias
|
|
- `decompose(format, bits)` -> `FloatInfo` (sign, raw+unbiased exponent,
|
|
significand, class, value, ULP)
|
|
- Classifies normal, denormal, zero, infinity, quiet NaN, signaling NaN
|
|
- `reinterpretValue` reinterprets low bits as the float value (exact f32->f64)
|
|
- `nearestBits(format, value)` finds the closest representable pattern and
|
|
reports whether rounding occurred (detectable for f32)
|
|
- ULP computed from the exponent (2^(unbiased - significand_bits); subnormals
|
|
step by the smallest positive subnormal); NaN for inf/NaN
|
|
- 21 unit tests: 0x3F800000=1.0, special values, denormals, ULP, decimal
|
|
rounding (3.14 rounds in f32), overflow to inf, NaN handling, round trips
|
|
- TUI overlay (`src/tui/float_view.zig`, toggled with Ctrl-F in programmer mode):
|
|
- Color-coded bit grid: sign=pink, exponent=cyan, significand=green
|
|
- Shows value, classification, formula ((-1)^s x 2^e x mantissa), ULP, and
|
|
raw fields (sign, biased/unbiased exponent + bias, significand hex)
|
|
- Ctrl-W toggles f32/f64 and auto-snaps the bit width (32/64)
|
|
- Typing a decimal float (e.g. `3.14`) stores its nearest bit pattern and
|
|
notes rounding; non-float input falls back to the integer expression engine
|
|
- Auto-entry: Tab into programmer mode with a non-integer (or inf/NaN) `ans`
|
|
opens the float view on the exact f64 bit pattern; integer `ans` uses the
|
|
integer views
|
|
- Bit toggling in the grid updates the interpretation live
|
|
- Verify: `0x3F800000` shows 1.0 (f32), special values classify correctly,
|
|
`3.14` shows nearest representation with rounding note
|
|
|
|
### Task 5.4: Implement programmer mode TUI view - bit grid [DONE - merged into 5.3]
|
|
- Bit grid widget: 8 bits per group, groups separated by space
|
|
- Arrow key navigation (left/right moves cursor across bits, up/down between rows for 32/64-bit)
|
|
- Space/Enter toggles the bit under cursor
|
|
- Bit changes immediately reflected in all base displays
|
|
- Typing a value in the expression input updates the grid
|
|
- Toggling bits updates the expression/result displays
|
|
- Verify: can navigate grid, toggle bits, see values change in real-time
|
|
|
|
### Task 5.5: Implement struct visualizer TUI view
|
|
- Create `tui/src/views/struct_viz.zig`
|
|
- Text input for struct definition (multi-line mini editor or single-line with parsing)
|
|
- Live-updating field table (offset, size, align, padding)
|
|
- Memory map visualization (byte grid with color-coded fields and padding)
|
|
- ABI toggle (`a` key), endian toggle (`e` key)
|
|
- "Packed comparison" shown at bottom
|
|
- Transition: from programmer mode, press a key (e.g., `S`) to enter struct sub-view, Esc to return
|
|
- Verify: can enter struct definition, see layout update, toggle ABI/endian
|
|
|
|
### Task 5.6: Implement financial mode TUI view [DONE]
|
|
- Created `src/tui/financial.zig`; financial is now a top-level mode and Tab
|
|
cycles Standard -> Programmer -> Financial -> Convert
|
|
- Four forms behind one chip row: CAGR, Compound, TVM, Amortization, with a sixth
|
|
END/BGN row on TVM for annuity due
|
|
- TVM and compound solve for whichever field is left blank, matching the engine's
|
|
`TvmParams` convention; a blank solve-for field renders `[solve]` and an
|
|
unanswerable form says what it is waiting for instead of doing nothing
|
|
- Amortization shows payment and totals plus a scrollable schedule (FR-7.5):
|
|
PgUp/PgDn from either zone and the mouse wheel. The scroll offset is clamped at
|
|
draw time, so a resize or a changed period count cannot leave a stale offset
|
|
showing an empty table
|
|
- Two entry routes: digits typed in the form zone go into the focused field, and
|
|
the shared input line evaluates an expression into it (`30 * 12` -> 360), which
|
|
keeps the full expression language available inside a field
|
|
- Results are live, so financial mode is the one mode that appends nothing to
|
|
history
|
|
- Formula breakdown under the result: the general relationship always, plus a
|
|
substituted line for CAGR and compound where it fits on one row
|
|
- State and solving live in `financial.zig` as a pure `State` struct, so the
|
|
behavior is testable without a terminal; `App` embeds one and forwards keys
|
|
- DEFERRED from the original spec: nothing. The step-by-step breakdown deferred
|
|
from Task 2.5 is included here
|
|
- 43 form-logic tests, 12 rendered-frame tests (a real surface is drawn and the
|
|
cells read back, which is what catches a layout that writes nothing or runs off
|
|
the bottom), and 11 wiring tests through the actual key and mouse handlers
|
|
- Verify: rendered frames inspected for all four forms at 80x24, plus narrow (34
|
|
columns) and short (10 rows) terminals; confirmed the first mortgage row reads
|
|
1,199.10 / 1,000.00 / 199.10 / 199,800.90 and totals 231,677.04
|
|
|
|
### Task 5.6.1: Fix AST ownership leaks found while testing 5.6 [DONE]
|
|
- The financial-mode wiring tests run on `testing.allocator` rather than an arena,
|
|
which exposed two pre-existing leaks the whole suite had been hiding:
|
|
- `evalStringInfo` and `evalProgrammerString` never freed the parsed tree at
|
|
all, on any path. Every evaluated expression leaked its whole AST. Invisible
|
|
until now because the CLI and every existing test pass in an arena
|
|
- the parser leaked partial trees on every error path (a parser test comment
|
|
even said an arena was needed "so error paths don't leak")
|
|
- Fixed by giving the parser proper cleanup (`errdefer freeExpr` at each site
|
|
where a child is parsed before a step that can fail, and explicit frees on the
|
|
trailing-token and unmatched-paren paths) and having both eval entry points
|
|
release the tree they own. `freeExpr` is now public, since callers need it
|
|
- This mattered most in the TUI, which is the only long-running caller: every
|
|
expression and every typo grew the arena for the life of the session
|
|
- 4 regression tests on `testing.allocator`: 16 malformed inputs, programmer-mode
|
|
malformed inputs, successful parses freed exactly once, and 200 repeated
|
|
evaluations
|
|
|
|
### Task 5.6.2: Financial mode UX refinements and frontend coverage [DONE]
|
|
- Shift-Tab walks back through the modes (FR-7.1); `nextMode`/`prevMode` are one
|
|
ordering used by both directions, tested as inverses
|
|
- Form fields group digits as they are typed (FR-7.5.1): `200` then `0` shows
|
|
`2,000`. Display-only, reusing the engine formatter's grouping helpers (now
|
|
public) rather than a third copy of the logic; the buffer stays ungrouped so it
|
|
still parses, and a typed comma is absorbed
|
|
- Form fields accept expressions (FR-7.5.2): `12 * 30` shows verbatim with an
|
|
`(Enter)` prompt and Enter evaluates it in place. An unevaluated field is
|
|
reported as such rather than treated as a blank, which in TVM and compound means
|
|
"solve for this"
|
|
- Rate fields show a trailing `%` and absorb a typed one (FR-7.5.3)
|
|
- Focus styling changed from reversed cyan to a dark selection fill with a yellow
|
|
block cursor; the mode's own yellow is reused for the row marker and label
|
|
instead of introducing another accent
|
|
- Help overlay rewritten as a scrollable line list (FR-7.6.1). The previous
|
|
per-section height gating silently dropped the Convert section once Financial
|
|
was added above it, which a render test caught
|
|
- Coverage now measured for the CLI and TUI, not just the engine (NFR-5): three
|
|
reports with disjoint include paths. `Coverage.addModule` takes include paths
|
|
instead of hardcoding `engine/src`, resolved to absolute paths because the
|
|
substring form matched any home directory containing `/src/`
|
|
- TUI coverage went from unmeasured (the view modules had literally zero
|
|
instrumented lines) to 98.91%, via the render harness in
|
|
`src/tui/test_render.zig` and event tests through the real handlers
|
|
- Verify: 811 tests, engine 99.30%, CLI 95.74%, TUI 98.91%; frames inspected for
|
|
every mode at sizes from 20x6 to 200x60
|
|
|
|
### Task 5.6.3: Solve compound interest for rate and years [DONE]
|
|
- `financial.compoundRate` and `financial.compoundPeriods`: the compound
|
|
relationship solved for its remaining two variables, both closed form, no solver
|
|
- `financial.effectiveAnnualRate` (APY), because a solved rate is NOMINAL and
|
|
reporting it alone invites comparison against a quoted effective rate. The form
|
|
shows both whenever compounds/year > 1; at annual compounding they coincide and
|
|
only one figure appears
|
|
- Compounds/year is deliberately not solvable (FR-5.2.1): a solution exists only
|
|
between annual and continuous compounding, and a fractional frequency is
|
|
meaningless. It is prefilled with `1` instead (FR-7.5.4), which is what keeps
|
|
"blank means solve" true for every field the user sees. The prefill is baked into
|
|
the comptime initial state, since a draw-time default could not distinguish
|
|
"never touched" from "deliberately cleared"
|
|
- Expression parity (FR-5.7): `compound_rate`, `compound_years`, and
|
|
`apy(nominal, per_year)` in 3- and 4-argument forms, so the form cannot solve
|
|
something the expression language cannot
|
|
- Solving for rate at annual compounding IS CAGR; both entry points are kept (CAGR
|
|
takes periods, not years) and a test asserts they agree so they cannot drift
|
|
- 19 engine tests including a four-way round trip through all four variables, 9
|
|
form tests, 3 evaluator tests, 3 rendered-frame tests
|
|
- Verify: `tally 'compound_rate(10000, 25000, 5)'` gives 20.11244339814313, equal
|
|
to `cagr(10000, 25000, 5) * 100`; `apy(18, 12)` gives 19.5618
|
|
|
|
### Task 5.7: Implement unit conversion TUI view [DONE]
|
|
- Created `src/tui/convert.zig`; convert is now a third top-level mode and Tab
|
|
cycles Standard -> Programmer -> Convert
|
|
- Category chips wrap to the terminal width, selected chip highlighted
|
|
- From/To unit columns list every unit in the category with the selection marked
|
|
- Live result plus a conversion-factor line (or an "affine conversion" note for
|
|
temperature, where no single factor applies)
|
|
- Value comes from the shared input line; a bare number is used directly and
|
|
anything else is evaluated as a standard expression first (so `2*3.5` works)
|
|
- Backtick toggles input/selection zone (same convention as programmer mode);
|
|
arrows navigate columns and selections with wrapping; Ctrl-S swaps units
|
|
- Changing category resets the unit pair to that category's defaults
|
|
- Graceful degradation: if the terminal is too short, shows "... N more"
|
|
- Fully mouse-clickable (see Task 5.9)
|
|
- Verify: category switching filters units, selection updates the live result,
|
|
swap works, conversions match the CLI
|
|
|
|
### Task 5.9: TUI mouse support [DONE]
|
|
- Requirement documented as FR-7.11 (mouse is first-class, with keyboard parity
|
|
still required by FR-7.7); architecture in design.md 8.0
|
|
- `RegionSet` in `src/tui.zig`: fixed 512-entry table of `{row, col, len, action}`
|
|
rebuilt every frame during drawing, so hit targets cannot drift out of sync
|
|
with what is displayed. Newest-registered region wins, letting views layer a
|
|
row-wide fallback under finer per-digit targets.
|
|
- `Action` union covers mode tabs, programmer field focus (with optional bit
|
|
cursor), bit toggling, input focus, help dismissal, float overlay + format,
|
|
width/endian/signedness toggles, and all convert-mode selections
|
|
- Only left button press is acted on, so one click fires exactly one action
|
|
- Wired into every view: mode tabs, standard mode input, programmer mode (bit
|
|
grid, HEX/OCT digit cursor placement, BIN bit flipping, DEC row focus,
|
|
Bits/Signed/Endian labels), float view (bits, format/exit hints), convert mode
|
|
(category chips, both unit columns), and the help overlay
|
|
- Click semantics follow the data: a bit grid cell and a BIN digit are one bit so
|
|
clicking flips them; a HEX nibble or OCT digit spans several bits with no single
|
|
meaningful toggle, so clicking places the cursor for typing
|
|
- Extracted shared state helpers (`setMode`, `cycleBitWidth`, `toggleEndian`,
|
|
`toggleSignedness`, `toggleFloatFormat`, `setConvCategory`, `swapConvUnits`) so
|
|
keyboard and mouse paths cannot diverge
|
|
- `build.zig`: added a `tui_tests` target rooted at `src/tui.zig`. The CLI test
|
|
target roots at `src/main.zig`, which only reaches `tui.zig` from `main()`, and
|
|
`main()` is never analyzed in test mode, so TUI tests were being silently
|
|
skipped
|
|
- 12 unit tests for the pure logic: region hit/miss boundaries, newest-wins
|
|
layering, clear, zero-length rejection, budget exhaustion, action payloads,
|
|
`wrapIndex` wrapping/clamping, and `defaultUnitIndices` invariants
|
|
- Verify: `zig build test` runs the TUI tests (366 total); interactive clicking
|
|
needs a manual pass since the overlay is terminal-only
|
|
|
|
### Task 5.8: TUI polish and keybindings [PARTIAL]
|
|
- DONE: `?` help overlay showing keybindings, mouse actions, functions, operators
|
|
- DONE: mouse support (see Task 5.9): clicking tabs, bits, digits, config labels,
|
|
convert categories/units, and the input line
|
|
- DONE: Molokai color theme
|
|
- NOT DONE: vi-style input mode (optional: Esc for normal mode, i for insert)
|
|
- NOT DONE: mouse wheel scrolling for history
|
|
- Verify: help overlay works, mouse interactions work, looks reasonable in 80x24 terminal
|
|
|
|
### Task 5.10: Act on the full-codebase review [PARTIAL]
|
|
|
|
A four-part review of the whole codebase (numeric core, language layer, domain
|
|
modules, frontends plus build) produced 29 wrong-answer or crash findings, 10
|
|
groups of tests giving false confidence, and a set of documentation mismatches.
|
|
This task records what was fixed, what was deliberately not, and what remains.
|
|
|
|
FIXED:
|
|
- **Comma digit separator** (`tokenizer.zig`): a comma now groups digits only when
|
|
exactly three follow. The old rule was "a digit follows", so `log(100,10)` lexed
|
|
as `log(10010)` and returned 4.0004, and `max(1,2)` became `max(12)`. Every FR-5.7
|
|
financial call was unreachable without a space after each comma. Base literals no
|
|
longer accept commas at all (they group with spaces and underscores, FR-1.8).
|
|
`max(1,234)` is inherently ambiguous and resolves to `max(1234)`; FR-1.8 now says
|
|
so.
|
|
- **Exact rendering carry** (`rational.zig`): `roundUpDecimal` dropped the carry
|
|
when the integer part was all nines, so `10 - 1/(3*10^20)` printed as
|
|
`0.00000000000000000000` in the display AND the clipboard.
|
|
- **Exact rendering of small magnitudes** (`rational.zig`, `formatter.zig`): values
|
|
below the 20-digit fractional budget rendered as zero. New
|
|
`Rational.toScientificString` renders from the rational rather than from
|
|
already-rendered text, so `2^-70` shows as `8.4703294725430034e-22`.
|
|
- **Unchecked float-to-integer conversions** (`evaluator.zig`, `programmer.zig`):
|
|
`2^64 and 1`, `~1e30` and `tally -p '1e40'` aborted the process. Now `Overflow`
|
|
or `DomainError` through `toFixedWidthBits`/`shiftAmount`.
|
|
- **`minInt(i128)` negation** (`formatter.zig`): panicked when formatting a 128-bit
|
|
value with only the sign bit set, reachable from the TUI. Now negates in the
|
|
unsigned domain.
|
|
- **Unbounded recursion** (`parser.zig`): `Parser.max_nodes` (1000) and
|
|
`max_nest_depth` (128). 4000 terms of `1+1+1+...` segfaulted `evalExact`.
|
|
- **The tautological render assertion** (`test_render.zig`): `wellFormed` compared
|
|
each row's length to the width it was allocated with and could never fail.
|
|
Replaced by `furniture(rows).intact()`, which checks the separator, prompt and
|
|
status rows. That immediately caught three layout bugs, now also fixed: the
|
|
programmer view drawing over the prompt on a short terminal (it now reports how
|
|
many rows it needs), the convert view's overflow notice erasing the unit row
|
|
above it and undercounting, and the TUI's convert value defaulting to
|
|
`Number.fromFloat(1)` so the opening screen read `999,999,999.9999999 nm`.
|
|
- Strengthened the tests the review named as coverage theater: float-view
|
|
classifications are asserted by name, the convert overflow count is asserted
|
|
exactly, programmer base rows are asserted by row index, and the financial "draws
|
|
without panicking" test now asserts every field label.
|
|
|
|
DELIBERATELY NOT FIXED, documentation corrected instead:
|
|
- `roundToCents` is half-even on the binary value, which differs from decimal
|
|
half-even on 573 of 10000 decimal ties. Fixing it means a decimal type in the
|
|
money path; the no-bias property FR-5.8 exists for still holds. Documented on
|
|
`RoundingMode`.
|
|
- The `psi` f64 factor is 1 ulp from the correctly rounded ratio because the
|
|
comptime `p/q` division rounds twice. The exact path is unaffected. The
|
|
"cannot drift apart" claim in `units.zig` has been qualified.
|
|
- The rate solver picks whichever of several roots its seed order reaches first,
|
|
and its scale-relative tolerance does not cover noise proportional to
|
|
`(1+r)^n`, so some well-posed inputs report `ConvergenceFailure`. Both are now
|
|
documented on `solveRate` rather than half-fixed.
|
|
- The unit round-trip tests are tautological (`fromBase` is the algebraic inverse
|
|
of `toBase`); they are kept because they do catch asymmetric edits to the affine
|
|
temperature conversions, and the comment claiming a stronger guarantee is gone.
|
|
- Shift-by-width-or-more clamps rather than yielding zero. It is a policy choice
|
|
(C, Zig, x86 and Java all differ); the decision belongs with the operator
|
|
unification below.
|
|
- FR-7.8 (base quick-switch keys) is removed rather than implemented: `d`, `h`,
|
|
`o` and `b` are digits in the editable value fields.
|
|
- FR-1.6 (variables limited to `A-F, X, Y, Z`) and FR-2.8 (little-endian default)
|
|
now follow the implementation, which is right in both cases.
|
|
- Documentation corrected where it described behaviour the code does not have:
|
|
`ast.zig`'s ownership header (it claimed arena allocation with no per-node
|
|
deallocation, the opposite of the truth, which is how the AST leaks happened),
|
|
the `caret` token comment, design 2.3 and 2.5 (stale AST and Environment
|
|
shapes), design 7's programmer and conversion output, design 8.1's footer, and
|
|
design 8.0/8.0.0's overclaims about hit regions and the render harness.
|
|
|
|
DE-DUPLICATION PASS (done before the human review, since duplicated logic is what
|
|
the review would have spent its time on):
|
|
- **One money formatter.** `formatter.formatAmount`/`formatMoney` in the engine;
|
|
`main.zig` and `src/tui/financial.zig` had a copy each, and all three disagreed
|
|
about overflow. The formatter returns `null` rather than `"?"`, so the CLI now
|
|
reports "an amount in this schedule is too large to format" with exit status 1
|
|
where it used to print a table of question marks and exit 0 (open item 9 below,
|
|
now closed).
|
|
- **One error phrase table.** `types.errorPhrase` owns the strings, with an
|
|
exhaustive switch and no `else`, so an added `CalcError` is a compile error. The
|
|
CLI and TUI decorate it at comptime (`switch (err) { inline else => ... }`). The
|
|
TUI's private copy had fallen behind and rendered `InvalidExpression`,
|
|
`ConvergenceFailure` and `InsufficientParameters` as "evaluation error".
|
|
- **One comma-grouping implementation and one scientific renderer.**
|
|
`writeUnsignedWithCommas` now writes plain digits and calls `writeGroupedDecimal`;
|
|
new `splitDecimalText` is the only place decimal text is taken apart. The 65-line
|
|
`scientificFromDecimalText` is gone: both magnitude branches call
|
|
`Rational.toScientificString`.
|
|
- **One negative-sqrt rule, one numeric-error mapping.** `Number.sqrt` raises
|
|
`NegativeRoot`; `number.toCalcError` is the single mapping (`evaluator.mapError`
|
|
and `units.mapNumberError` were two copies). `evalSingleArgFn` now distinguishes
|
|
"unknown name" from "bad argument", so `sqrt(-1)`, `asin(2)`, `ln(0)`,
|
|
`factorial(-1)` and `log2(0)` report `DomainError` instead of `UnknownFunction`.
|
|
- **One mode order, one form order.** `tui.mode_tabs` is indexed by the `Mode` tag
|
|
(checked at comptime) and drives Tab, Shift-Tab and the drawn bar, which were
|
|
three encodings of one sequence. Form cycling moved into
|
|
`financial.State.nextForm`/`prevForm`. That copy had a live bug: the `Form` tag is
|
|
a `u2`, so `@intFromEnum(form) + 1` overflowed and pressing Right on Amortization
|
|
panicked in a debug build. The existing test only wrapped backwards.
|
|
- **Dead code.** `engine.zig` lost 20 of 31 curated re-exports (the ones with no
|
|
caller, including `Value` after `Number` replaced it); `types.Value` and
|
|
`types.ErrorInfo` are gone; `Parser` lost `mode`, `previous`, `had_error` and
|
|
`error_pos` and `Tokenizer` lost `mode`, all written and never read, so both
|
|
`init` signatures dropped their `Mode` parameter (parsing does not depend on the
|
|
mode, only evaluation does); `Number.applyFloatFn` and `Environment.history_len`
|
|
are gone; and the `0x`/`0o`/`0b` prefix-skipping branches in the programmer view
|
|
could never run, because only formatter `raw` strings carry a prefix.
|
|
|
|
STILL OPEN, in the order I would take them:
|
|
1. `>>` is logical in standard mode and arithmetic in programmer mode, shift
|
|
amounts wrap in one and clamp in the other, and `evaluator.zig` ignores the
|
|
configured bit width entirely. The two implementations of these nine operators
|
|
need to become one, parameterised by width (FR-2.12 promises they agree).
|
|
|
|
DECIDED: standard mode is 64-bit signed. A user who wants another width uses
|
|
programmer mode, which keeps its configurable width (FR-2.3). The shared
|
|
implementation therefore takes width and signedness as parameters, and standard
|
|
mode passes 64 and signed. Signedness is the status quo rather than a change:
|
|
`evaluator.zig` already converts results back through `@as(i64, @bitCast(...))`,
|
|
so standard-mode `~0` is -1.
|
|
|
|
DECIDED: `>>` fills with the sign bit (arithmetic) in both modes, so standard
|
|
`-8 >> 1` becomes -4 instead of 9223372036854775804. `>>>` fills with zeros
|
|
(logical) in both modes.
|
|
|
|
Bits shifted past the width are discarded, not wrapped: `0b1000 << 1` is 16.
|
|
Wrap-around is what `rol`/`ror` are for, and both modes already agree on those.
|
|
|
|
STILL TO DECIDE: a shift distance at or beyond the width. Standard mode reduces
|
|
the distance modulo 64 (`1 << 64` is 1) and programmer mode clamps it to
|
|
`width - 1` (8-bit `0xFF >>> 20` shifts by 7 and gives 1). Both turn "shift
|
|
everything out" into "shift a little". Recommendation: let the shift run to
|
|
completion, so `<<` and `>>>` yield 0 and `>>` yields 0 or -1 by sign. That makes
|
|
`1 << 64` yield 0, which is a visible change with test expectations attached.
|
|
Zig's saturating `<<|` was considered as a home for `<<<` and rejected: `>>>`
|
|
means "the zero-filling variant of `>>`", so `<<<` would have to mean the
|
|
zero-filling variant of `<<`, which is `<<` itself.
|
|
2. Signed division and modulo in programmer mode use unsigned semantics:
|
|
`-10 / 2` gives 9223372036854775803.
|
|
3. Multi-base detail lines are computed through an f64 round trip, so
|
|
`0xDEADBEEFDEADBEEF` shows `hex: DE AD BE EF DE AD C0 00`. The exact value is
|
|
right there; the display path should use it.
|
|
4. Cross-tier `max`/`min` compare through f64 and return the wrong operand when
|
|
the values differ below f64 resolution.
|
|
5. `Rational.toFloat` double-rounds (no sticky bit), so results can be 1 ulp off.
|
|
6. Convert mode's selection zone falls through to the programmer key handler, so
|
|
Enter never fires there and printable keys mutate `prog_value`.
|
|
7. Amortization rejects valid loans because the derived payment rounds down to
|
|
cents before the "covers the interest" check; the principal column is not
|
|
rounded at all.
|
|
8. History display caps at 512 flattened lines built oldest-first, so results stop
|
|
appearing after roughly 102 detailed entries. History memory is never reclaimed
|
|
(Ctrl-L frees into an arena).
|
|
9. ~~Money formatting degrades to `?` and still exits 0 at large magnitudes.~~
|
|
Fixed by the de-duplication pass above.
|
|
10. Assignment parses in prefix position (`1 + x = 2` mutates `x`), and assignment
|
|
to a constant name is silently discarded.
|
|
11. Literals longer than 128 characters are rejected by a fixed tokenizer buffer,
|
|
and non-decimal literals are capped at 64 bits, both below what the exact tier
|
|
supports.
|
|
12. `engine/src/c_api.zig` has no tests and appears in no coverage report, because
|
|
no test target builds the shared library. Deferred until Phase 6 gives it a
|
|
caller; recorded here so it is a known gap rather than an oversight.
|
|
|
|
---
|
|
|
|
## 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 |
|