Source: context/design-principles.md

On this page

Improvise Design Principles

#1. Functional-First Architecture

#Commands Are Pure, Effects Are Side-Effectful

Every user action flows through a two-phase pipeline:

  1. Command (Cmd trait) — reads immutable context, returns a list of effects. The CmdContext is a read-only snapshot: model, layout, mode, cursor position. Commands never touch &mut App. All decision logic is pure.
  2. Effect (Effect trait) — a small struct with an apply(&self, app: &mut App) method. Each effect is one discrete, debuggable state change. The app applies them in order.

This separation means:

#Prefer Transformations to Mutation

Where possible, build new values rather than mutating in place:

#Compose Small Pieces

Commands compose via Binding::Sequence — a keymap entry can chain multiple commands, each contributing effects independently. The o key (add row + begin editing) is two commands composed at the binding level, not a monolithic handler.

#Decompose rather than early return

Early Returns usually are a signal of mixed responsibilities: if an early return would clarify a function, consider how the function could be decomposed for the same effect without the early return.


#2. Polymorphism Over Conditionals

#Dispatch Through Traits and Registries, Not Match Blocks

#Use Enums to Make Invalid States Unrepresentable

#When You Add a Variant, the Compiler Finds Every Call Site

Prefer exhaustive match over if let or _ => wildcards. When a new Axis variant or AppMode is added, non-exhaustive matches produce compile errors that guide you to every place that needs updating.


#3. Correctness by Construction

#Canonical Forms Prevent Equivalence Bugs

CellKey::new() sorts coordinates by category name. Two keys that name the same intersection but in different order are identical after construction. Equality, hashing, and storage all work correctly without callers needing to remember to sort. Property tests verify this invariant.

#Smart Constructors Enforce Invariants

#Type-Safe Identifiers

CategoryId and ItemId are typed aliases. While they are usize underneath, using named types signals intent and prevents accidentally passing an item count where an item ID is expected.

#Symbol Interning for Data Integrity

DataStore interns category and item names into Symbol values (small copyable handles). This means:

#Parse-Time Validation

Formulas are parsed into a typed AST (Expr enum) at entry time. If the syntax is invalid, the user gets an error immediately. The evaluator only sees well-formed trees — it does not need to handle malformed input.

#Grammar-Defined File Format

The .improv file format is defined by a PEG grammar (persistence/improv.pest) and parsed by pest. The grammar is the single source of truth — the parser is a tree-walker over the grammar's parse tree, not an ad-hoc line scanner. This means:

#CL-Style Pipe Quoting for Names

Names in the .improv format use CL-style |...| pipe quoting. A name is bare if it matches [A-Za-z_][A-Za-z0-9_-]*; everything else must be pipe-quoted. Escapes inside pipes: \| (literal pipe), \\ (backslash), \n (newline). This convention is shared between the .improv persistence format and the formula parser's identifier syntax.

#Formula Identifiers and Quoting

