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