tally/.kiro/specs/calculator/design.md

102 KiB
Raw Blame History

Design: Tally - Cross-Platform Calculator

References


1. System Architecture

┌─────────────────────────────────────────────────────────────┐
│                        Frontends                            │
├─────────────┬──────────────────┬────────────────────────────┤
│   CLI       │      TUI         │        Android             │
│  (Zig)      │  (Zig+libvaxis)  │   (Kotlin/Compose)         │
└──────┬──────┴────────┬─────────┴──────────────┬─────────────┘
       │               │                        │
       │  Zig import   │  Zig import            │  JNI (C ABI)
       │               │                        │
┌──────▼───────────────▼────────────────────────▼─────────────┐
│                     Engine (Zig Library)                     │
├─────────────────────────────────────────────────────────────┤
│  parser  │  evaluator  │  programmer  │  struct_layout      │
│          │             │              │                     │
│  tokens  │  standard   │  bitwise ops │  abi_profiles       │
│  ast     │  financial  │  base conv   │  layout_compute     │
└─────────────────────────────────────────────────────────────┘

The engine is a pure Zig library with no I/O. Frontends depend on the engine; the engine depends on nothing outside std.

Build Graph

build.zig (workspace root)
|- engine/build.zig        -> produces: static lib (.a), C header, shared lib (.so/.dylib/.dll)
|- cli/build.zig           -> produces: statically-linked binary
|- tui/build.zig           -> produces: statically-linked binary (links libvaxis)
|- android targets         -> produces: libtally.so for arm64-v8a, x86_64
                               (consumed by android/ Gradle project for APK packaging)

Build system philosophy:

  • zig build is the single entry point for all compilation. No Makefile, no CMake, no shell scripts.
  • zig build (no args) -> builds engine + CLI + TUI for the host platform.
  • zig build -Dtarget=aarch64-linux-android -> cross-compiles engine .so for Android arm64.
  • zig build test -> runs all engine unit tests with coverage reporting.
  • The android/ Gradle project handles only APK packaging and Compose UI compilation - it does not invoke Zig. Prebuilt .so files are committed or produced by a prior zig build step.

Toolchain management (mise):

  • .mise.toml at project root declares: Zig version, any other needed tools.
  • mise install provisions the full dev environment. No other manual setup required.
  • CI uses the same .mise.toml for reproducibility.

2. Engine Design

2.1 Module Breakdown

Module Responsibility
grouping.zig The thousands rule, shared by both display paths
Integer.zig A fixed-width integer (file-as-struct): raw, width, signedness, the interpretations, and the notations it renders in
Rational.zig Exact rationals over big integers (file-as-struct)
number.zig The exact/inexact numeric model (section 2.7), including how a value renders itself at a budget the caller supplies. Lowercase: Number is a tagged union, which a file-as-struct cannot express
tokenizer.zig Lexer, and Base for literals
ast.zig AST node definitions
parser.zig Pratt parser -> AST
evaluator.zig Walk AST, produce results
bitwise.zig The fixed-width operators (& | xor ~ << >> >>> rol ror), shared by both modes
programmer.zig Programmer-mode evaluation, its Config, wrapping arithmetic
float_interp.zig IEEE 754 bit-level interpretation
units.zig Unit conversion tables and resolver
financial.zig CAGR, TVM, compound interest, amortization, and money display (cents are a property of money, not of a screen)
message.zig Gone: the wording lives in engine.zig beside the Error union it words
engine.zig Public API surface (Zig-native): the module re-exports, the Error union, and phrase
c_api.zig extern "C" wrappers for JNI/FFI consumers

Every module declares its own error set; there is no shared one (section 10).

There is deliberately no types.zig and no errors.zig. types.zig existed, and being named after a language feature rather than a concept, it accumulated four unrelated groups: the error vocabulary, the fixed-width integer model, Base (used only by the lexer and the AST), and Mode (which the engine stored and never read). errors.zig was where the error vocabulary landed, until the same question showed that what needed splitting was the type inside it. Each piece has gone to the module that owns it. struct_layout.zig is still unimplemented (Phase 4).

A file is named TitleCase when the file is the type, with its fields at container level: Integer.zig and Rational.zig. number.zig stays lowercase because Number is a tagged union and a Zig file is always a struct, so the file cannot be that type without wrapping the union in a field, which would add a hop at every use of the tag that is the type's whole identity.

2.2 Core Data Types

/// Result of any calculation
pub const Value = union(enum) {
    integer: Integer,
    float: f64,
    boolean: bool,
    struct_layout: StructLayoutResult,
    multi_base: MultiBaseResult,  // programmer mode result
};

pub const Integer = struct {
    /// Raw bits stored in u64, interpretation depends on context
    raw: u64,
    bit_width: BitWidth,
    signedness: Signedness,
};

pub const BitWidth = enum { bits8, bits16, bits32, bits64 };
pub const Signedness = enum { signed, unsigned };

pub const MultiBaseResult = struct {
    value: Integer,
    decimal_signed: []const u8,    // formatted string
    decimal_unsigned: []const u8,
    hex: []const u8,
    octal: []const u8,
    binary: []const u8,
    bit_pattern: [64]u1,           // individual bits, index 0 = LSB
};

2.3 AST Nodes

The shape as implemented (engine/src/ast.zig); the earlier sketch here omitted the text field, which the whole exact tier depends on.

pub const Expr = union(enum) {
    number: Number,
    string_literal: []const u8,   // ASCII bytes to pack, quotes stripped
    unary: Unary,
    binary: Binary,
    call: Call,
    variable: []const u8,
    assignment: Assignment,
};

pub const Number = struct {
    float_value: f64,
    /// For programmer mode: the exact integer when the literal fits u64.
    int_value: ?u64,
    base: Base,
    /// The literal exactly as written, pointing into the source. Decimal literals
    /// are re-parsed from this rather than from `float_value`, because
    /// `float_value` has already rounded and `0.1` cannot be recovered from it.
    text: []const u8,
};

pub const Base = enum { decimal, hex, octal, binary };

pub const Binary = struct {
    op: BinaryOp,
    left: *Expr,
    right: *Expr,
};

pub const BinaryOp = enum {
    add, sub, mul, div, mod, pow,
    bit_and, bit_or, bit_xor, shift_left,
    shift_right, shift_right_logical,
    rotate_left, rotate_right,
};

Ownership: nodes are allocated individually and the caller releases the tree with parser.freeExpr. Passing an arena and skipping the walk is allowed, but nothing assumes it (see ast.zig for why that distinction matters).

Size limits. Parser.max_nodes (1000) and Parser.max_nest_depth (128) bound the tree, because every walk over it recurses. A flat chain like 1+1+1+... builds a left-deep tree whose depth is the operator count, and 4000 terms segfaulted evalExact before the limit existed. Exceeding either reports InvalidExpression.

2.4 Parser Design

Approach: Pratt parser (top-down operator precedence)

Precedence table (low -> high):

Level Operators
1 | (OR), or
2 xor (XOR keyword)
3 & (AND), and
4 <<, >>, >>>, rol, ror
5 +, -
6 *, /, %
7 ^, ** (exponentiation, always)
8 Unary -, ~ (NOT), not
9 Function calls, parentheses

^ is always exponentiation. There is no mode-dependent operator overloading. XOR is a keyword (xor), consistent with the existing rol/ror keyword operators. Bitwise ops have both symbolic and keyword forms: &/and, |/or, ~/not. XOR is keyword-only (xor) since ^ is reserved for power. Every operator means the same thing in standard and programmer modes.

One implementation of the fixed-width operators. bitwise.zig implements the eight fixed-width binary operators plus ~ and two's complement negation, over a Domain of width plus signedness. Standard mode passes Domain.standard, which is 64-bit signed and not configurable; programmer mode passes its configured width and signedness. Values are u128 bit patterns masked to the width, the representation types.Integer already used. Both callers dispatch through an inline else prong that resolves the operator at comptime, so an operator added to BinaryOp that belongs in this tier fails to compile rather than falling through to the rational tier. FR-2.13 covers the distance rules: shifts run to completion, rotations are cyclic, negative distances are domain errors.

No implicit multiplication: adjacency (e.g. 2pi) is an error; use explicit *. Spaces within hex/oct/bin literals are separators (e.g. 0xFF FF).

Parsing does not depend on the mode. Tokenizer.init(source) and Parser.init(allocator, source) take no Mode: the same text produces the same tree in standard and programmer mode, and only evaluation differs. Both used to accept and store a Mode that no code read, which implied a mode-dependent grammar that does not exist.

2.5 Evaluation

The evaluator maintains an Environment:

pub const Environment = struct {
    allocator: Allocator,
    /// Variables hold `Number`, so an assignment keeps whatever exactness its
    /// expression had: `X = 0.1` stores exactly one tenth.
    variables: std.StringHashMap(Number),
    ans: Number,
};

It holds no mode and no programmer configuration. It used to hold both and read neither: the caller picks evalString or evalProgrammerString, so which evaluator runs is decided at the call rather than by state. The TUI was writing a mode into that field on every mode change and nothing ever consulted it. Mode has left the engine entirely; each frontend has its own, and they differ (the CLI has two, the TUI has four including Convert, and the engine had a financial member it could never act on because financial calculations are ordinary functions in expressions).

Programmer mode's configuration belongs to programmer mode:

// programmer.zig
pub const Config = struct {
    width: BitWidth = .bits64,
    signedness: Signedness = .signed,
    /// Big-endian by default so the HEX row reads as the number itself; see FR-2.8.
    display_endian: std.builtin.Endian = .big,
};

// Integer.zig: the file is the type
raw: u128,
width: BitWidth = .bits64,
signedness: Signedness = .signed,

Integer.zig is TitleCase because the file is the type. Width and signedness were briefly a nested IntType struct, which existed only because bitwise.zig operated on bare u128 patterns and needed the pair passed alongside. Now that the operators take values (apply(op, left: Integer, right: Integer)), nothing carries the pair without bits to go with it, so the fields sit directly on the value and Config holds them flat. That also removed a class of mistake: a pattern can no longer be operated on at a width it did not come from.

Byte order is std.builtin.Endian rather than an engine enum of the same two members.

Standard mode evaluates to Number, the exact/inexact union of section 2.7. Programmer mode evaluates to Integer, which is a u128 pattern plus the IntType that interprets it. Financial functions compute in f64 and enter the expression language as inexact Number values (section 5.6).

2.6 Number Display Formatting