Bare identifiers are (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_" | "-")* — no spaces, the same shape improv.pest's bare_name has. A multi-word name such as Total Revenue must be pipe-quoted.

Keywords need a word boundary after them, not before. IS, WHERE and the aggregate names are spelled ^"…" in formula.pest, and is_expr/where_clause/ inline_where are compound-atomic with sp+ so that Region ISLAND and SUM(Rev WHEREAS x = y) are parse errors rather than silently-wrong filters. A SUM/IF without parens falls through to ref_expr, which is why a bare keyword still names an item; the writer quotes it anyway (quote::quote_name).

Pipe-quoted identifiers (|...|) allow any characters — including spaces, keywords, and operators — inside the delimiters. Use pipes when a category or item name collides with a keyword or contains special characters:

|WHERE|             — category named "WHERE"
|Revenue (USD)|     — name with parens
|Cost + Tax|        — name with operator chars
SUM(|Net Revenue| WHERE |Region Name| = |East Coast|)

identifier is pipe_quoted | bare_ident, so a pipe-quoted name works everywhere a bare one does: expressions, aggregate arguments, and a WHERE clause's category name. A double-quoted string is a filter_value only — the right-hand side of a WHERE or an IS, never an expression.


#4. Separation of Concerns

#Four Layers

LayerDirectoryResponsibility
Modelsrc/model/Categories, items, groups, cell data, formulas. Pure data, no rendering.
Viewsrc/view/Axis assignments, page selection, hidden items, layout computation. Derived from model.
Command / Effectsrc/command/, src/ui/effect.rsIntent (commands) and state mutation (effects). Bridges user input to model changes.
Renderingsrc/draw.rs, src/ui/Terminal drawing. Reads model + view, writes pixels. No mutation.

#Formulas Are Data, Not Code

A formula is a serializable struct: raw text, target name, category, AST, optional filter. It is stored in the model alongside regular data. The evaluator walks the AST at read time. Formulas never become closures or runtime-generated code.

#Formula Evaluation Is Fixed-Point

recompute_formulas(none_cats) iterates formula evaluation until values stabilize. Each pass evaluates all formula cells using the current cache (for formula-derived values) and raw data aggregation (for data values). This avoids recursive evaluation through evaluate_aggregated and naturally handles chained formulas (Margin = Profit / Revenue where Profit = Revenue - Cost). Circular references converge to CellValue::Error("circular") after MAX_EVAL_DEPTH iterations.

#Display Rounding Is View-Only

Number formatting (format_f64) rounds for display. Formula evaluation always operates on full f64 precision. The rounding function is only called in rendering paths — never in eval_formula or aggregation.

#Drill State Isolates Edits

When editing aggregated (drill-down) cells, a DrillState snapshot freezes the current cell set. Pending edits accumulate in a staging map. On commit, ApplyAndClearDrill writes them all atomically. On cancel, the snapshot is discarded. No partial writes reach the model.


#5. A Front End Displays State; It Does Not Reimplement Behaviour

The goal is to minimise the amount of new code a new UI framework needs. Logic is centralised in a redux-like store, so a second or third front end is mostly a function from state to pixels plus a wire for events — not a second implementation of how the application behaves.

The pieces already exist and are worth naming as what they are:

reduxhere
actionCommand / ModelCommand (improvise-protocol)
reducerreduce_view, and WidgetCache::apply per widget
storeViewState + the per-widget caches
dispatchSessions::apply_checked — the one door (gotcha 36)

So the question to ask of any code in a front end is: would a third front end have to write this again? If yes, it is behaviour and it belongs in the shared layer. Three things make this concrete rather than aspirational:

The measure is lines of framework-specific code, and it should trend to "paint and wire". web/app.js is the current benchmark: a few hundred lines that know no protocol, reaching the application through a handful of string calls. A front end that needs to understand CellKey coordinates, or which category a row label belongs to, or what a keypress means, is one that has been handed a job the store should have done.

A derivation done twice is the defect, even when both copies are correct today. Two front ends deriving the same fact will diverge, and the divergence is invisible until someone compares them side by side. improvise-edp is the worked example — the category tree was flattened twice, two different ways, and the fix put viewmodel::flatten_cat_tree where both ends call it. Gotcha 44 was the same defect and is now closed the same way (improvise-anr9): the grid's frame — header levels, label tuples, group runs and their collapse state — is improvise_protocol::AxisFrame, and both src/ui/grid.rs and improvise-wasm-client/src/render.rs ask it rather than deriving one. Note what the second cure cost that the first did not: a Vec<String> of labels had to change type, and a shared derivation could not be added beside the old one — row_levels next to row_labels would have been two answers to "what are the labels", which is the disease and not the cure.

Sharing the table beats testing two copies of it. Where a front end must turn input into actions, share the mapping rather than mirroring it: two hand-kept copies held in step by a test are one definition waiting to be made (doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)). That is why the keymap is shared rather than reimplemented per front end, and why doctrine(mirror_the_cmd_line_for_line) is a rule about resolvers — the narrow, unavoidable residue — and never a licence to restate a table.

What legitimately stays per-framework. Painting, event wiring, and the mapping from a semantic value to a framework type: AxisColor → ratatui::Color in draw.rs::axis_fg, MinibufColor → minibuf_fg. That mapping is the shape to imitate — the decision is in the shared layer as a semantic enum, and only the last translation is local. Anything larger than a lookup table is a candidate for the store.


#6. Guidelines for New Code


#7. Testing

#Coverage and ambition

Aim for ~80% line and branch coverage on logic code. This is a quality floor — go higher where the code is tricky or load-bearing, but don't pad coverage by testing trivial getters or chasing 100% on rendering widgets. The test suite should remain fast, and the rule is two numbers because the ambiguity between them is what let the old single "under 2 seconds" drift: no single test binary may exceed 2 seconds, and the workspace wall clock is the sum of them. Excludes compilation, and excludes harness/, which is Playwright and takes minutes in its own CI job. Slow tests erode the habit of running them.

