Files
improvise/context/repo-map.md
T
Edward Langley 0b261165d1 docs(context): the dirty flag is per-effect; only the record tier uses the batch delta
Also record the improvise-command to improvise-gp and improvise-gp to improvise-core edges.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-10-10 15:49:04 -07:00

293 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Repository Map (LLM Reference)
Terminal pivot-table app. Rust 2024, Ratatui TUI, command/effect architecture.
Apache-2.0. Root binary+lib `improvise`; sub-crates under `crates/`:
- `improvise-core` — `Model`, `View`, `Workbook`, number formatting. No UI/IO.
- `improvise-formula` — formula parser, AST, `parse_formula`.
- `improvise-io` — `.improv` save/load, CSV/JSON import, `Storage` backends. No UI/commands.
- `improvise-protocol` — the wire vocabulary and the portable session slice: `Command`, `Projection`, `ViewState`, the per-widget caches, `reduce_view`, key types, and `delivery/{wire,notice,exchange,client}.rs` (notices, watermarks, corrections). Depends on `improvise-core`, `zset`, `numtower` + serde. No IO, no command layer, no terminal.
- `improvise-command` — command/effect layer + semantic `App` state (app.rs, effect.rs, cat_tree.rs, viewport.rs, delivery.rs). Depends on `improvise-protocol`, `improvise-gp`, `zset`, `improvise-incremental`. No ratatui/crossterm.
- `numtower` — exact decimal arithmetic tower (`Num`, canonical text), ADR 005. Pure Rust, no workspace deps; `improvise-protocol` uses it for the wire form. Long form `context/code/numtower.md`.
- `zset` — std-only Z-sets (non-zero checked `i64` weights, panic on overflow) plus `Circuit`, operators, `Trace`, `Rebuild` (ADR 009 clause 1). No dependencies. `context/code/zset.md`.
- `zset-ffi` — C ABI over `zset` (`cdylib`/`staticlib`/`rlib`) for CFFI; `include/zset_ffi.h`. Native only, **not** in `wasmCrates`. `context/code/zset-ffi.md`.
- `improvise-incremental` — the `Derived` trait, `Reading`, `Eager`, `differential_in`/`drawn_probes`, and `cell_delta`/`DeltaValue`/`moved_records` (the store's `CellChange` log to a `ZSet`). Depends on `improvise-core` + `zset`; `core` never depends on `zset`. `context/code/improvise-incremental.md`.
- `gp-tree` — problem-agnostic genetic-programming regression trees (`Tree`, `Spec`, `breed`, `simplify`, `render`). Knows nothing about improvise.
- `improvise-gp` — the one seam between the two: emits a fitted `gp_tree::Tree` as improvise formula text (`formula_text` / `fitted_formula`). Depends on `gp-tree`, `improvise-formula` **and `improvise-core`** — `dataset.rs` builds a feature matrix from a `View`'s records (improvise-4s8) and `fitted_formula` takes the `&Model` it must resolve against (improvise-8xhy). Nothing depends on it yet.
- `improvise-ws-server` — the host: tokio + tungstenite, a `Registry` of `Host`s keyed by `DocumentId` (multi-document, ADR 001), `Sessions` fan-out, and the socket framing (`wire.rs`). Native only.
- `improvise-worker-server` — the *other* host: the same `App`, `Sessions` and `Host` as the socket server, behind `self.onmessage`/`self.postMessage` with OPFS underneath. wasm32 only in anger; `worker.rs`/`vfs.rs` are ordinary Rust and are where every decision and every test is. Nothing depends on it.
- `improvise-wasm-client` — the browser client: the fold, the key classifier, the socket framing it mirrors (`socket.rs`), and the render model the DOM paints (`render.rs`). Non-dev deps are `improvise-protocol` + serde + wasm-bindgen and nothing else. The page itself is `web/`.
The main crate (and `improvise-command`) re-export the sub-crates as `crate::{model, view, workbook, format, formula, persistence, import}` so consumer paths stay stable when crates shuffle. The main crate also re-exports `improvise_command::command` as `crate::command` and `improvise_command::ui::{app, cat_tree, effect, viewport}` under `crate::ui`. `ui::app` re-exports the protocol's `AppMode`, `ViewState`, `ViewCursor`, `ViewFrame`, `Panel`, `MinibufferConfig`, `MinibufColor` and `HELP_PAGE_COUNT`, and `command::key` re-exports the protocol's key types, so every pre-existing path still resolves.
Architectural intent lives in `context/design-principles.md` — read that for the "why". This doc is the "where".
---
## How to Find Things
| I need to... | Look in |
|-------------------------------------|-------------------------------------------------|
| Add a keybinding | `improvise-protocol/src/keymap.rs` → `Keymaps::default_keymaps()` (the table; the browser looks up the same bindings). Resolving a bound *name* into effects is `improvise-command/src/command/keymap.rs`. |
| Add a user command | `command/cmd/<submodule>.rs`, register in `registry.rs` |
| Add a state mutation | `ui/effect.rs` → implement `Effect` |
| Change formula eval | `model/types.rs` → `eval_formula` / `eval_expr` |
| Change cell storage / lookup | `model/cell.rs` → `DataStore` |
| Change category/item behavior | `model/category.rs` → `Category` |
| Change view axis logic | `view/types.rs` → `View` |
| Change an axis label or cycle | `view/axis.rs` → `impl Axis`. An axis's **colour** is `Face::Axis(axis)` in `improvise-protocol/src/face.rs`, resolved by the `Theme` and turned into a style by `draw.rs::face_style` (ADR 004) |
| Ask "what data is visible" vs "what can the user page" | `View::filtering_cats()` vs `categories_on(Axis::Page)` |
| Change grid layout | `view/layout.rs` → `GridLayout` |
| Change `.improv` format | `persistence/improv.pest` + `persistence/{format,parse}.rs` |
| Change where documents are stored | `storage.rs` → `Storage` impls (`opfs.rs` for wasm) |
| Change number display | `format.rs` → `format_f64` |
| Change CLI args | `main.rs` (clap) |
| Change import logic | `import/wizard.rs` → `ImportPipeline` |
| Change how a gp-tree fit searches | `gp-tree/src/fit.rs` → `FitConfig` / `fit` |
| Change gp-tree CSV preprocessing | `gp-tree/src/data.rs` → `preprocess` |
| Change how a name is quoted anywhere | `improvise-formula/src/quote.rs` — the one home, both halves |
| Change import-wizard keys/steps | `command/cmd/wizard.rs` (keys) + `import/wizard.rs` (steps) |
| Change frame layout | `draw.rs` → `draw()` |
| Change an incremental operator or the circuit | `crates/zset/src/{ops,circuit}.rs` |
| Change the C ABI | `crates/zset-ffi/src/lib.rs` + `crates/zset-ffi/include/zset_ffi.h` |
| Change the wire form of a value or delta | `improvise-protocol/src/delivery/wire.rs` |
| Change what the host publishes/answers | `improvise-command/src/ui/delivery.rs` |
| Change what counts as a document or record write | `improvise-command/src/ui/app.rs::apply_effects` (reads the batch cell delta) |
| Ask "does this grid draw column totals" | `GridLayout::draws_totals` (`view/layout.rs`) — the one condition |
| Change how wide the TUI grid is beside a side panel | `improvise-command/src/ui/viewport.rs` → `grid_width` (reached through `App::grid_width`); `draw.rs::draw_content` splits on it and `App::cmd_context` counts `visible_cols` on it (improvise-gtn7.1) |
| Change what the browser page draws | `improvise-wasm-client/src/render.rs` → `RenderModel` |
| Change a browser↔server frame | `improvise-protocol/src/frame.rs` → `ClientFrame`/`ServerFrame`. The **one** pair; both ends `pub use` it and neither defines one (improvise-xks6) |
| Change the worker's message loop | `improvise-worker-server/src/worker.rs` → `Worker::on_frame` |
| Change the session loop (either host) | `improvise-command/src/ui/host.rs` → `Host::handle` |
| Change how the browser opens or seeds a document | `improvise-worker-server/src/vfs.rs` → `open` / `documents` |
| Change what a browser resolves without the host | `improvise-protocol/src/resolve.rs` → `resolve`. **Not** `wasm-client/src/keys.rs`, which holds only `BrowserKey` (`KeyboardEvent` → improvise key) — this row named it until 2026-09-21 and that pointer is how a false claim reached `context/` (improvise-e5pf) |
| Change the browser page itself | `web/app.js` (thin), `web/index.html`, `web/style.css` |
| Host another document | `improvise-ws-server/src/registry.rs` → `Registry`, `ClientId` |
| Change app state / modes | `ui/app.rs` → `App`, `AppMode` |
| Write a test for model logic | `model/types.rs` → `mod tests` / `formula_tests` |
| Write a test for a command | `command/cmd/<module>.rs` → colocated `mod tests`; helpers in `cmd/mod.rs::test_helpers` |
---
## Central Pattern: Cmd → Effect
```
keypress → Keymap lookup → Cmd::execute(&CmdContext) → Vec<Box<dyn Effect>> → Effect::apply(&mut App)
(pure, read-only ctx) (list of mutations) (only mutation site)
```
```rust
pub trait Cmd: Debug + Send + Sync {
fn name(&self) -> &'static str;
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>>;
}
pub trait Effect: Debug {
fn apply(&self, app: &mut App);
fn kind(&self) -> EffectKind; // NO default — the compiler makes you classify
}
// EffectKind::{ViewOnly, Model(ModelChange)}
// ModelChange::{CellData, LayoutGeometry, WholeWorkbook}
```
`CmdContext` (see `command/cmd/core.rs`) holds immutable refs to `Model`, `Workbook`, `View`, `GridLayout`, the `CmdRegistry`, `AppMode`, cursor/offsets, buffers, yanked cell, key code, expanded cats, panel/tile cursors, the opened editor, the view back/forward stacks, `visible_rows`/`visible_cols` and the cursor cell's `display_value`.
**Add a command**: implement `Cmd` in a `command/cmd/` submodule, register in `registry.rs`, bind in `default_keymaps()`. Simple wrappers go through the `effect_cmd!` macro in `effect_cmds.rs`.
**Add an effect**: implement `Effect` in `ui/effect.rs`, add a constructor fn if it helps composition.
---
## Key Types (skim; read the source for fields/methods)
**Model** (`model/types.rs`): `categories: IndexMap<String, Category>`, `data: DataStore`, `formulas: Vec<Formula>`, `views: IndexMap<String, View>`, `active_view`, `measure_agg`. `MAX_CATEGORIES = 24` (regular only) — `pub`, re-exported as `improvise_core::model::MAX_CATEGORIES`, and the one home: the import wizard reads it rather than keeping a copy (improvise-felo.1). Virtual categories `_Index`, `_Dim`, `_Measure` always exist. Use `regular_category_names()` for user-facing pickers.
**CellKey** (`model/cell.rs`): `Vec<(cat, item)>` — **always sorted by category**. Build with `CellKey::new(coords)` / `with(cat, item)` / `without(cat)`. Never construct the inner `Vec` directly.
**CellValue**: `Number(f64) | Text(String) | Error(String)`. Errors surface from formula eval (circular, div/0, etc.).
**DataStore** (`model/cell.rs`): interned symbols + secondary index `(Symbol, Symbol) → {InternedKey}`. Hot-path lookups go through `matching_values` / `matching_cells`.
**Formula AST** (`improvise-formula/src/ast.rs`):
- `Expr { Number, Ref, BinOp, UnaryMinus, Agg, If }`
- `BinOp { Add, Sub, Mul, Div, Pow, Eq, Ne, Lt, Gt, Le, Ge }`
- `AggFunc { Sum, Avg, Min, Max, Count }`
- `Formula { raw, target, target_category, expr, filter }`
**View** (`view/types.rs`): `category_axes: IndexMap<cat, Axis>`, page selections, hidden items, collapsed groups, `number_format`, `prune_empty`. It carries **no cursor** — `selected`/`row_offset`/`col_offset` moved to `ViewState::cursors` (improvise-8uz). `Axis { Row, Column, Page, Filter, None }`. `cycle_axis` rotates Row → Column → Page → None; `Filter` is outside the rotation (see gotcha 27).
**GridLayout** (`view/layout.rs`): pure function of `Model + View`. `cell_key(r,c)`, `cell_value(r,c)`, `drill_records(r,c)`. **Records mode** auto-detects when `_Index` is on Row and `_Dim` is on Column.
**AppMode** (`ui/app.rs`): 17 variants (Normal, Editing, FormulaEdit, FormulaPanel, CategoryPanel, ViewPanel, TileSelect, CategoryAdd, ItemAdd, ExportPrompt, SavePrompt, CommandMode, ImportWizard, Help, Quit, RecordsNormal, RecordsEditing). Eight text-entry variants carry a `MinibufferConfig`. `SearchMode` is Normal + `search_mode: bool`, not its own variant. Per-variant UI facts live on the enum as exhaustive-match methods — `label()`, `hint_text()`, `panel()`, `minibuffer()`, `is_editing()`, `is_records()` (improvise-2hi); add a variant and the compiler names every one you must fill in. A mode's colour is a `Face` (`Face::Mode*`, `Face::of_minibuffer`), turned into a ratatui `Style` only by `src/draw.rs::face_style`. `App` state is split into `ModelState` (workbook, file_path, dirty) and `ViewState` (mode, cursor/offsets, buffers, panels, search) — see improvise-x2c/ew0. `wizard` and `drill_state` live on `App`, not `ViewState` (improvise-h0b) — see gotcha 24.
**Keymap** (`improvise-protocol/src/keymap.rs`; `dispatch` and `KeymapSet` in `improvise-command/src/command/keymap.rs`): `Binding { Cmd | Prefix(Arc<Keymap>) | Sequence(Vec<…>) }`. Lookup fallback: `exact(key,mods) → Char(NONE) → AnyChar → Any → parent`. 17 mode keymaps, one per `ModeKey`, built by `Keymaps::default_keymaps()`; mode resolved via `ModeKey::from_app_mode()`. `ModeKey` and `AppMode` are two different enumerations that happen to be the same size — `SearchMode` is a `ModeKey` and not an `AppMode`; `Quit` is an `AppMode` with no keymap — so do not "fix" one count to match the other.
---
## `.improv` File Format
Plain-text, markdown-like, defined by `persistence/improv.pest`. Parsed by pest — the grammar is the single source of truth (the grammar-walking test generator reads it via `pest_meta`).
```
v2025-04-09
# Model Name
Initial View: Default
## View: Default
Region: row
Measure: column
|Time Period|: page, Q1
hidden: Region/Internal
collapsed: |Time Period|/|2024|
format: ,.2f
## Formulas
- Profit = Revenue - Cost # defaults to [_Measure]
- Tax = Revenue * 0.1 [CustomCat]
## Category: Region
- North, South, East, West # bare items, comma-separated
- Coastal_East[Coastal] # grouped item, one per line
> Coastal # group definition
## Data
Region=East, Measure=Revenue = 1200
Region=West, Measure=Revenue = |pending| # pipe-quoted text
```
- **Name quoting**: bare `[A-Za-z_][A-Za-z0-9_-]*`, else CL-style `|…|` with escapes `\|`, `\\`, `\n`. Same convention in the formula tokenizer.
- **Write order**: Views → Formulas → Categories → Data. Parser tolerates any order.
- **Gzip**: `.improv.gz` (same text, gzipped).
- **Legacy JSON**: auto-detected by leading `{`; never written.
- **Axis keywords**: `row`, `column`, `page`, `filter`, `none`; `page`/`filter` carry the selected item after a comma.
- **Virtual categories**: `_Index`/`_Dim` never persist; `_Measure` persists only non-formula items (formula targets are rebuilt from the `## Formulas` section).
- **Virtual views**: `_Records`/`_Drill` (`view::VIRTUAL_VIEW_NAMES`) are never written and are dropped on load if an older file carries them; saving from records/drill mode writes the first real view as `Initial View`. Their commands rebuild them on demand.
---
## Gotchas (digest — read before editing)
One or two lines each: the rule, and the trap. **The numbers are stable and cited across the repo; a
citation of `gotcha N` means entry N in `context/gotchas.md`, which holds the full argument,
the incidents and the ticket ids.** Read the long form of every gotcha your diff touches. Counts and
"closed/open" claims in the long form are dated snapshots.
1. **Records mode is wide format.** A row is a coordinate tuple; columns are `[coords…, +cat, raw measures…, +]`. Editing a category column moves every measure cell in the row. `_Dim` carries a column *index*, resolved by `GridLayout::record_col`. `GridLayout::new` does **not** prune; `with_frozen_records(m, v, None)` does — production code wants the latter. `prune_axis_items` takes a predicate and re-marks survivors.
2. **Effects never touch `std::fs`.** Documents go through `App::storage: Box<dyn Storage>`. Autosave and the headless final write still bypass it (improvise-31oe.11).
3. **Every effect declares an `EffectKind`; there is no default.** `ViewOnly` never reaches other clients; `Model(CellData | LayoutGeometry | WholeWorkbook)` does. The view lives inside the workbook, so axis/page/format are `LayoutGeometry` and cursor/scroll/`SwitchView` are `ViewOnly`; `Add/RemoveCategory` are `LayoutGeometry`. `EffectKind` is too coarse to check the `ModelCommand` mapping — `every_model_command_names_the_effect_of_the_same_name` does.
4. **Commands never mutate.** `&CmdContext` is read-only; return effects.
5. **`CellKey` is always sorted.** Use the constructors.
6. **No `Model::add_item`.** Use the `Workbook` facade (`wb.add_item`) or go through the category.
7. **Virtual categories.** `_Measure` items = explicit ∪ formula targets (`measure_item_names()`). Cell aggregation (`apply_agg`) and formula `SUM/AVG` (`eval_agg`) differ on empty input; non-numeric values reduce by unanimity, else `Error("mixed")` — never dropped. `evaluate_aggregated` takes `Aggregation::{Exact, OverHidden}`.
8. **Display rounding is view-only.** `format_f64` only in rendering; never feed a rounded value back into eval.
9. **Formula eval is fixed-point.** `recompute_formulas` iterates to stability; cycles become `Error("circular")`.
10. **Drilling a formula cell** strips the `_Measure` coordinate so the raw feeding records come back.
11. **Minibuffer keymaps come from `TextEntry`** (eight text-entry modes); the buffer a prompt names and the keys edit are one string by construction. Read/write a runtime-named buffer with `read_buffer`/`write_buffer`. `SearchMode` is hand-written.
12. **Panel keymaps come from `PanelKeymap`**; `Panel::ALL`/`name()`/`key()` drive the toggle keys.
13. **A prefix key is stored as an address** (`ViewState::transient_prefix`), resolved by `Keymaps::prefix_keymap`; prefixes must stay flat (`prefix_keymaps_are_flat`). `ViewState` stays free of `improvise-command` types.
14. **Keybindings are per-mode** (`ModeKey::from_app_mode`); minibuffer Enter/Esc are `Binding::Sequence`. `every_hint_key_resolves_in_its_mode` checks which keys a hint names, not what they do.
15. **Registry names come from prototypes, not strings** — except the `Move` literals and four aliases, guarded by tests; a wrong-variant prototype shows up as a *duplicate* registration.
16. **`effect_cmd!` / `simple_effect!`** wrap one effect; `simple_effect!`'s kind argument is mandatory. Six wrappers return `unless_unknown(...)` (gotcha 51). Mode entry is `enter-mode <name>`; no per-mode shim commands.
17. **One float epsilon** (`FLOAT_EQ_EPSILON = 1e-10`) for `=`/`!=` *and* the div/0 guard, in both evaluators.
18. **Order is data.** `IndexMap` order is persisted; an index of `HashSet`s is for lookup, never iteration (`DataStore::matching_*` sort to store order; `analyze_records` is first-seen order). A group's fold reads through the same rule (`spanned_positions`).
19. **Cell-edit commit paths split** (`commit_regular_cell_value` / `commit_plain_records_edit`); keep both in sync. `cmd::core::uneditable` is the one decline, asked at open *and* commit. `CellId::NonItem` is a stored value naming no item.
20. **Minibuffer commits go through `commit-buffer <formula|category|item>`**; the add effect is emitted last so its failure message wins.
21. **An abort stops the chain, never the bookkeeping.** `Save`/`SaveAs`/`AddCategory` set `abort_effects`; tidy-up (mode restore, buffer clear) goes at the *head* of a batch. An abort is not a rollback (gotcha 47).
22. **The import wizard's back path is a history stack.** Preconditions live in `validation_error()`; `ResolveDuplicates` appears only when `collisions()` is non-empty; headless defaults to `DuplicatePolicy::Refuse`. A collision is a cell written by two records.
23. **`app.layout` goes stale mid-batch** — put a `RebuildLayout` effect before any effect that reads the layout after a grid change. The rebuild is skipped when nothing it reads moved (felo.13); a writer bypassing effects must call `rebuild_layout()`.
24. **The cursor is per-client, keyed by view** (`ViewState::cursors`; use `App::cursor()/cursor_mut()`). Replacing the document clears the map and bumps `document_generation`. Cursor invalidation is *observed* from the grid frame (`cursor_epoch`), not called by effects; two arms (`LayoutGeometry` hashes the active view, `CellData` every view).
25. **`ViewState` and its field types derive `Serialize`/`Deserialize`** — no borrowed-`'static` fields anywhere it reaches.
26. **Axis presentation lives on `Axis`; colour is a `Face`** resolved by the `Theme`; `draw.rs::face_style` is the one translation to ratatui.
27. **`Axis::Filter` constrains without being navigable.** Visible data = `filtering_cats()`; user-actionable = `categories_on(Page)` / `tileable_cats()`. What a virtual view must be is model-side (`records_steps`, `drill_steps`).
28. **The browser backend is sync on purpose, with an async seam** (`OpfsFs`; `open/attach/refresh/commit_deletes`). It needs a *dedicated* worker — `createSyncAccessHandle` is absent in a `SharedWorker`.
29. **"Active view" is stored twice and may differ:** `Workbook::active_view` is the default, `ViewState::active_view` is authoritative per client. `App::resolve_view` is the one fallback; `Sessions::adopt_deleted_views` handles a deleted view.
30. **Two tests gate `ViewState` membership:** portable (no `Rc`/`Arc`) and snapshot-safe. `wizard` and `drill_state` live on `App` as session-local residue.
31. **`:` commands settle their mode by observation** (`ReturnToNormalUnlessMoved`), not by a per-effect constant.
32. **There is no aggregate render cache.** One cache per widget (`WidgetCache`), a `Projection` is one update to one widget; bootstrap is a *compaction* (`Snapshot { view, seed }`). The wizard has a cache but deliberately no projection.
33. **The grid's labels frame its cells; the fold is not commutative.** A `Cells` update that overtakes its frame is dropped; `apply` invalidates relabelled positions itself; every arm touching labels or widths re-fits. Don't coalesce a batch.
34. **A formula target is unquoted; pipe quoting has one home** (`improvise_formula::quote`). The keyword guard is canonical. `header_name` is total; duplicate `## Category:` sections are one category; `WHERE` needs `sp+`.
35. **`Dirty` over-reports on purpose** — a redundant projection is cheap, a missed one leaves a client wrong forever. `LayoutGeometry` includes `formulas`.
36. **A client's command has one door:** `Sessions::apply_checked(app, id, Checked)`. Assert the `Broadcast` itself, and seat a bystander where a wrong relay can move it.
37. **`gp-tree` returns data and never prints; a fit needs seed *and* `FitConfig`.** A scorer takes `Row<T>` slices. `fitted_formula` refuses features the document cannot name; categories are forced `Categorical` (zv8t).
38. **The wire vocabulary is derived twice** (`Command` view half, `ModelCommand` model half). Completeness is checked (`vocabulary_is_complete.rs`). `ApplyAndClearDrill` and `AcceptWizardModel` are deliberately off the wire (session-local operands). Recount, don't cite.
39. **`Applied` is an enum (`Diff | Rebased | Rejected`)** because a document swap is not a diff; the trigger is the generation moving, not the variant. A swap fixture must keep the view *name*.
40. **A batch's basis is three tiers** (`document_generation`, `layout_version`, `record_versions`); the unit is the record; lower tiers are *observed*, not read off the effect. The record tier is the consolidated batch cell delta (`cell_delta` over the store's change log, `moved_records`); `record_fingerprints` is gone.
41. **`Storage` is not a VFS;** SQLite was declined on measurement (a second serialization is a second grammar).
42. **Browser render decisions are made in Rust** (`RenderModel`: `editable` from the `CellId` variant, selection reconciled with offsets, `flatten_cat_tree`). `improvise_protocol::frame` is the one envelope pair for both deployments.
43. **A batch carries its basis, and the server publishes it** (`Projection::Basis`). `Basis` has no `Default` or public constructor; `Checked`/`CheckedBatch` make "ignoring the basis" unrepresentable; `Rejection` keeps `RecordGone` / `RecordMoved` / `RecordUnstated` apart. Anti-staleness, not security. The check is a property of the session door (`apply_command` does not check), and only two of the three tiers hoist per frame — the record tier is asked per command (`records_named`) against the frame's basis. Read gotcha 43 before touching it.
44. **The TUI grid draws from `GridCache`**, which frames it too (`AxisFrame`; width rule `header_level_widths`). Use the one-pass accessors, not per-position scans. `RecordHash` is document-scoped, `AggregateHash` layout-scoped.
45. **One session loop (`Host`), two transports.** Saving is a request frame (`ClientFrame::Save`); dirtiness is *declared* for `WholeWorkbook` and *observed* for `CellData`/`LayoutGeometry` (`MarkDirty` is gone); for `CellData` the flag is each effect's own delta (drained after every `CellData` effect, so it keeps its order with `Save`) plus reorders, formulas and config, not a document fingerprint; writes that cancel across effects still leave it set. `impl Storage for Rc<T>` lives in `improvise-io`. `harness/` runs `OpfsFs` for real; Safari is unseen.
46. **The page picks a peer:** `?ws=` → socket, else a module `Worker`. `#status` composes two facts; the first-frame watchdog is per-peer-agnostic. The demo model is built so every cell is a `Record`.
47. **Keys become commands in the browser;** the server must not know the keymap. `GridExtent`, `set_viewport`, `Commands(Vec)`, `resolve::NEEDS_THE_HOST` and the five client rules (`projected_at`, `opened`, `refused_here`, `Step::Declined`, `notify`).
48. **The browser JS is partly executed** (`harness/tests/shell.spec.js`); changes to `web/*.js` belong there. Serialize frames on one tail promise; disarm, don't pre-empt, a watchdog. Evidence headings may be module-qualified (and must be when the leaf collides).
49. **A websocket handshake is exempt from same-origin policy** — `src/origin.rs` refuses the upgrade (loopback and absent allowed, two refused). Not authentication.
50. **A composed TUI row measures in cells and names which side yields** (`fill_line` + `Keeps`). Keep whichever key table is *derived from what it describes*. Scrolls are measured where drawn (`grid_width`).
51. **The command language reads pipes, refuses surplus, and checks names when applied** (`only_when_omitted`; `RequireKnown`/`unless_unknown`).
52. **A date column imports as one hierarchical category;** `--split-date` restores flat. `Plan::spelling_clash` refuses what would silently merge; `--strict` refuses partial skips.
53. **Delivery: a watermark is a `Stamp`; a correction is `target - held`, applied only at the watermark it starts from.** `through` is not `at` (the watermark advances only to `through`), a refused frame restores `Delivery` with the app snapshot, and a lost last notice is found only by `poll`. Axis changes refetch the window until ADR 008's render model. Long form `context/code/delivery.md`.
---
## File Map
Per-crate entry points. Per-file notes, test counts and a dated line-count snapshot are in
`context/file-inventory.md`; re-derive sizes with `wc -l`.
| Area | Where | Entry points |
|---|---|---|
| Core model | `crates/improvise-core/src/` | `model/{types,cell,record,category,id}.rs`, `view/{layout,types,axis}.rs`, `workbook.rs`, `format.rs` |
| Formula | `crates/improvise-formula/src/` | `parser.rs`, `formula.pest`, `ast.rs`, `quote.rs` |
| I/O | `crates/improvise-io/src/` | `storage.rs`, `opfs.rs`, `persistence/{improv.pest,format,parse,export,mod}.rs`, `import/{wizard,analyzer,csv_parser}.rs` |
| Protocol | `crates/improvise-protocol/src/` | `lib.rs` (ViewState), `reduce.rs`, `render.rs`, `keymap.rs`, `face.rs`, `frame.rs`, `basis.rs`, `resolve.rs`, `client.rs`, `viewmodel.rs`, `help.rs` |
| Command layer | `crates/improvise-command/src/command/` | `cmd/{core,registry,navigation,cell,commit,effect_cmds,fit,grid,mode,panel,search,text_buffer,tile,wizard}.rs`, `keymap.rs`, `parse.rs` |
| Semantic UI state | `crates/improvise-command/src/ui/` | `effect.rs`, `app.rs`, `project.rs`, `reduce.rs`, `host.rs`, `viewport.rs`, `cat_tree.rs` |
| TUI + CLI | `src/` | `draw.rs`, `main.rs`, `ui/{grid,help,import_wizard_ui,text}.rs`; integration tests in `tests/` |
| GP | `crates/{gp-tree,improvise-gp}/` | `gp-tree/src/{lib,data,fit}.rs`, `improvise-gp/src/{lib,dataset,fit}.rs` |
| Socket host | `crates/improvise-ws-server/src/` | `registry.rs`, `origin.rs`, `transport.rs`, `main.rs`; `host.rs` is a re-export shim |
| Worker host | `crates/improvise-worker-server/src/` | `worker.rs`, `vfs.rs`, `bindings.rs` (wasm32 shim) |
| Browser client | `crates/improvise-wasm-client/src/`, `web/` | `lib.rs`, `keys.rs`, `render.rs`, `socket.rs`; `web/{app,shell,worker,serial}.js`, `index.html`, `demo.improv`, `build.sh`, `assemble.sh` |
| Browser harness | `harness/` | Playwright (`tests/shell.spec.js`, `tests/opfs.spec.js`), `opfs-contract/`, `build.sh` — not a workspace member; its own CI job |
| Examples | `examples/`, `crates/*/examples/` | `gen-grammar.rs`, `pretty-print.rs`, `category_width_bench.rs`, `fit_csv.rs` |
| Scripts | `scripts/` | `checkers.sh` (every `*-test.sh` suite), `check-comment-runs.py`, `check-ci-graph.py`, `bd-audit-ids`, `kb`, `pr-watch`, `pr-reviews`, `build-doc-site.mjs` |
| Context docs | `context/` | `design-principles.md`, `gotchas.md`, `file-inventory.md`, `code/<area>.md` (arguments), `test-evidence/` (bug evidence by test name), `adr/` (001–005, 007, 009; no 006/008 file yet), `cloud-agents.md`; `prolog-sessions/kb.pl` is the KB; `docs/design-notes.md` is product vision |
---
## Wasm
The browser bundle is `improvise-{core,formula,io,protocol,command,wasm-client,worker-server,incremental}`
plus `zset` and `numtower`; each `cargo check --target wasm32-unknown-unknown` clean with no feature flags
(`wasmCrates` in `nix/ci.nix`, which also gates `improvise-gp`, a candidate). `improvise-ws-server`,
`zset-ffi` and the root crate are native-only.
Check new bundle dependencies with `cargo tree --edges normal` (the default output includes dev edges),
per crate. `cargo check` cannot see runtime hazards: `Instant::now()` panics on wasm (`web-time`), and
`std::fs` compiles but fails in a browser. Long form: `context/file-inventory.md`, appendix.
## CLI
```
improvise [model.improv] # open TUI (default)
improvise import data.csv [--no-wizard] [-o out] # import CSV/JSON
improvise cmd 'add-cat Region' -f model.improv # headless command(s)
improvise script setup.txt -f model.improv # run script file
```
Import flags: `--category --measure --time --skip --extract --axis --formula --name`, `--split-date <field>`
(flat date split; default is one hierarchical category), `--strict` (refuse any skipped record), and
`--on-duplicate refuse|sum|separate` (default `refuse`). Details: `context/code/cli.md`, gotchas 51–52.
## Testing — the short version
Full guidance: `context/design-principles.md` §7.
- **No single test binary over 2 seconds**; the workspace wall clock is the sum. Re-measure with `cargo test --workspace` (a `finished in` per binary) and compare the slowest binary to the next few — a ratio, not seconds, and not a ratio to the median (it is zero).
- Commands: build a `CmdContext`, call `execute`, assert on the returned effects. No terminal.
- Property tests (`proptest`, default 256 cases) for invariants and round-trips; a grammar-walking strategy must read its pest grammar once (`OnceLock`).
- **Bug-fix workflow:** write the failing test first. Record the bug in `context/test-evidence/`, keyed by test name — not in a doc comment (two-line cap, `scripts/check-comment-runs.py`).
- Coverage target ~80% on logic code; skip ratatui render paths.
## Key dependencies
ratatui 0.30, crossterm 0.29, clap 4.6 (derive), serde/serde_json, indexmap 2, anyhow, chrono 0.4, pest, flate2, csv, enum_dispatch. Dev: proptest, tempfile, pest_meta.