The engine provides raw values; frontends apply display formatting. The engine includes a formatting module that produces display strings and raw (clipboard-friendly) strings separately.

Decimal Formatting

Context Display Clipboard
Standard result 4,294,967,295 4294967295
Fractional result 231,677.04 231677.04
Financial result $1,234,567.89 1234567.89
Large decimals 4,294,967,295 (never scientific unless > 15 digits of precision) 4294967295

Grouping applies to the integer part regardless of a fractional part. Commas group the digits left of the decimal point and never appear to the right of it, so 231677.04 displays as 231,677.04 rather than ungrouped. Grouping only whole numbers would make the same magnitude read two different ways depending on whether cents happened to be present, which showed up immediately in financial output. Because FR-1.8 accepts commas as input digit separators, the grouped display remains valid input. Text already in scientific notation is left alone.

Scientific notation threshold: Only use scientific notation when the number exceeds what can be meaningfully displayed in decimal (> 15 significant digits, or absolute value > 10^15 or < 10^-15). Otherwise, always show full decimal with commas. This is a deliberate departure from calculators that jump to scientific notation at 10+ digits.

Programmer Mode Formatting

Values are displayed with visual grouping separators to aid readability, but clipboard copies the raw value without separators.

Base Display (value view) Display (memory view) Clipboard
Hex FF FF FF FF FF FF FF FF 0xFFFFFFFF
Bin 1111 1111 1111 1111 1111 1111 1111 1111 same 0b11111111111111111111111111111111
Oct 0o37_777_777_777 - 0o37777777777
Dec 4,294,967,295 - 4294967295