The threshold is a tripwire, and the seconds are not portable. The same commit measured improvise-io's lib binary at 1.4s on one machine and 3.7–5.2s on another (improvise-8ec3), so a PR body citing "longest single binary 1.47s" is checkable and false for the reader who checks it, which is worse than no claim at all. Three consequences:

improvise-8shu is closed, and its diagnosis was wrong — which is the more useful half. The ticket blamed an unbounded grammar walk and asked for a constrained strategy. Instrumenting the two halves separately: load_grammar() ×1000 took 2.50s against a 15.7ms walk, so the walk was 0.6% of the cost and improv_file() was re-parsing improv.pest once per generated case. Caching it in a OnceLock took improvise-io's lib from 1.42s to 0.21s with the strategy's input space unchanged; constraining it would have moved 15.7ms and shipped as "the fix". Measured at f152d10 on the orchestrator's machine: 1445 tests over 34 binaries, longest single binary 0.6–1.0s across two runs — the spread on one machine in one sitting being its own argument for reading your own run. improvise-io is no longer the outlier. Lowering the case count below 256 is still not on the table (see below).

#Demonstrate bugs before fixing them

Write a test that fails on the current code before writing the fix. Prefer a small unit test targeting the broken function over an end-to-end test. After the fix, the test stays as a regression guard.

Record the bug in context/test-evidence/, keyed by test name — what broke, what fault was injected, and why the test can fail. It does not go in the test's doc-comment: comment runs are capped at two lines (see below), and the evidence is longer than that in every case worth having. The cost of the split is that a renamed test orphans its entry. scripts/check-comment-runs.py --check-evidence catches that, in two directions. Every ###/#### heading shaped like a test name must name a test that runs — the names are diffed against cargo test --workspace -- --list minus its --ignored twin, never scraped out of the source, because text that looks like a test and a test are indistinguishable to a scanner (improvise-rm4s). And a test whose doc comment cites context/test-evidence/ must have an entry there, or the pointer reads as a promise the explanation exists.

Neither direction is "every test has evidence", deliberately: most tests have none and should not be forced to have one. The reasoning and the residual holes are in context/code/tooling.md.

#A check that looks like the answer, answering a neighbouring question

The characteristic defect in this repo is not a wrong line; it is a check that is adjacent to the thing it names. Three instances landed in one campaign (PRs #59/#60), in three different layers, which is why it is worth stating as a shape rather than three gotchas:

Each has the same remedy shape: name the question you actually mean, then find an input that separates it from its neighbour.

This generalises doctrine(fold_tests_must_test_order_not_the_axiom) — asserting fold(prefix)+fold(rest) == fold(whole) is the definition of a left fold, so it answers a neighbouring question too. What the campaign adds is that the pattern recurs in test repairs and in merge hygiene, not only in production code and not only in fold tests.

#E2E pins the boundaries; unit tests work between them

E2E and integration tests are rare and pin boundary contracts. Unit tests assume those contracts and test between the boundaries (user, 2026-09-20).

So the question to ask of a slow test is never "what feature does this cover" — it is "which boundary is this, and what is the contract at it?" The answer changes what you write. Four browser widgets had never worked — the tile bar, the view panel, the formula panel, and the category sidebar ignoring category_panel_open — and all four were one boundary failure: a RenderModel field with no element painting it. Four per-widget Playwright specs cost four slow tests and would still have missed the fifth; a single contract assertion that every field the render model exposes is painted catches all four and the next one. harness/tests/opfs.spec.js is the cleanest instance of the right shape already in the tree: it runs the storage contract, not a feature.

Three corollaries, in the order they bite:

#Use property tests judiciously

Property tests (proptest) are for invariants that must hold across all inputs — not a replacement for example-based tests. Good candidates:

Keep case counts at the default (256). Don't crank them to thousands — if a property needs more cases to feel safe, constrain the input space with better strategies rather than brute-forcing. Property tests that take hundreds of milliseconds each are a sign something is wrong.

#What to test

#What not to test

#Test the property, not the implementation

A test like "calling set_axis(cat, Row) sets the internal map entry to Row" is brittle — it mirrors the implementation and breaks if the storage changes. Instead test the observable contract: "after set_axis(cat, Row), axis_of(cat) returns Row and categories_on(Row) includes cat." This style survives refactoring and catches real bugs.