Rationale for hex grouping:

  • Display uses space per byte (FF FF FF FF): matches hex editors, debuggers, xxd output. Consistent with binary's nibble-space grouping. Universally readable.
  • These are two different views of the same data: the value (what you'd write in source code) vs. the memory (how it sits in RAM). For this calculator, both use the same space-separated format.

Binary always groups by nibble (4 bits) with spaces - universally expected by programmers.

Formatting Data Structure

pub const FormattedValue = struct {
    /// Human-readable display string (with commas, underscores, spaces)
    display: []const u8,
    /// Machine/clipboard string (raw, no separators, includes prefix)
    raw: []const u8,
};

All frontends:

  • Show display in the UI
  • Copy raw to clipboard on long-press (Android) or yank (TUI)

2.7 Numeric Model: Exact and Inexact

2.7.1 The problem

Standard mode currently evaluates everything as f64. That produces four distinct classes of wrong answer, all reproducible today:

0.1 + 0.2            -> 0.30000000000000004    binary cannot represent 0.1
1.1 + 2.2            -> 3.3000000000000003     same
9007199254740993     -> 9.007199254740992e15   SILENTLY A DIFFERENT INTEGER
2^53 + 1             -> 9.007199254740992e15   same, past f64's integer limit
12 in in ft          -> 0.9999999999999998     conversion factors are decimal
factorial(171)       -> error: unknown function  f64 overflow, wrong error too

The third case is the serious one. The user typed an exact integer and got a different integer back. Meanwhile programmer mode, one Tab away, is exact to 128 bits. That is an internal inconsistency, not a rounding preference.

2.7.2 The representation

A tagged number with an exact tier and an inexact tier:

/// Exact rational: p/q with arbitrary-size integers, always reduced, q > 0.
pub const Rational = struct {
    num: std.math.big.int.Managed,
    den: std.math.big.int.Managed,
};

pub const Number = union(enum) {
    /// Exact value. Closed under + - * / and integer powers.
    exact: Rational,
    /// Inexact value, produced once a computation escapes the rationals.
    inexact: f64,
};

Contagion rule: any operation with an inexact operand yields inexact. A value never silently becomes exact again. This is what keeps the guarantee honest: exact means "no rounding has occurred anywhere in this value's history", not "happens to look clean right now".

2.7.3 Why rational and not decimal128

Both fix 0.1 + 0.2. They are not otherwise equivalent:

decimal128 arbitrary rational
Form +/- 34-digit significand x 10^exp, 128 bits fixed p/q, arbitrary-size integers
0.1, 0.0254 exact exact (1/10, 127/5000)
1/3 34-digit approximation exact
(1/3)*3 0.999...9 exactly 1
9007199254740993 exact exact
sqrt(2), pi approximation cannot represent
Rounds every operation never
Memory fixed 16 bytes grows with denominators
Trailing zeros (2.50) preserved (scale is part of the value) lost

Two conclusions:

  1. Every decimal128 value is a rational. Rationals strictly subsume them. What decimal128 adds is fixed cost, standard rounding modes, and scale semantics, not additional expressiveness.
  2. decimal128 does not deliver correctness, it delivers familiarity. 1/3 * 3 is still not 1. Since we are prioritizing correctness, rational wins outright.

Decimal's genuine advantage is scale preservation, which matters for money (2.50, not 2.5). We recover that in financial mode with an explicit display scale plus banker's rounding at the commit boundary, rather than a second numeric type. See section 5.

2.7.4 The exact/inexact boundary

The trigger is whether the operation escapes the rationals, evaluated per call rather than per function. Notably it is not the input base: 0xdeadbeef is exactly 3735928559, and base is a lexical and display property only.

Stays exact Becomes inexact
+ - * / on exact operands sin cos tan asin acos atan
^ with integer exponent ^ with non-integer exponent
sqrt of a perfect square sqrt of a non-square
%, abs, ceil, floor, round log ln log2 log10 exp cbrt
factorial (unbounded) constants pi, e, tau
integer and decimal literals any operand already inexact

sqrt(4) is 2 exactly; sqrt(2) is not. We deliberately stop short of a CAS: Qalculate simplifies sqrt(8) to 2*sqrt(2) symbolically, we will not.

2.7.5 Denominator growth and the demotion policy

Rationals grow. Chained division with coprime denominators multiplies them, so a pathological input can consume unbounded memory. This must be designed in from the start, not discovered later:

  • Cap the denominator at a bit-width budget (starting proposal: 4096 bits).
  • On exceeding it, demote to inexact rather than erroring or growing.
  • Demotion is silent in the result value but visible in the tag, so a frontend can indicate that exactness was lost.

Interactive single calculations will not approach this. The cap exists so the failure mode is graceful degradation to today's behavior.

2.7.6 Display

Exact values still have to be rendered at finite width, so display precision remains a separate decision from compute precision:

  • An exact value with a terminating decimal expansion prints exactly: 1/10 -> 0.1, and 12 in in ft -> 1.
  • An exact value with a non-terminating expansion is rendered by long division to a digit budget with correct rounding, and marked as approximate: 100 km in mi is exactly 781250/12573, displayed as 62.137119224.
  • Because the exact form is retained, we can additionally offer the fraction (781250/12573) as a display option. This is a feature, not a workaround.

This supersedes the unimplemented "exceeds 15 significant digits" clause of NFR-7, which was never a precision decision and, read literally, would have rendered 0.9999999999999998 as 9.999999999999998e-1. NFR-7 needs a real answer to a better-posed question: how many digits to show for a non-terminating exact value.

2.7.7 What is unaffected

  • Programmer mode stays fixed-width u128. Wrapping and masking at a chosen bit width is the entire point of that mode; exact arithmetic would break it.
  • The IEEE 754 float view stays binary f32/f64. It exists to show binary encodings.
  • The f64 rendering paths remain, since the inexact tier still needs them.

2.7.8 Fallback precision: why f64 and not f128

The obvious objection to f64 for the inexact tier is that the usual justification (hardware instructions) does not apply to a calculator, which evaluates one expression per human action. That objection is correct, and performance is not the reason. The reason is that Zig 0.16's f128 support is partial and, worse, silently inconsistent. Probed directly:

Operation f128 status
@sqrt compiles, accurate to full f128 (34 digits verified)
@sin, @cos compiles, accurate to full f128
@log, @log10, @exp compiles, but only f64-accurate
std.math.pow does not compile ("pow not implemented for f128")
std.math.cbrt does not compile
std.math.atan, asin compiles
parseFloat / formatting works, 112 mantissa bits (~34 decimal digits)

The middle row is the disqualifying one. @log(2.0) as f128 returns 0.6931471805599452862267639829951804, which is the f64 value widened: it diverges from the true value at the 17th significant digit. So f128 storage would hand us 34 digits of which roughly 18 are noise, with nothing to distinguish them. For a calculator that then displays those digits, this is actively worse than knowingly using f64.

The general principle: the precision of the inexact tier is bounded by the accuracy of the underlying transcendental implementations, not by the width of the storage type. Widening storage without widening the algorithms buys nothing but false confidence.

Upstream status (checked, not assumed)

The f64-accuracy above is not a misconfiguration or a version quirk. It is visible in Zig's own source, with its own TODO markers. lib/compiler_rt/log.zig:

pub fn logq(a: f128) callconv(.c) f128 {
    // TODO: more correct implementation
    return log(@floatCast(a));
}

A systematic scan of lib/compiler_rt/ in Zig 0.16.0 splits the f128 routines cleanly in two:

  • Downcast to f64, all five marked TODO: expq, exp2q, logq, log2q, log10q
  • Genuine f128 implementations: sinq, cosq, tanq, sqrtq, fabsq, floorq, ceilq, roundq, truncq

That is an exact match for the probe results: @sin/@cos/@sqrt accurate, @log/@log10/@exp only f64-accurate.

Tracker status, as of this writing. Note that Zig has moved to Codeberg and GitHub is effectively frozen (GitHub last pushed 2025-11-27; Codeberg shows ~742 open issues against GitHub's ~2876, so most GitHub issues were not migrated):

Gap Tracked?
std.math.pow f128 GH #23602 open, labeled "contributor friendly". PR #23631 implemented it but was closed unmerged 2026-01-05 ("no CI on GitHub anymore... reopen on Codeberg"). Nobody has. No Codeberg issue exists.
std.math.cbrt f128 No issue on either tracker. Unreported. cbrt.zig is ported from musl, which only ships cbrtf/cbrt.
logq/expq etc. f64 accuracy No issue on either tracker. Only the in-source TODOs.

The nearest historical artifacts are GH #4026 ("implement all the math functions for all the floating point types", closed 2022-12-28 under milestone 0.11.0 with its f128 column still unchecked) and GH #15711 (a reference table created and closed in the same second, which lists the f128 symbols as existing - existence, not accuracy).

Adjacent open work suggesting f128 remains a soft spot generally: GH #23865 "Better @sqrt for f32, f64 and f128", GH #23173 "introduce new float types", and Codeberg #36093 "compiler_rt: min(f128) / max(f128) causes integer overflow".

Conclusion: "wait for Zig to fix f128" is not a plan. Two of the three gaps are not even reported, and the one with a working patch lost it to a tracker migration. Obtaining true f128 accuracy would mean writing or porting the routines ourselves, which is the same category and scale of work as going arbitrary-precision - so it should be justified on its own terms, not smuggled in as "just use a wider type".

(Optional, off the critical path: reviving the pow PR on Codeberg is a well-scoped upstream contribution, and the issue is explicitly labeled contributor friendly.)

So f64 is chosen because ~15-16 digits is the precision we can actually justify, and we display no more than that. The upgrade path is therefore not "switch the storage type to f128"; it is "implement or bind correctly-rounded arbitrary-precision transcendentals" (the problem MPFR exists to solve), which is a separate project with its own justification.

Incidentally, the probe also confirms that width alone never fixes the decimal problem: in f128, 0.1 + 0.2 is 0.30000000000000000000000000000000004. Only exactness fixes it.

2.7.9 Implementation sequencing

The change must not land as one diff mixing structural churn (signature changes rippling through hundreds of test assertions) with behavioral change (results becoming correct). Reviewing that combination is impractical: the interesting lines are buried in mechanical ones.

Two orderings were considered.

Rejected: structure first. Introduce Number, rewrite every test and the display path to consume it, review, then change behavior. This fails because the structural rewrite is not behavior-neutral: rewriting a test to consume Number requires asserting whether each result is exact or inexact, and that classification is the behavioral design. Every such assertion would be written against the old f64 behavior and then flipped in the later step. Double churn, and the intermediate commit is a large diff that improves nothing.

Chosen: behavior first, behind the existing signature.

  1. Add rational.zig and number.zig, wired to nothing. Pure addition with their own tests. Reviewable in isolation, zero risk to existing behavior.
  2. Switch the evaluator's internals to the exact tier, keeping evalString returning f64. All existing tests must still pass. Review and commit.
  3. Change evalString and the display path to return and consume Number, with the resulting mechanical churn in test signatures. Review and commit.
  4. Add the exactness tests that only the Number API can express.

What makes step 2 worthwhile rather than invisible is that converting an exact value to f64 once, at the boundary already fixes most of the payoff. Today's errors are accumulated: 0.1 and 0.2 are each rounded before being added. An exact 3/10 rounded a single time to the nearest f64 prints as 0.3.

Bug-by-bug, where the fix lands:

Bug Fixed by step 2 (f64 boundary) Needs step 3 (Number)
0.1 + 0.2 -> 0.3 yes -
1.1 + 2.2 -> 3.3 yes -
0.1 * 3 -> 0.3 yes -
1e20 + 1 - 1e20 -> 1 yes -
factorial(171) partly (becomes inf, not an error) yes, for the exact value
9007199254740993 no (f64 cannot hold it) yes
2^53 + 1 no yes
1/3 retained exactly no (only observable as exact) yes
12 in in ft -> 1 no - see below needs exact unit factors

Unit conversion is a separate problem from the evaluator. An earlier draft of this table claimed the exact evaluator would fix 12 in in ft. It does not, and the reason is worth recording: UnitDef.to_base_factor is an f64, so 0.0254 is already the binary approximation before any arithmetic happens, and convertUnits does its own f64 multiply and divide without ever touching the evaluator. Making conversions exact requires the factors themselves to be exact (declared as decimal text and parsed into rationals), which is a mechanical change across ~100 table entries and belongs in its own commit. Tracked as Task 2.0e.

So step 2 is independently verifiable through the current API, and the large-integer cases become the motivating tests for step 3 rather than speculative ones.

2.7.10 Migration and test impact

The dominant risk is compile-time breakage, not behavioral. An audit of the 402-test suite found no test that asserts the broken behavior - nothing pins 0.30000000000000004, the 2^53 cliff, or the factorial limit. So the target is "no test changes its expected value", not "no test file is touched".

Unaffected by construction: programmer.zig (47), float_interp.zig (21), formatter.zig (40), types.zig and tui.zig (16).

Kept passing by making the change additive:

  • tokenizer.zig (39): NumberValue gains an exact field, does not replace float/int_value.
  • parser.zig (32, 10 of which read number.float_value): the AST number node likewise gains a field.
  • evaluator.zig (62, 40 asserting @as(f64, ...)): keep evalString returning f64 as a wrapper over the exact core. Every one of those assertions involves small values where exact-to-f64 is bit-identical.
  • units.zig (106, 95 routed through the expectConvert helper): change the helper once, leave the call sites alone.

Three existing items must change because we are invalidating their premise:

  1. tokenizer.zig test "parseNumber huge decimal falls back to float" - 99999999999999999999999999 becomes exactly representable, so the test still passes but its intent is now false. Rewrite it.
  2. Number.renderInexact's 2^53 bound (then formatter.zig's is_integer) - that guard is the display half of the 9007199254740993 bug.
  3. evaluator.zig factorial's x > 170 rejection, which is also the source of the misleading unknown function error.

Compatibility wrappers are scaffolding with a deadline, not an end state. If the CLI and TUI keep calling the f64 API, the exactness never reaches the user and the work is invisible. The display path must move to Number (step 3 of 2.7.9), and that is where main.zig's ~20 output assertions get reviewed. Literal zero test churn across the whole sequence would be evidence we had not delivered anything.

Windows Calculator draws the same exact/inexact line we are drawing: its arbitrary-precision rational engine covers basic arithmetic only, not transcendentals.

New tests required: rational arithmetic and reduction, exact/inexact contagion, the demotion policy at the denominator cap, rational-to-decimal rendering and rounding, and a regression test per bug in 2.7.1 - including one asserting 9007199254740993 round-trips, which nothing currently guards.

Allocation-failure testing

errdefer cleanup paths are not reachable by ordinary tests, and covering them is worthless on its own: the line exists to prevent a leak, so executing it proves nothing unless the test also asserts that nothing leaked.

std.testing.FailingAllocator fails after exactly N allocations. Sweeping N across an operation's whole allocation sequence, with testing.allocator underneath to detect leaks at test end, turns those paths into real assertions. (std.mem.Allocator.failing fails on the first allocation, which only covers entry-point OOM, not the interesting mid-construction failures.)

This is not a coverage exercise; the sweep immediately found three genuine bugs in rational.zig that ordinary tests could never reach:

  1. A double free. initOwned took its numerator and denominator by value and freed them via errdefer on its own failure, while every caller ALSO had an errdefer for the same values. Any allocation failure inside normalization freed them twice, which segfaulted.
  2. A dangling copy. Because the parts were passed by value, and normalization can reallocate limbs, a caller's errdefer could free a stale pointer even without the double free. Fixed by renaming to finish and taking both parts by pointer, with the contract that ownership transfers only on success: on failure the caller's errdefer sees live, current values and frees them exactly once.
  3. Two struct-literal leaks. initInt, initZero and initRatio built .{ .num = try initSet(...), .den = try initSet(...) } in a single literal. An errdefer cannot cover a value that does not exist yet, so a failure on the second allocation orphaned the first. Fixed by building the parts as separate statements, each with its own errdefer. floor and factorial were also missing an errdefer on their denominator.

The remaining uncovered lines in these modules are unreachable arms guarded upstream, and diagnostic paths inside the tests themselves.


3. Struct Layout Engine

3.1 Mini-DSL Grammar

struct_def    := "struct" "{" field_list "}"
field_list    := (field ";")*
field         := type_name identifier
type_name     := "u8" | "u16" | "u32" | "u64"
                | "i8" | "i16" | "i32" | "i64"
                | "f32" | "f64" | "bool" | "ptr"
                | "[" number "]" type_name        // arrays
identifier    := [a-zA-Z_][a-zA-Z0-9_]*

3.2 ABI Profile Interface

pub const AbiProfile = struct {
    name: []const u8,
    pointer_size: u8,       // bytes
    max_alignment: u8,      // maximum alignment enforced

    /// Given a field type, return its (size, alignment) in bytes
    field_layout: *const fn (field_type: FieldType) -> struct { size: u8, alignment: u8 },

    /// Struct-level alignment (usually max of all field alignments)
    struct_alignment: *const fn (fields: []const FieldInfo) -> u8,

    /// Whether tail padding is added to round up to struct alignment
    tail_padding: bool,
};

// Built-in profiles
pub const x86_64_sysv = AbiProfile{ ... };    // default
pub const x86_64_win64 = AbiProfile{ ... };   // future
pub const packed = AbiProfile{ ... };          // no padding

3.3 Layout Computation Algorithm

function compute_layout(fields, abi):
    offset = 0
    struct_align = 1
    result_fields = []

    for each field in fields:
        (size, align) = abi.field_layout(field.type)
        struct_align = max(struct_align, align)

        # Insert padding for alignment
        padding = (align - (offset % align)) % align
        if padding > 0:
            emit_padding(offset, padding)
        offset += padding

        # Place field
        emit_field(field.name, field.type, offset, size)
        offset += size

    # Tail padding
    if abi.tail_padding:
        tail = (struct_align - (offset % struct_align)) % struct_align
        if tail > 0:
            emit_padding(offset, tail)
        offset += tail

    return StructLayout {
        total_size: offset,
        alignment: struct_align,
        fields: result_fields,
    }

3.4 Layout Result Structure

pub const StructLayoutResult = struct {
    total_size: usize,
    alignment: u8,
    fields: []const LayoutField,
    bytes: []const ByteInfo,     // byte-by-byte map for visualization
    abi_name: []const u8,
};

pub const LayoutField = struct {
    name: []const u8,
    type_name: []const u8,
    offset: usize,
    size: usize,
    alignment: u8,
    padding_before: usize,
};

pub const ByteInfo = union(enum) {
    field: struct { field_index: usize, byte_within_field: usize },
    padding: struct { before_field_index: usize },
    tail_padding: void,
};

4. Unit Conversion Engine

4.1 Architecture

Units are organized by category. Each category has a canonical base unit. All conversions go through the base unit (from -> base -> to), except temperature which uses formula-based conversion.

pub const UnitCategory = enum {
    length, mass, temperature, time, digital_storage,
    speed, area, volume, energy, pressure, data_rate, angle,
};

pub const UnitDef = struct {
    name: []const u8,            // canonical name (e.g., "km")
    aliases: []const []const u8, // alternatives (e.g., "kilometer", "kilometers")
    category: UnitCategory,
    /// base = value * to_base_factor + to_base_offset
    /// DERIVED at comptime from the exact text below; never written by hand.
    to_base_factor: f64,
    to_base_offset: f64 = 0,
    /// The factor as exact text (decimal or `p/q`), or null when the factor is
    /// irrational and no exact form exists.
    factor_text: ?[]const u8 = null,
    offset_text: ?[]const u8 = null,

    pub fn toBase(self: UnitDef, value: f64) f64 {
        return value * self.to_base_factor + self.to_base_offset;
    }

    /// The reverse direction is DERIVED, not stored.
    pub fn fromBase(self: UnitDef, base: f64) f64 {
        return (base - self.to_base_offset) / self.to_base_factor;
    }
};

Factors are declared as exact text; the f64 is derived. Tables are written with define(.length, &.{ .{ .name = "in", .factor = "0.0254" }, ... }), and define computes the f64 fields at comptime. This is the same single-source-of- truth argument as fromBase below, applied one level up: an inch is exactly 127/5000 m, and if the exact and approximate forms were both hand-written they could disagree.

Some factors need the p/q form because they have no terminating decimal expansion: Fahrenheit scales by exactly 5/9, a knot is exactly 1852/3600 m/s, a torr exactly 101325/760 Pa. A few units are defined in terms of pi (the angle units, and the parsec at 648000/pi au) and so have NO exact rational factor; those declare approx_factor instead and their conversions fall back to the inexact tier.

Only the to-base mapping is stored; the reverse is derived. An earlier draft of this design gave each unit four fields (to_base_factor/to_base_offset plus from_base_factor/from_base_offset). That was changed because it lets a table entry describe a conversion that disagrees with its own inverse: nothing stops a typo in from_base_factor from making km -> m -> km lossy. Deriving fromBase by inverting toBase gives one source of truth per unit, halves the numbers a contributor has to get right, and makes round-trip correctness structural rather than something tests have to police. A unit test still round-trips every unit and every within-category unit pair as a guard against bad factors.

4.2 Conversion Resolution

Unit names resolve through findUnit(name), which searches every category's table by canonical name and alias:

  1. An exact, case-sensitive match is tried first. This is what keeps case-distinguished units meaningful: K is Kelvin (not a "kilo" something), B is byte, and kB (1000) never silently becomes KiB (1024).
  2. Only if nothing matches exactly does a case-insensitive pass run, which is what makes forgiving input like KM or Celsius work.

Conversion goes through the category base unit: to.fromBase(from.toBase(value)). Both units must be in the same category, or the result is IncompatibleUnits. An unrecognized name is UnknownUnit.

A single multiplicative factor is reported alongside the result only when both units are linear (zero offset). Temperature conversions are affine, so no single factor describes them and the factor is reported as null.

Free-form conversion requests (units.parseRequest) let conversion be plain expression input rather than a special subcommand, so tally 32F in C and typing 100 km to mi in the TUI both work:

  • A standalone, whitespace-delimited, case-insensitive to or in is the trigger. This needs no unit-aware tokenizer, because neither word is otherwise an identifier in the grammar.
  • No separator means parseRequest returns null and the caller falls through to ordinary expression evaluation, so existing behavior is untouched.
  • in is both a separator and the name for inches. Candidate separators are therefore tried last-to-first, keeping the first reading that yields a valid conversion. In 5 in in cm the later in is the separator; in 100 mm in in the earlier one is, because only that reading resolves. 1 in in in is a valid identity. min is untouched because a separator must be whitespace-delimited.
  • Backtracking also improves errors: if a candidate reading fails, the reason is remembered, so a committed-but-invalid request reports UnknownUnit/IncompatibleUnits instead of a confusing parse error.
  • The trailing unit is split off by trying the longest suffix first, so 100 km yields ("100", km) and not ("100 k", m), and no space is required (32F). A candidate must start at a token boundary (the preceding character may not be a letter), which is what stops the trailing s of 100 smoots from being read as seconds.
  • The value portion is returned unevaluated as text, so parseRequest needs no dependency on the evaluator and callers can evaluate it in whatever environment they already have. This is why 2*3 kg to lb works.

NOT YET IMPLEMENTED: unit-qualified arithmetic (5 kg + 3 lb), which genuinely does need unit-aware tokenizing and evaluation. The planned approach:

For expressions like 5 kg + 3 lb, the parser would:

  1. Identify 5 kg as a unit-qualified value
  2. See + 3 lb and convert 3 lb to kg (left-hand unit wins)
  3. Evaluate as 5 + 1.36078 = 6.36078 kg

4.3 Adding New Units

New units require only a table entry - no parser changes:

// In units.zig
const length_units = [_]UnitDef{
    .{ .name = "m",  .aliases = &.{"meter", "meters"},  .category = .length, .to_base_factor = 1.0 },
    .{ .name = "km", .aliases = &.{"kilometer", "kilometers"}, .category = .length, .to_base_factor = 1000.0 },
    .{ .name = "mi", .aliases = &.{"mile", "miles"},    .category = .length, .to_base_factor = 1609.344 },
    // ... add new entries here
};

Invariants enforced by tests rather than convention: every category's base unit must have to_base_factor == 1 and no offset, no unit may have a zero factor, canonical names must be unique across all categories, every unit must be listed in the table matching its own category field, and every unit and unit pair must round-trip. A bad table entry fails the suite instead of silently producing wrong answers.


5. Financial Module

5.1 CAGR

CAGR = (end_value / start_value) ^ (1 / periods) - 1

5.2 Compound Interest

FV = PV * (1 + rate/100/m)^(m * years)

Solvable for any of its four variables, all in closed form:

PV    = FV / (1 + rate/100/m)^(m*years)
rate  = m * ((FV/PV)^(1/(m*years)) - 1) * 100
years = ln(FV/PV) / (m * ln(1 + rate/100/m))

m (compounds per year) is deliberately NOT solvable. As m rises, FV climbs from the annual case toward the continuous limit PV * e^(rate*years/100), so a solution exists only inside that band: for 10,000 at 6% over 10 years, only for future values between about 17,908 and 18,221. Nearly every real input has no answer, and a fractional answer ("compound 7.3 times a year") would be meaningless, so it stays an input with a prefilled default of 1.

Nominal versus effective. compoundRate returns a NOMINAL annual rate: at m = 12 it is the APR a lender quotes, not what the money earned. effectiveAnnualRate converts (18% nominal monthly is 19.56% effective), and the TUI shows both whenever m > 1. This only matters when solving, because a rate that was typed in is one the user already understands; a rate the calculator produces is one they are about to compare against something else.

Solving for the rate at m = 1 is exactly CAGR. Both entry points are kept: CAGR is a named thing people look for, and it takes periods rather than years so it works for quarters or months without a frequency argument. A test asserts the two agree, so they cannot drift.

5.3 TVM (Time Value of Money)

The five TVM variables: N (periods), I/Y (interest rate per period), PV (present value), PMT (payment), FV (future value).

Relationship:

PV * (1 + r)^N + PMT * [((1 + r)^N - 1) / r] + FV = 0

where r = I/Y / 100.

Solving for the unknown variable:

  • FV, PV, PMT: algebraic (closed-form)
  • N: logarithmic solution
  • I/Y: Newton-Raphson iterative solver (no closed form)

Sign convention is the cash-flow one used by financial calculators: money received is positive, money paid out is negative. A borrower taking a 200,000 loan has pv = 200000 and a negative pmt. The solver never flips signs on the caller's behalf, because a silent flip is harder to debug than a wrong-looking answer.

The rate solver takes its derivative by central difference rather than analytically: the annuity-due form of the derivative is unwieldy and the numerical version is not the accuracy bottleneck. Two details make it reliable:

  1. Scale-relative acceptance. The residual of the TVM equation is proportional to the size of the cash flows, so it is compared against tolerance * max(|pv|, |fv|, |pmt| * n, 1). With an absolute tolerance a 200,000 mortgage cannot converge at all, because floating-point noise in the residual exceeds any fixed epsilon.
  2. Multiple seeds. The residual can be flat or badly sloped near a poor initial guess, so the solver retries from a small set of starting rates (5%, 1%, 10%, 0.5%, 25%, -5%, 50%). A single seed fails on well-posed inputs.

5.4 Amortization

An amortization schedule is generated period by period from the balance rather than from a closed form, because that is the only way the rounding matches what a lender's table shows. Each period:

interest  = round(balance * r)
principal = payment - interest
balance   = balance - principal

Rows are read from the borrower's side, so every figure is positive: payment is an amount paid, balance an amount owed. Mixing in the TVM sign convention only makes a table harder to read.

Three behaviors are deliberate:

  • The final period settles the balance exactly. Cent rounding leaves a residue over hundreds of periods, so the last payment differs slightly from the rest. This is what real schedules do, and it is why the total of the principal column equals the loan amount to the cent.
  • An overlarge payment retires the loan early, and the schedule simply ends short of periods.
  • An underfunded term ends in a balloon. A payment that covers the interest but not the full term leaves a large final payment, which is shown rather than smoothed away. A payment that does not cover even the first period's interest never amortizes at all, so it is a domain error rather than an infinite loop.

Single rows (amortizationEntry) and totals (amortizationTotals) walk the same cursor as the full schedule, so a row fetched on its own can never disagree with the same row inside a table. Only the full schedule allocates.

5.5 Reachability: expression functions, not just a mode

Financial calculations are exposed as ordinary expression functions in standard mode, mirroring how unit conversion is reachable from a bare expression. That keeps them composable (cagr(10000, 25000, 5) * 100) and usable from the CLI without a subcommand per formula. See FR-5.7 for the function list.

This required the evaluator's function dispatch to grow beyond the 0/1/2-argument cases it previously handled; financial functions take three or four arguments. An unknown name at those arities still reports UnknownFunction, and a wrong arity for a known financial function does too, rather than filling in defaults.

The one exception is the amortization table itself: a table is not a value, so it lives behind the tally amort subcommand (FR-6.11). The per-period figures are still reachable as functions (amort_interest(...) and friends).

5.6 Precision: f64 plus explicit rounding

This module is f64, not the exact Number tier used elsewhere (design 2.7). Every formula here needs a non-integer power or a logarithm - CAGR raises to 1/n, the period solver takes ln, the rate solver iterates - and those escape the rationals by definition, so there is nothing for the exact tier to preserve.

What money needs is not exactness but controlled rounding, which is a separate concern: roundToScale(value, decimals, mode) and roundToCents are applied at the point a figure becomes an amount. The default is half-even (banker's rounding); half-up is available but is not the default because it biases totals upward across many roundings.


6. C API (for Android/FFI)

// c_api.zig - extern "C" exports
//
// SAFETY: All string inputs use pointer + length (not null-terminated).
// This avoids buffer overread vulnerabilities and allows embedded nulls.
// All returned strings include a length - caller must free with tally_result_free().

pub const CalcString = extern struct {
    ptr: [*]const u8,
    len: usize,
};

pub const CalcResult = extern struct {
    /// JSON-encoded result. Null if error.
    json_ptr: ?[*]u8,
    json_len: usize,
    /// Error message if json_ptr is null.
    error_ptr: ?[*]u8,
    error_len: usize,
};

/// Evaluate an expression, return JSON result.
/// Caller must free the result with tally_result_free().
export fn tally_eval(
    expr_ptr: [*]const u8,
    expr_len: usize,
    mode: c_int,                      // 0=standard, 1=programmer, 2=financial
    config_ptr: ?[*]const u8,         // optional JSON config (null if none)
    config_len: usize,                // 0 if no config
) callconv(.c) CalcResult;

/// Compute struct layout, return JSON result.
export fn tally_struct_layout(
    def_ptr: [*]const u8,
    def_len: usize,
    abi_ptr: [*]const u8,             // "sysv", "win64", "packed"
    abi_len: usize,
    endian: c_int,                    // 0=little, 1=big
) callconv(.c) CalcResult;

/// Convert a value between units, return JSON result.
export fn tally_convert(
    value: f64,
    from_ptr: [*]const u8,
    from_len: usize,
    to_ptr: [*]const u8,
    to_len: usize,
) callconv(.c) CalcResult;

/// Free a CalcResult's buffers.
export fn tally_result_free(result: *CalcResult) callconv(.c) void;

/// Get version string (static lifetime, do not free).
export fn tally_version(out_len: *usize) callconv(.c) [*]const u8;

Design notes:

  • No null-terminated strings. All inputs take (ptr, len) pairs. This prevents buffer overread, allows binary data in expressions if ever needed, and is the idiomatic C pattern for length-known strings.
  • CalcResult struct bundles success/error in a single return. Caller checks json_ptr != null for success.
  • JSON result format keeps the Android/FFI boundary simple - Kotlin parses JSON natively. On the JNI side, the Kotlin bridge reads (ptr, len) into a ByteArray and decodes UTF-8.

7. CLI Design

tally [OPTIONS] <expression>
tally struct [OPTIONS] <definition | --file path>
tally convert <value> <from_unit> <to_unit>
tally amort <principal> <rate> <periods> [payment] [--monthly] [--summary] [--exact]

Options:
  -p, --programmer       Programmer mode
  -b, --bits <WIDTH>     Bit width: 8, 16, 32, 64 (default: 64)
  --base <BASE>          Output base: dec, hex, oct, bin, all (default: all)
  --json                 JSON output
  --abi <ABI>            Struct ABI: sysv (default), packed
  --endian <E>           Endianness: little (default), big
  -h, --help             Show help
  --version              Show version

CLI Output Examples

Standard:

$ tally "2^32 - 1"
4,294,967,295

Programmer (actual output, verified against the binary):

$ tally -p "0xFF & 0x0F"
  dec(signed):   15
  dec(unsigned): 15
  hex: 00 00 00 00 00 00 00 0F
  oct: 0 000 000 000 000 000 000 017
  bin: 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 1111

Separators are spaces, not underscores, and the base prefix appears only in the clipboard form. There is no bits: line.

Struct:

$ tally struct "struct { u32 x; u8 flags; u16 id; u64 ts; }"
ABI: x86-64 System V | Endian: little

  Offset  Size  Field       Type   Align  Padding
  ──────  ────  ──────────  ─────  ─────  ───────
  0       4     x           u32    4
  4       1     flags       u8     1
  5       1     (padding)                  1 byte
  6       2     id          u16    2
  8       8     ts          u64    8

  Total: 16 bytes (alignment: 8, padding: 1 byte, tail: 0)

  Memory map:
  00: [xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx] x
  04: [ffff ffff] [········] [iiii iiii iiii iiii] flags, pad, id
  08: [tttt tttt tttt tttt tttt tttt tttt tttt  ts
  0C:  tttt tttt tttt tttt tttt tttt tttt tttt]

Unit conversion (actual output):

$ tally convert 100 km mi
100 km = 62.13711922373339696174 mi

$ tally "72 F to C"
72 F = 22.22222222222222222222 C

Both operands are echoed, canonical short names are used rather than long or symbol forms, and the full exact expansion is shown (20 fractional digits for a non-terminating one). tally "5 kg + 3 lb" is FR-4.2 and is NOT implemented: it reports error: unexpected token.


8. TUI Layout

8.0.0 How the TUI is tested

Terminal code is usually left uncovered on the grounds that it needs a terminal. Most of it does not. Two harnesses cover nearly all of it:

  • Rendered frames. src/tui/test_render.zig draws a real frame through the real widget draw path into a real surface, then reads the cells back as rows of text. Assertions are made against what a user would see ("= PMT -1,199.10", "rows 1-7 of 360"), plus furniture(rows).intact(), which checks that the separator, prompt and status rows are still where they belong. That last check is the load-bearing one: content which overflows its region lands on those rows, so checking them is how overflow is detected.

    This originally offered wellFormed(rows, width) instead, described here as a check that no column had been miscomputed. It compared each row's length against the width it had just been allocated with, so it could never fail. Two layout bugs (programmer rows drawn over the prompt at 100x16, financial chips drawn over it at 20x5) lived under a green suite because of it. Style is still not observable: flatten keeps graphemes and drops attributes, so focus highlighting and the cursor cannot be asserted, and tests that care assert a neighbouring glyph and say so.

  • Real event handlers. vxfw.EventContext is constructible in a test, so key presses and mouse events go through handleKey and handleMouse rather than through a reimplementation of them. This is what catches a binding that was never routed, or one mode swallowing another mode's keys.

What is left uncovered is the terminal boundary itself: the event loop, vaxis initialisation, and main. Coverage is reported per source tree (engine, CLI, TUI) with disjoint include paths, so a file is never accounted for twice and a query tool never has to guess which report answers for it.

8.0 Mouse Input Architecture

The TUI is mouse-driven as well as keyboard-driven (FR-7.11). Rather than each view doing its own coordinate arithmetic against hardcoded layout constants, hit targets are registered as a side effect of drawing:

pub const Action = union(enum) {
    mode: Mode,                                          // tab bar
    prog_field: struct { field: ProgField, bit: ?u7 },   // focus field, optional cursor
    toggle_bit: u7,                                       // flip one bit
    focus_input, close_help, toggle_float,
    cycle_width, toggle_endian, toggle_signedness, toggle_float_format,
    conv_category: UnitCategory, conv_from: usize, conv_to: usize, conv_swap,
    fin_form: Form, fin_field: usize,                     // financial chips and rows
};

pub const RegionSet = struct {
    items: [512]HitRegion,   // { row, col, len, action }
    count: usize,
    pub fn clear(self: *RegionSet) void;
    pub fn add(self: *RegionSet, row: u16, col: u16, len: u16, action: Action) void;
    pub fn at(self: *const RegionSet, row: u16, col: u16) ?Action;
};

Key properties:

  • Regions are rebuilt every frame. clearRegions() runs at the top of the draw function, and each view calls addRegion right where it draws the thing. Hit targets therefore cannot drift out of sync with what is on screen, which is the usual failure mode for hand-maintained click maps.

    With one caveat that was found by review: registration is co-located with drawing, but drawing itself had no bounds check, so a row drawn off the bottom registered a region anyway and a click on the separator focused an invisible field. Co-location keeps the mapping honest only as far as the drawing is; the height guards added to the programmer and convert views are what make the property hold.

  • Newest region wins. at() searches backwards, so a view can register a broad row-wide fallback first (click anywhere on the HEX row to focus it) and then finer targets on top (click a specific nibble to put the cursor there).

  • Draw-time registration means one source of truth. The same loop that decides where a bit character goes also registers that cell, so the mapping from cell to bit index is written once. registerDigitRegions mirrors drawFieldWithCursor's digit walk for the same reason.

  • Left press only. Release, motion, and drag are ignored so one physical click produces exactly one action. The wheel is the one exception: in financial mode wheel_up/wheel_down scroll the amortization schedule, handled before the press filter because a wheel event is not a press. The wheel does not go through the region table at all, since it acts on the view rather than on a specific cell.

  • A fixed-size buffer, not an allocation. Drawing happens on every keystroke and must not fail; an exhausted budget silently drops extra regions (a click does nothing) instead of erroring. 512 comfortably covers the worst case, the 128-bit programmer view.

vxfw already puts the terminal in mouse mode and hit-tests surfaces, delivering .mouse events to the root widget with local coordinates equal to screen coordinates, so no terminal-level setup is needed here.

Click semantics are chosen per field to match what the data means: a bit grid cell and a BIN digit each represent exactly one bit, so clicking flips it, while a HEX nibble or OCT digit holds several bits with no single sensible "toggle", so clicking places the cursor for typing instead.

8.1 Standard Mode

┌─ Tally ─────────────────────── [Standard] [Programmer] [Financial] ─┐
│                                                                        │
│  History:                                                              │
│    2^10 = 1024                                                         │
│    sqrt(144) = 12                                                      │
│    Ans * 2 = 24                                                        │
│                                                                        │
│  ─────────────────────────────────────────────────────────────────     │
│  > 3 * pi + 1_                                                         │
│                                                                        │
│  = 10.42477796...                                                      │
│                                                                        │
└─ ?:help | Tab:mode | Enter:eval | Ctrl-L:clear | Ctrl-C:quit ───────────┘

The footer is the real status line. The [d]ec [h]ex [o]ct [b]in quick-switch it used to show was FR-7.8, which has been removed: those letters are hex and binary digits once the value fields became editable.

8.2 Programmer Mode

┌─ Tally ─────────────────────── [Standard] [Programmer] [Financial] [Convert] ─┐
│ Bit Width: [64]  Signed: [yes]  Endian: [LE]                                    │
│                                                                                  │
│ ┌─ Bit Pattern (63 -> 0) ─────────────────────────────────────────────────────┐  │
│ │ 0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0 │  │
│ │ 0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  1 1 1 1  1 1 1 1 │  │
│ └─────────────────────────────────────────────────────────────────────────────┘  │
│                                            cursor: bit 3  ▲                      │
│                                                                                  │
│  Signed:    255                                                                  │
│  Unsigned:  255                                                                  │
│  Hex:       0x0000_0000_0000_00FF                                                │
│  Oct:       0o377                                                                │
│  Bin:       0000 0000 0000 0000 0000 0000 1111 1111                              │
│                                                                                  │
│  > 0xFF & 0x0F_                                                                  │
│  = 15                                                                            │
│                                                                                  │
└──── arrows:nav  Space:toggle  w:width  s:sign  e:endian  y:yank -- Tab:mode ----┘

NOTE: The wireframe above (8.2) is superseded by the editable-fields design below. Keeping the old wireframe for reference during transition.

8.2.1 Programmer Mode - Editable Fields (REVISED DESIGN)

The programmer mode is an editable multi-field form where every representation of the value is live-editable. A change in any field instantly updates all others. Closer to integer.exposed than a traditional calculator with a prompt.

Fields (navigable with Up/Down):

  1. Bit grid - arrow keys move, Space toggles individual bits
  2. DEC(s) - editable signed decimal
  3. DEC(u) - editable unsigned decimal
  4. HEX - editable hex (space-separated bytes: FF 00 AB CD)
  5. ASCII - read-only (derived) view of the bytes, one glyph per byte, MSB first, aligned column-for-column under the HEX row directly above it. Printable bytes (0x20-0x7E) show as themselves; every other byte shows as ., matching the xxd hex-dump convention (e.g. .ELF, ...ascii). Not part of the Up/Down focus cycle since it has nothing to edit.
  6. OCT - editable octal
  7. BIN - editable binary (nibble-separated: 1111 0000)
  8. Expression - full expression evaluator (& | ^ ~ << >> etc.)

Navigation:

  • Up/Down moves focus between fields
  • When a text field is focused, typing replaces its content; value updates on Enter (or live as you type - TBD)
  • When bit grid is focused: left/right/up/down navigate, Space toggles
  • Tab cycles between Standard/Programmer mode
  • Ctrl-W cycles bit width (8/16/32/64/128)
  • Ctrl-E toggles endianness display (LE/BE)

Bit width is a non-destructive display lens:

  • The stored value keeps full precision (u128). Cycling the width never truncates it, so narrowing to a smaller width and then widening again restores the hidden upper bits.
  • The DEC/HEX/OCT/BIN/ASCII rows and the bit grid show the value masked to the current width.
  • While the stored value has bits set beyond the current width, a warning line is shown ("value exceeds N bits - showing low bits (widen to restore)"). It is a warning, not an error: the value is still usable.
  • Explicit value edits (typing a decimal digit, toggling a bit, editing a hex/oct/bin digit) commit to the current width, clearing any hidden upper bits. Only width cycling is non-destructive; deliberately editing at a narrow width means you are now working at that width.

Endianness:

  • Toggle with Ctrl-E; header shows current setting (Endian: LE / Endian: BE)
  • Default: big-endian. The HEX row's primary job is to show the value in hex, so by default it reads MSB-first and matches DEC/OCT/BIN. Little-endian is the opt-in "how it sits in x86-64 memory" view.
  • Affects: HEX byte display order and ASCII byte order only. In LE the display bytes are reversed (e.g. 0xDEADBEEF -> EF BE AD DE).
  • Does NOT affect: the underlying value, DEC, OCT, or BIN display, nor the clipboard raw form of HEX (which stays the canonical MSB-first number).
  • Rationale for BE default (revised from an earlier LE default): defaulting to LE would reverse the HEX row so it no longer matches the number shown in the DEC/BIN rows directly beside it, which is surprising in a calculator context.

Text/ASCII literals:

  • Single-quoted strings in expressions: 'ELF', 'RIFF', 'a'
  • Each character becomes one byte packed into the integer
  • Packing order follows endianness setting:
    • BE: first char -> most significant used byte ('AB' = 0x4142)
    • LE: first char -> least significant byte ('AB' = 0x4241)
  • Max chars = bit_width / 8 (1 for 8-bit, 2 for 16, 4 for 32, 8 for 64)
  • Non-ASCII characters are an error
  • Useful for: magic numbers ('ELF'), protocol tags, inspecting how string bytes pack into integer registers

IEEE 754 float interpretation:

  • A sub-view within programmer mode (toggle with a key, e.g. Ctrl-F)
  • Reinterprets the current bit pattern as an IEEE 754 float
  • Supported formats:
    • float32 (1 sign + 8 exponent + 23 significand, bias 127)
    • float64 (1 sign + 11 exponent + 52 significand, bias 1023)
  • Bit grid color-codes the three fields:
    • Sign bit: one color (e.g. pink)
    • Exponent: another color (e.g. cyan)
    • Significand: another color (e.g. green)
  • Display below the bit grid shows:
    • Decimal value (exact)
    • Value and ULP use scientific notation for very small/large magnitudes (e.g. a subnormal ULP shows as 1.4e-45), fixed-point otherwise, so each row stays on one line
    • Formula: (-1)^s * 2^(e-bias) * 1.mantissa
    • Classification: normal, denormal, zero, infinity, NaN
    • ULP: delta to next representable value
    • Raw fields: sign=0, exp=127 (unbiased: 0), frac=0x000000
  • Input: can type a decimal float value (e.g. 3.14) and it shows the closest representable bit pattern, noting if rounding occurred
  • Auto-entry: switching into programmer mode (Tab) with a non-integer value in ans opens this float view automatically on the exact f64 bit pattern, since the integer bit views cannot represent a fraction. An integer ans uses the integer views as usual. Infinity/NaN also open the float view (the only place they are representable).
  • Toggling bits in the grid updates the float interpretation live
  • Does NOT change bit_width setting - float32 uses 32-bit view, float64 uses 64-bit view (auto-switches width when toggling float format)

8.3 Struct Visualizer (sub-view of Programmer Mode)

┌─ Tally ────────────────── [Programmer > Struct Layout] ─────────────┐
│ ABI: x86-64 SysV  Endian: LE                                         │
│                                                                        │
│ Definition:                                                            │
│   struct { u32 x; u8 flags; u16 id; u64 ts; }                        │
│                                                                        │
│ ┌─ Memory Map ─────────────────────────────────────────────────────┐  │
│ │ 00 01 02 03 │ 04 │ 05 │ 06 07 │ 08 09 0A 0B 0C 0D 0E 0F │     │  │
│ │ ████████████ │ ▓▓ │ ░░ │ ▒▒▒▒▒ │ ░░░░░░░░░░░░░░░░░░░░░░░ │     │  │
│ │     x        │flag│pad │  id   │           ts              │     │  │
│ └───────────────────────────────────────────────────────────────────┘  │
│                                                                        │
│  Field        Type   Offset  Size  Align  Pad Before                   │
│  x            u32    0       4     4      0                            │
│  flags        u8     4       1     1      0                            │
│  id           u16    6       2     2      1                            │
│  ts           u64    8       8     8      0                            │
│                                                                        │
│  Total: 16 bytes | Alignment: 8 | Padding: 1 byte (6.25%)            │
│  Packed would be: 15 bytes                                             │
└──── a:abi  e:endian  Enter:edit struct  Esc:back ─────────────────────┘

8.4 Convert Mode

Tally                                  [Standard] [Programmer] [Convert]

  Category:
    Length  Mass  Temperature  Time  Digital Storage  Speed  Area
    Volume  Energy  Pressure  Data Rate  Angle

  100 km
  = 62.13711922373339 mi
  1 km = 0.621371192237334 mi

  From                      To
  > nm                        nm
    um                        um
    mm                        mm
    ...                     > mi

  ------------------------------------------------------------------
  > 100
  Arrows:select | Ctrl-S:swap | `:input | Tab:mode | ?:help

Structure:

  • Category chips wrap to as many lines as the terminal width needs. The selected chip is highlighted; when the selection zone has focus on the category column it is highlighted differently again.
  • The value comes from the shared input line at the bottom, keeping input consistent with the other modes. A bare number is used directly; anything else is evaluated as a standard expression first, so 2*3.5 or sqrt(2) work as input values.
  • The factor line shows the single multiplicative factor between the two units. Temperature is affine, so instead of a misleading number it says so.
  • Two unit columns (From and To) list every unit in the category with the selection marked. Both columns are clickable; the whole column slot is the click target, not just the name text, so a one-character unit like m is not a one-character target.
  • If the terminal is too short to show every unit, a ... N more (resize to see all) line is drawn rather than silently truncating the list.

Interaction:

  • Backtick toggles between the input zone and the selection zone, matching the convention already used in programmer mode.
  • In the selection zone, Left/Right move between the category, From, and To columns; Up/Down move the selection within the focused column, wrapping.
  • Ctrl-S swaps the two units, and works from either zone.
  • Selecting a new category resets the unit pair to that category's defaults (its base unit plus a distinct second unit), since the previous indices would refer to unrelated units in the new table.

8.5 Financial Mode

Tally                     [Standard] [Programmer] [Financial] [Convert]

  Calculation:
     CAGR   Compound   TVM   Amortization

  > Principal           200,000
    Rate per period     0.5 %
    Periods             360
    Payment             (derived)

  Payment 1,199.10 | total interest 231,677.04 | total paid 431,677.04

    Period      Payment     Interest    Principal      Balance
         1     1,199.10     1,000.00       199.10   199,800.90
         2     1,199.10       999.00       200.10   199,600.80
         3     1,199.10       998.00       201.10   199,399.70
         ...
    rows 1-7 of 360  (PgUp/PgDn)
  ------------------------------------------------------------------
  >
  Up/Dn:field | L/R:calc | Enter:eval | Space:END/BGN | Ctrl-U:clear

Four forms behind one set of chips: CAGR, Compound, TVM, Amortization. The TVM form adds a sixth row, an END/BGN toggle for annuity due.

Solving by omission. TVM and compound interest solve for whichever field is left blank, the way a financial calculator does and the way the engine's own TvmParams is shaped. Blank is therefore a meaningful state rather than an error, and a blank solve-for field renders as [solve] instead of looking like missing input. When the form is not yet answerable it says what it is waiting for ("clear one field to solve for it"), so an incomplete form is never a silent no-op. Amortization treats a blank payment differently: it means "derive it", since a schedule always has a payment.

Defaults are prefilled, not placeheld. Compounds/year starts at 1 rather than showing an empty field that means "annual". That keeps "blank means solve for this" true for every field the user sees, instead of blank meaning a default in one place and a solve slot in another. The prefill is baked into the comptime initial state rather than applied at draw time, because a draw-time default cannot tell "never touched" from "deliberately cleared". Clearing a non-solvable field has to report that it needs a value, not try to solve for it.

A solved rate says which kind it is. Above annual compounding the compound form reports = rate 18.00% nominal (19.56% effective). See design 5.2 for why: the nominal figure alone invites a wrong comparison against a quoted APY, and this is the only place the calculator produces a rate rather than consuming one.

Two ways to enter a number, deliberately. Typing in the form zone goes straight into the focused field, which is what makes quick entry work. The shared input line at the bottom evaluates an expression and drops the result into the focused field, so a value can also be computed from outside the form.

Fields hold expressions, and group as you type. 12 * 30 is valid input for a period count; it displays verbatim in orange with an (Enter) prompt, and Enter evaluates it in place. A plain number is grouped for display as it is typed (200 then 0 shows 2,000), while the buffer keeps the ungrouped text so it still parses and still round-trips through Enter. Grouping the buffer instead would mean stripping separators on every read and would make cursor arithmetic depend on where the commas happen to fall. A typed comma is absorbed for the same reason.

A field holding an unevaluated expression is reported as such rather than being treated as empty, since a blank field means "solve for this" in two of the four forms and silently conflating the two would be a wrong answer rather than a cosmetic problem.

Rates say they are rates. A percent field renders a trailing % after the value and absorbs a typed one, so 6% and 6 both mean six percent. The label carries that information too, but not once the eye has moved to the value column, which is where a bare 6 is ambiguous between a rate and a multiplier.

Focus styling. The focused field is a dark selection fill (Molokai's own selection colour) with the value in its normal foreground, and a single yellow block cursor. The earlier version reversed bright cyan into the background, which read as a 1980s terminal and fought every other colour on screen; a neutral fill keeps the number legible and leaves the cursor as the only bright cell on the row. The row marker and focused label are yellow, matching the mode's own tab colour rather than introducing a fourth accent.

Results are live, not history. Every keystroke recomputes, so there is nothing to submit and nothing is appended to history. That is why financial mode is the one mode whose input line does not produce a history entry.

The schedule is the reason this mode exists. Everything else here is available as an expression function (FR-5.7), but a 360-row table is not a value. It scrolls with PgUp/PgDn from either zone and with the mouse wheel, and shows rows X-Y of N whenever it is showing a window rather than the whole thing. The scroll offset is clamped at draw time rather than at scroll time, so a resize or a changed period count cannot leave a stale offset showing an empty table.

Formula breakdown. Under the result, the relationship being solved is shown, plus a substituted line for the forms where it fits (= (25000 / 10000)^(1/5) - 1). TVM and amortization show the general form only; substituting five variables into the TVM identity produces a line too long to be useful.

Interaction, consistent with the other modes:

  • Backtick toggles the input zone and the form zone.
  • Up/Down move between fields, wrapping. Left/Right switch calculation, so every form is reachable from the keyboard alone (FR-7.7).
  • Enter evaluates the focused field in place.
  • Space toggles END/BGN on the payments row; Ctrl-U (or Delete) empties a field, which is also how a TVM variable is marked as the one to solve for.
  • Clicking a chip selects a calculation; clicking a row focuses that field. Switching calculation resets the focused field and the scroll offset, but each form keeps its own entries, so moving between them does not discard typing.

8.6 Help Overlay

The overlay is a static line list (headers, bindings, free text) rendered through one windowed loop, not a sequence of sections that each gate themselves on the remaining height. The old shape had a specific failure: every section ended with if (row < height - 6), so as sections were added the later ones silently disappeared on a 24-row terminal with no indication anything was missing. That was found by a test asserting the Convert section was present, which failed once the Financial section was added above it.

As a line list the content can be scrolled (Up/Down, PgUp/PgDn, wheel) and its length is knowable without drawing, which is what lets the App clamp its own scroll offset. Any non-scrolling key returns, preserving the "press a key to get out" behaviour. The footer shows lines X-Y of N only when something is off screen, so a tall terminal is not cluttered with a position indicator it does not need.


9. Android UI Design

The Android app uses a fundamentally different interaction model from the TUI. Where the TUI is keyboard-driven with a prompt, Android is touch-first with purpose-built input surfaces for each mode. No text-cursor-in-a-prompt paradigm - instead, tappable buttons, interactive grids, and form fields.

9.1 Navigation & Shell

Bottom navigation bar with 4 destinations: Standard, Programmer, Financial, Convert.

┌─────────────────────────────────────────┐
│  ┌─────────────────────────────────┐    │
│  │                                 │    │
│  │        Mode Content Area        │    │
│  │                                 │    │
│  └─────────────────────────────────┘    │
│                                         │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  │
│  🔢 Standard  💻 Programmer  💰 Finance  🔄 Convert │
└─────────────────────────────────────────┘

9.2 Standard Calculator Screen

Classic calculator layout. Expression builds at top, result previews live below it, button grid fills the bottom half.

┌─────────────────────────────────────────┐
│  Expression area (scrollable)           │
│  ┌─────────────────────────────────┐    │
│  │ 2 × (3 + 4)                    │    │  ← expression in natural format
│  │                           = 14  │    │  ← live result preview (dimmed)
│  └─────────────────────────────────┘    │
│                                         │
│  History peek: last 2-3 results         │
│  ┌─────────────────────────────────┐    │
│  │ 5^2 = 25          sqrt(144) = 12│   │
│  └─────────────────────────────────┘    │
│                                         │
│  ┌─────────────────────────────────┐    │
│  │ sin │ cos │ tan │  (  │  )  │ ⌫ │    │  ← function row (swipeable for more)
│  ├─────┼─────┼─────┼─────┼─────┼───┤    │
│  │  7  │  8  │  9  │  ÷  │  ^  │   │    │
│  ├─────┼─────┼─────┼─────┼─────┤   │    │
│  │  4  │  5  │  6  │  ×  │  %  │   │    │
│  ├─────┼─────┼─────┼─────┼─────┤   │    │
│  │  1  │  2  │  3  │  -  │ sqrt│   │    │
│  ├─────┼─────┼─────┼─────┼─────┤   │    │
│  │  0  │  .  │ Ans │  +  │  =  │   │    │
│  └─────┴─────┴─────┴─────┴─────┘   │    │
└─────────────────────────────────────────┘

Key interactions:

  • Tap buttons to build expression (no keyboard typing required)
  • Long-press = to copy result
  • Swipe function row for more functions (log, ln, abs, etc.)
  • Tap history items to recall into expression
  • Pull-down to expand full history view

9.3 Programmer Mode Screen

Split into three vertical zones: value display, bit grid, and input buttons.

┌─────────────────────────────────────────┐
│  ┌─── Bit Width ────────────────────┐   │
│  │ [ 8 ] [ 16 ] [ 32 ] [●64]       │   │  ← segmented control
│  └──────────────────────────────────┘   │
│                                         │
│  ┌─── Value Display ────────────────┐   │
│  │ DEC  4,294,967,295              │   │  ← tappable to switch primary
│  │ HEX  0xFFFF_FFFF                │   │
│  │ OCT  0o37_777_777_777           │   │
│  │ BIN  1111 1111 1111 1111 11..  │   │
│  │ SIGN -1 (signed)                │   │
│  └──────────────────────────────────┘   │
│                                         │
│  ┌─── Bit Grid ─────────────────────┐   │
│  │ 31 30 29 28 │ 27 26 25 24       │   │  ← bit index labels
│  │  1  1  1  1 │  1  1  1  1       │   │  ← tappable cells
│  │ 23 22 21 20 │ 19 18 17 16       │   │
│  │  1  1  1  1 │  1  1  1  1       │   │
│  │ 15 14 13 12 │ 11 10  9  8       │   │
│  │  1  1  1  1 │  1  1  1  1       │   │
│  │  7  6  5  4 │  3  2  1  0       │   │
│  │  1  1  1  1 │  1  1  1  1       │   │
│  └──────────────────────────────────┘   │
│                                         │
│  ┌─── Operators ────────────────────┐   │
│  │ AND│ OR │XOR │NOT │ << │ >> │ROL │   │  ← bitwise op buttons
│  ├────┼────┼────┼────┼────┼────┼────┤   │
│  │  A │  B │  C │  D │  E │  F │   │   │  ← hex input
│  ├────┼────┼────┼────┼────┼────┤   │   │
│  │  7 │  8 │  9 │  0x│  0b│CLR │   │   │  ← digits + base prefix
│  ├────┼────┼────┼────┼────┼────┤   │   │
│  │  4 │  5 │  6 │  + │  - │  = │   │   │
│  ├────┼────┼────┼────┼────┼────┤   │   │
│  │  1 │  2 │  3 │  × │  ÷ │  ⌫ │   │   │
│  └────┴────┴────┴────┴────┴────┘   │   │
└─────────────────────────────────────────┘

Key interactions:

  • Tap any bit in the grid to toggle it - all displays update instantly
  • Haptic pulse on bit toggle
  • Tap DEC/HEX/OCT/BIN label to make that the primary input base
  • Bit grid scrolls horizontally for 64-bit (or collapses to 4 rows of 16)
  • FAB or toolbar button for "Struct Layout" sub-screen

9.4 Struct Layout Screen (Programmer sub-screen)

Accessed via a button within Programmer mode. Uses a form-based struct builder rather than raw text DSL (though raw text input is available via a toggle).

┌─────────────────────────────────────────┐
│  ← Back to Programmer                  │
│                                         │
│  ┌─── ABI / Settings ──────────────┐   │
│  │ ABI: [x86-64 SysV ▾]  Endian: [LE ▾]│
│  └──────────────────────────────────┘   │
│                                         │
│  ┌─── Fields ───────────────────────┐   │
│  │ ┌────────┬──────────┬─────────┐  │   │
│  │ │ Type ▾ │ Name     │    ⊖    │  │   │  ← each row is a field
│  │ ├────────┼──────────┼─────────┤  │   │
│  │ │ u32    │ x        │    ⊖    │  │   │
│  │ │ u8     │ flags    │    ⊖    │  │   │
│  │ │ u16    │ id       │    ⊖    │  │   │
│  │ │ u64    │ ts       │    ⊖    │  │   │
│  │ └────────┴──────────┴─────────┘  │   │
│  │          [ + Add Field ]          │   │
│  │     [ ✏️ Raw DSL input toggle ]    │   │
│  └──────────────────────────────────┘   │
│                                         │
│  ┌─── Memory Map ───────────────────┐   │
│  │ ██████████ ▓▓ ░░ ▒▒▒▒ ░░░░░░░░░ │   │  ← color-coded byte bar
│  │ x(4)      fl p  id   ts(8)      │   │  ← labels below
│  └──────────────────────────────────┘   │
│                                         │
│  ┌─── Summary ──────────────────────┐   │
│  │ Total: 16 bytes  Align: 8        │   │
│  │ Padding: 1 byte (6.25%)          │   │
│  │ Packed: 15 bytes                  │   │
│  └──────────────────────────────────┘   │
└─────────────────────────────────────────┘

Key interactions:

  • Type dropdown for each field (Material exposed dropdown menu)
  • Swipe-to-delete or tap ⊖ to remove a field
  • Drag handles to reorder fields (see padding change in real-time)
  • Memory map is a custom Canvas composable with tappable segments (tap a segment -> highlights field info)
  • Toggle for raw DSL text input (for copy-paste from code)
  • Share button: export struct layout as text or image

9.5 Financial Mode Screen

Card-based interface with function selector at top.

┌─────────────────────────────────────────┐
│  ┌─── Function ────────────────────┐    │
│  │ [●CAGR] [TVM] [Compound] [PV]  │    │  ← scrollable chip row
│  └─────────────────────────────────┘    │
│                                         │
│  ┌─── CAGR Inputs ────────────────┐     │
│  │                                 │     │
│  │  Start Value                    │     │
│  │  ┌─────────────────────────┐    │     │
│  │  │ 10,000                  │    │     │  ← text field
│  │  └─────────────────────────┘    │     │
│  │                                 │     │
│  │  End Value                      │     │
│  │  ┌─────────────────────────┐    │     │
│  │  │ 25,000                  │    │     │
│  │  └─────────────────────────┘    │     │
│  │                                 │     │
│  │  Periods (years)                │     │
│  │  ┌─────────────────────────┐    │     │
│  │  │ 5                       │    │     │
│  │  └─────────────────────────┘    │     │
│  │                                 │     │
│  │       [ Calculate ]             │     │
│  └─────────────────────────────────┘     │
│                                         │
│  ┌─── Result ─────────────────────┐     │
│  │  CAGR = 20.11%                 │     │
│  │                                 │     │
│  │  Formula:                       │     │
│  │  (25000/10000)^(1/5) - 1       │     │
│  │  = 2.5^0.2 - 1                 │     │
│  │  = 0.2011...                    │     │
│  └─────────────────────────────────┘     │
└─────────────────────────────────────────┘

Key interactions:

  • Chip row selects function type, input form adapts
  • For TVM: 5 input fields, user leaves one blank -> that's the solve target
  • Compute triggers engine call, result animates in
  • Result card shows formula breakdown (expandable)
  • Long-press result to copy

9.6 Convert Mode Screen

Designed for fast, one-handed unit conversion.

┌─────────────────────────────────────────┐
│  ┌─── Category ────────────────────┐    │
│  │ [Length] [Mass] [Temp] [Time] ▸ │    │  ← scrollable chip row
│  └─────────────────────────────────┘    │
│                                         │
│  ┌─────────────────────────────────┐    │
│  │                                 │    │
│  │   100                           │    │  ← large editable number
│  │   kilometers              [▾]   │    │  ← unit selector dropdown
│  │                                 │    │
│  │          ⇅  (swap)              │    │  ← tap to swap from/to
│  │                                 │    │
│  │   62.1371                       │    │  ← live result (updates as you type)
│  │   miles                   [▾]   │    │  ← unit selector dropdown
│  │                                 │    │
│  └─────────────────────────────────┘    │
│                                         │
│  ┌─── Keypad ─────────────────────┐     │
│  │  7  │  8  │  9  │             │     │
│  ├─────┼─────┼─────┤     ⌫      │     │
│  │  4  │  5  │  6  │             │     │
│  ├─────┼─────┼─────┼─────────────┤     │
│  │  1  │  2  │  3  │             │     │
│  ├─────┼─────┼─────┤      C      │     │
│  │  0  │  .  │  ±  │             │     │
│  └─────┴─────┴─────┴─────────────┘     │
└─────────────────────────────────────────┘

Key interactions:

  • Conversion updates live as user types (no "calculate" button needed)
  • Swap button rotates from/to units with an animation
  • Unit selectors open a searchable bottom sheet (for categories with many units)
  • Category chips filter which units appear in the selectors
  • Recent conversions remembered

9.7 Tablet Layout (landscape / large screen)

On tablets, modes use a two-pane layout:

┌─────────────────────┬──────────────────────────────┐
│                     │                              │
│   Input / Controls  │    Results / Visualization   │
│                     │                              │
│  (button grid,      │   (history, bit grid,        │
│   form fields,      │    memory map, formula        │
│   struct editor)    │    breakdown)                 │
│                     │                              │
└─────────────────────┴──────────────────────────────┘
│  🔢 Standard  💻 Programmer  💰 Finance  🔄 Convert │
└─────────────────────────────────────────────────────┘

9.8 Android Architecture (Technical)

┌──────────────────────────────────────┐
│  Compose UI Layer                    │
│  ├── StandardScreen                  │
│  ├── ProgrammerScreen                │
│  │   ├── BitGridComposable           │
│  │   └── StructVisualizerScreen      │
│  ├── FinancialScreen                 │
│  └── ConvertScreen                   │
├──────────────────────────────────────┤
│  ViewModel Layer                     │
│  ├── CalculatorViewModel             │
│  ├── ProgrammerViewModel             │
│  ├── FinancialViewModel              │
│  ├── ConvertViewModel                │
│  └── manages state, calls engine     │
├──────────────────────────────────────┤
│  JNI Bridge (TallyEngine.kt)         │
│  ├── external fun evaluate(...)      │
│  ├── external fun structLayout(...)  │
│  ├── external fun convert(...)       │
│  └── JSON serialization/deser        │
├──────────────────────────────────────┤
│  Native .so (libtally.so)            │
│  └── C ABI from engine/c_api.zig    │
└──────────────────────────────────────┘

The JNI bridge sends expression strings down and receives JSON results back. This keeps the boundary thin and debuggable.


10. Error Handling Strategy

Each module declares what it can fail with, and the sets compose:

// parser.zig
pub const Error = error{
    UnexpectedToken, UnmatchedParen, UnexpectedEnd,
    InvalidExpression, InvalidNumber, OutOfMemory,
};

// Rational.zig, inherited by number.zig
pub const Error = error{
    OutOfMemory, DivisionByZero, InvalidNumber,
    ExponentTooLarge, NegativeRoot,
};

// bitwise.zig
pub const Error = error{DomainError};

// units.zig: its own two, plus whatever the exact path raises
pub const Error = error{ UnknownUnit, IncompatibleUnits, OutOfMemory } || number.Error;

// financial.zig
pub const Error = error{
    InsufficientParameters, ConvergenceFailure,
    DomainError, DivisionByZero, OutOfMemory,
};

// evaluator.zig: its own, plus every dependency's
pub const Error = error{ UnknownFunction, UnknownVariable, DomainError, Overflow } ||
    parser.Error || number.Error || bitwise.Error || financial.Error;

// engine.zig: what any entry point can return, for frontends to switch over
pub const Error = evaluator.Error || programmer.Error || units.Error || financial.Error;

This replaced one hand-written CalcError with 20 members that every engine function claimed to return. That signature was false in both directions: parser.parse said it might return ConvergenceFailure and UnknownUnit, so no caller could switch on what a parse can actually produce, and four members (InvalidType, InvalidFieldName, DuplicateFieldName, StructTooLarge) belonged to a struct layout module that does not exist, so nothing could return them while errorPhrase still gave them wording.

Error members unify by name in Zig, so the per-module sets compose with no coordination: parser.Error.OutOfMemory and units.Error.OutOfMemory are the same value. The unions are written with || rather than enumerated, so the compiler maintains them.

Deleting the single set also deleted the translation between tiers. number.toCalcError existed to map the numeric model's errors into the engine-wide set, and what it mapped away were the more useful names: ExponentTooLarge became Overflow and NegativeRoot became DomainError. Now sqrt(-1) reports "square root of a negative number" and 2^3000000 reports "the exponent is too large to compute".

Errors carry no position or context. An ErrorInfo with a source position was specified here and declared in the code, but nothing ever constructed one, and the parser fields that would have fed it were written on every error path and never read. Adding position reporting means threading it through the returns, which is worth doing deliberately rather than leaving a half-built shape in place.

The wording lives in exactly one place, engine.phrase, in the engine rather than a frontend because every frontend needs the same words, including the Android app across the C ABI. Its switch has no else, so an error added to any module's set fails to compile until it is given a phrase, and a comptime block checks the other direction, so it cannot carry wording for an error the engine is incapable of producing. Frontends decorate at comptime (switch (err) { inline else => ... }): the CLI adds error: and a newline, the TUI adds error: , and a view with better context can override individual cases, as the financial form does for DomainError. Each frontend used to carry its own copy of the whole table, and the TUI's had drifted three errors behind.


11. Key Design Decisions

# Decision Rationale
1 Pratt parser over recursive descent Easier to extend precedence levels; cleaner for infix with mixed prefix operators
2 u64 storage for all programmer integers Simplest representation; bit_width used for masking/interpretation, not storage
3 JSON over C ABI boundary Avoids complex struct marshaling; Kotlin/Swift handle JSON natively; small perf cost acceptable for calculator
4 Separate parser for struct DSL Struct grammar is different enough from expressions; keeps both parsers simple
5 ABI as interface/vtable Adding Windows x64 or ARM is implementing one struct; no changes to layout algorithm
6 libvaxis for TUI Most mature Zig TUI; supports Windows/Mac/Linux; active development; used by Ghostty
7 ^ always means power; XOR is the xor keyword Avoids mode-dependent operator overloading; every operator means the same thing in both modes; consistent with existing rol/ror keyword operators
8 Statically-linked desktop binaries Zero dependencies for end user; Zig makes this trivial
9 Unit conversion via base-unit normalization Simple to implement, easy to extend; only temperature needs special case (offset)
10 Android: button grids, not text prompts Touch-first UX; no keyboard needed for basic use; purpose-built surfaces per mode beat a generic expression prompt
11 Android: form-based struct builder + raw toggle Most users won't want to type DSL syntax on a phone; visual builder is more natural; power users can toggle to raw text
12 Android: live conversion without "Calculate" button Conversions are instant; eliminating a tap makes the UX feel responsive and direct