Accepted but explicitly not a priority: the goal is the OPTION of using improvise as a DB client. Written down now so the option stays open, since several decisions in flight could foreclose it by accident. The consequence worth the ADR: a live-backed document splits into our configuration and their data, which .improv does not distinguish — and read through the DB-client framing that is the natural shape, a saved query plus a pivot configuration, i.e. a report definition. Also flags that an external writer voids a guarantee ADR 001 currently states. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
60 KiB
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—.improvsave/load, CSV/JSON import,Storagebackends. No UI/commands.improvise-protocol— the wire vocabulary and the portable session slice:Command,Projection,ViewState, the per-widget caches,reduce_view, key types. Depends only onimprovise-core+ serde. No IO, no command layer, no terminal.improvise-command— command/effect layer + semanticAppstate (app.rs, effect.rs, cat_tree.rs, viewport.rs). Depends onimprovise-protocol. No ratatui/crossterm.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 fittedgp_tree::Treeas improvise formula text (formula_text/fitted_formula). Depends ongp-tree+improvise-formula; nothing depends on it yet.
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 | command/keymap.rs → default_keymaps() |
| 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, color or cycle | view/axis.rs → impl Axis (colors resolve in draw.rs::axis_fg) |
| 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 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)
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, GridLayout, AppMode, cursor/offsets, buffers, yanked cell, key code, expanded cats, and panel/tile cursors.
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 = 12 (regular only). 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): 16 variants (Normal, Editing, FormulaEdit, FormulaPanel, CategoryPanel, ViewPanel, TileSelect, CategoryAdd, ItemAdd, ExportPrompt, CommandMode, ImportWizard, Help, Quit, RecordsNormal, RecordsEditing). 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. mode_style() stays in src/draw.rs because Style is a ratatui type. 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 (command/keymap.rs): Binding { Cmd | Prefix(Arc<Keymap>) | Sequence(Vec<…>) }. Lookup fallback: exact(key,mods) → Char(NONE) → AnyChar → Any → parent. 16 mode keymaps built by KeymapSet::default_keymaps(); mode resolved via ModeKey::from_app_mode().
.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/filtercarry the selected item after a comma. - Virtual categories:
_Index/_Dimnever persist;_Measurepersists only non-formula items (formula targets are rebuilt from the## Formulassection). - 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 asInitial View. Their commands rebuild them on demand.
Gotchas (read before editing)
-
Records mode is wide format. A row is one coordinate tuple — the cells sharing everything but
_Measure— and each raw measure gets its own editable column, followed by a+column that creates a new measure (improvise-c5v). Consequences: two measures on one coordinate are ONE row; editing a category column moves every measure cell in the row, not just the one under the cursor;_Dimin the synthetic key carries the column index, not a label, because a category and a measure may share a name — resolve it withGridLayout::record_colinto aRecordCol. Formula targets are not columns (nothing to type into them). Drill pending edits are keyed by(row, RecordCol).prune_axis_itemstakes a predicate, not a flag vector.prune_emptyhands itimpl Fn(usize) -> boolover the data index (group headers consume no index). The domain is derived fromitemsitself, so there is no parallel length a caller could get wrong — the earlier&[bool]+debug_assertpair could disagree between debug and release if a third caller ever sizedkeepfrom something other thanrow_count()/col_count()(improvise-n3wt). -
Effects never touch
std::fs. Every document read or write goes throughApp::storage: Box<dyn Storage>(improvise-fuj). The host picks the backend:App::newsuppliesPhysicalFs,App::with_storagetakes any other — a browser worker will supply OPFS (improvise-i34), and tests useMemoryFs.improvise-iokeeps path-taking wrappers (save,load,export_csv,parse_csv_with_warnings) for the native CLI, each delegating to a_to/_from/_bytesvariant that takes the backend or the bytes. -
Every effect declares an
EffectKind, and there is no default.ViewOnlynever reaches other clients;Model(CellData | LayoutGeometry | WholeWorkbook)does (improvise-mae). A new effect that forgets to classify itself fails to compile — that is deliberate, since the failure mode is silent: a mis-declaredViewOnlyedit would simply never reach connected clients. Watch two traps: the View lives insidemodel_state.workbook, so "touches model_state" does NOT mean "is a model change" (axis/page/format areLayoutGeometry, cursor and scroll areViewOnly— see improvise-8uz); andSave/SaveAs/MarkDirtytouchmodel_statefor bookkeeping only and areViewOnly.AddCategory/RemoveCategoryare the third trap and go the other way: they look like data edits, but both go throughWorkbook, whoseon_category_added/on_category_removedrewrite every view's axis map, so they areLayoutGeometry. The two "an effect declaring X actually changes X" tests cannot separate them — a category change moves model data and view config, so it clears either bar;structural_effects_declare_their_kindpins the declaration itself. A third trap, now guarded:EffectKindis too coarse to check a mapping.ui/reduce.rs::model_effectssends each of 28ModelCommandvariants to one effect, and every inverse pair —HideItem/ShowItem,AddItem/RemoveItem,CreateView/DeleteView,SetCell/ClearCell— declares the same kind, so swapping three of them at once left the suite at 918 passing while three wire messages did the exact inverse of what they said. The mapping is name-for-name by construction, soevery_model_command_names_the_effect_of_the_same_nameasserts that, reading the type name off the effect's derivedDebug(Effectis a trait object, sotype_nameis out of reach). A variant whose effect must be named something else is a variant that stopped being derived — it should fail there and be argued about. Their reach is wider than the name suggests:Model::remove_categoryalso drops every formula targeting the category, which is whyDirty::of(LayoutGeometry)reportsformulas: true— see gotcha 35. -
Commands never mutate.
&CmdContextis read-only; returnVec<Box<dyn Effect>>. If you want to touch&mut App, add or extend an Effect. -
CellKeyis always sorted. Use the constructors; equality and hashing rely on canonical form. -
No
Model::add_item. With aWorkbookin hand use the facade:wb.add_item("Region", "East")?/add_item_in_group/remove_item(improvise-tvt). With only aModel, go through the category:m.category_mut("Region").unwrap().add_item("East"). -
Virtual categories.
_Measureitems = explicit items ∪ formula targets. Usemeasure_item_names()/effective_item_names("_Measure").add_formuladoes not add items to_Measure. Formula-target lookup also falls back into_MeasureviaModel::find_item_category. Cell aggregation goes throughapply_agg, formulaSUM(…)/AVG(…)througheval_agg— they differ on empty input (absent vs.0for Sum/Count, error for Avg/Min/Max). -
Display rounding is view-only.
format_f64(half-away-from-zero) is only called in rendering. Formula eval uses fullf64. Never feed a rounded value back into eval. -
Formula eval is fixed-point.
recompute_formulas(none_cats)iterates until stable, bounded byMAX_EVAL_DEPTH; circular refs converge toCellValue::Error("circular").App::newruns it before the first frame so formula cells render on startup. -
Drill into formula cells strips the
_Measurecoordinate when it names a formula target, somatching_cellsreturns the raw records that feed the formula instead of returning empty. -
Minibuffer keymaps come from
TextEntry. The seven text-entry modes are built by theTextEntrybuilder inkeymap.rs, which binds the shared spine (AnyChar→append-char <buf>, Backspace→pop-char <buf>, Esc→[clear-buffer <buf>, enter-mode <exit>]) and welds every commit key to itsclear-buffervia.commit(key, cmd, args)(improvise-w3q).TextEntry::newtakes theAppModeand reads<buf>off itsMinibufferConfig, so the buffer the prompt names and the buffer the keys edit are the same string by construction (improvise-k8h) — the keymap never spells a buffer name..rebindexists only for CommandMode's Backspace.SearchModeis hand-written on purpose — no commit key, andexit-search-modeowns the query.EnterModeclears the incoming mode's minibuffer throughwrite_buffertoo — buildingSetBufferdirectly there was unreachable only because no shippedAppModeis keyed"search".MinibufferConfig::buffer_keyis aString, not&'static str: a borrowed-'static field makesDeserializeunimplementable, and every text-entryAppModevariant carries aMinibufferConfig(improvise-a2x). A buffer named at runtime is read withcmd::core::read_bufferand written with its mirrorwrite_buffer— "search" is not in the buffers map but its ownViewStatefield, and those two functions are the only code that knows it (improvise-gdl). -
Panel keymaps come from
PanelKeymap. The three panel modes share a spine (Esc/Tab→Normal, arrows and j/k→move-panel-cursor <panel>, every panel's toggle key,:→command mode) built byPanelKeymap::new(panel); the keys that act on the panel's contents arrive through.bind/.bind_args/.deletebecause they vary in shape, not just command name (improvise-4pg).Panel::ALL/name()/key()inui/app.rsdrive the toggle keys for Normal and all three panels, so a fourth panel reaches every keymap from oneALLentry.name()must spell whatparse_panelaccepts. -
Prefix keys are stored as an address, not a keymap.
ViewState::transient_prefixholds the(KeyCode, KeyModifiers)that opened a prefix;KeymapSet::prefix_keymapresolves it back to the sub-keymap, walking parents soRecordsNormalreaches Normal'sg/y/Z(improvise-jc8).ViewStatemust stay free ofimprovise-commandtypes — it is destined for the protocol crate, whichimprovise-commandwill depend on. Resolution assumes prefixes are flat;prefix_keymaps_are_flatfails the build if a nested one is added. -
Keybindings are per-mode.
ModeKey::from_app_mode()resolves the mode; Normal +search_mode=truemaps toSearchMode. Minibuffer modes bind Enter/Esc asBinding::Sequencesoclear-bufferfires alongside commit/cancel. Hints are checked against the keymaps:every_hint_key_resolves_in_its_mode(command/keymap.rs) splits eachAppMode::hint_text()and requires every key it names to resolve in that mode's map, becauseonly_quit_has_no_hintasserts only that a hint is present. It checks which keys are named, not what they do. Its tokenizer knows the hint dialect explicitly (named keys,Ctrl-<c>,F/C/Valternatives, character runs likehjkl/[:], the em-dash prose tail, CommandMode's:qex-commands) and panics on a token it cannot classify rather than skipping it. -
Registry names come from prototypes, not strings. Every
register*call reads the name off a prototypeCmdinstance, so a typo is impossible (improvise-9cn) — there is no name literal inregistry.rs. What types can't reach: four commands name themselves off a field (ViewNavigate,TileAxisOp,ToggleGroupAtCursor,CommitAndAdvance), so a prototype can name the wrong variant and still compile. That shows up as a duplicate registration, not a mismatch — the wrong-variant entry collides with its sibling and the sibling's name vanishes.registered_names_are_uniquecatches it;every_bound_command_name_is_registeredcatches the keymap side, and checks both halves ofdispatch'sregistry.interactive(name, args, ctx).ok()?— the name resolves and the args parse — because improvise-s13 movedenter-mode ["export-prompt"]/["formula-edit"]out of the nullary class, where a typo'd mode name compiles into a dead key. It builds theCmdContextin each keymap's ownAppMode, sincecommit-buffer itemreadsctx.mode. -
effect_cmd!macro wraps a pre-built effect as a parseable command. Use it for simple wrappers; reach for a hand-writtenCmdonce there's real decision logic. Its counterpart on the effect side issimple_effect!inui/effect.rs, which declares a one-field newtype that writes one place on theApp. Its kind argument is mandatory —simple_effect!(Name(T) => |app| place, EffectKind::ViewOnly)— even though every call site so far answersViewOnly: hard-coding it reintroduced exactly the default gotcha 3 does without, since$placeis an unconstrained expression and a one-lineactive_view_mut().number_formatsetter would have reportedViewOnlywith the suite green (improvise-8fy). Likewise, mode entry has no per-mode shim commands — bindenter-mode <mode-name>(improvise-s13); the name is decoded byparse_mode_nameinregistry.rs, andEnterModeclears the mode's minibuffer on the way in. -
Float equality inside formula eval is currently mixed —
=/!=use a1e-10epsilon but the div-by-zero guard checksrv == 0.0exactly. Don't assume one or the other; tracked for cleanup. -
IndexMappreserves insertion order for categories, views, and items. Persistence relies on this. -
Commit paths for cell edits split across
commit_cell_value→commit_regular_cell_value/commit_plain_records_editincommand/cmd/commit.rs. Keep the synthetic-records branch in sync with the plain branch when changing behavior. -
Minibuffer commits go through one command.
commit-buffer <formula|category|item>(CommitBuffer { kind: CommitKind }) owns buffer→trim→bail-on-empty→dirty→status for all three; the varying parts are methods onCommitKind. The add effect is emitted last so its failure message wins over the command's optimistic status (improvise-6t3).CommitKind::Itemcarries its target category, resolved fromAppMode::ItemAddbyparse_commit_kindat registry time — socommit-buffer itemis not constructible from a script line. -
A failed
Saveaborts the batch. TheSaveeffect setsabort_effectswhen there's no file path or the write fails (improvise-8tk), so anything chained after it — theQuitinwq/ZZ— is skipped and the error stays on screen. ABinding::Sequenceflattens into one effect vec, so the abort carries across sequence steps too. -
The import wizard's back path is a history stack, not a reverse table.
advancepushes the step it leaves ontoImportWizard::history;previouspops it, so back always retraces the path actually taken through the conditional transitions (improvise-907). Preconditions live invalidation_error(), checked on the step where the user can still fix them — never deferred tobuild_model.NameModelis terminal foradvance: Enter there callsbuild(), which parks the workbook inbuiltonDone, andAcceptWizardModeltakes it from there. Going back fromDonediscardsbuilt. -
app.layoutgoes stale mid-batch.apply_effectsrebuilds only after the whole batch, so an effect that reads the layout after an earlier effect changed the grid needs aRebuildLayouteffect in front of it — the command decides the order (seeEnterEditAtCursorCmd,CommitAndAdvance). Read the cursor's cell text viaApp::display_value_at_cursor(); don't build aCmdContextinsideapply(it recomputes column widths). -
The cursor is per-client and keyed by view.
ViewState::cursors: HashMap<String, ViewCursor>holdsselected/row_offset/col_offset, keyed by view name; an absent key means the origin. Reach it throughApp::cursor()(aCopysnapshot),App::cursor_mut(), andApp::reset_cursor()— never offView, which no longer has the fields (improvise-8uz). Two consequences. Changing the grid's shape must reset the cursor explicitly:View::transpose_axes/cycle_axisused to do it themselves, and nowTransposeAxes/CycleAxiscallapp.reset_cursor()— a new geometry effect that renumbers rows or columns has to do the same. Replacing the document must clear the map:LoadModelandAcceptWizardModelcallcursors.clear(), because the incoming workbook's view names have nothing to do with the outgoing one's. That clearing replacesWorkbook::normalize_view_state, which is gone — aViewcan no longer carry a stale offset. The same clearing owes the session mirrors:install_workbookbumpsApp::document_generation, andClientSession::projectreseeds any mirror seeded at an earlier one (improvise-cqq). An effect cannot reachSessions, so the generation is the mechanism andSessions::rebaseis only the client-facing half. -
ViewStateand its field types areSerialize/Deserialize. It is the wire payload of the client Snapshot (improvise-q08), soAppMode,MinibufferConfig,MinibufColor,ViewFrame,KeyCodeandKeyModifiersall derive both (improvise-a2x), andimprovise-commanddepends onserdefor it. The constraint this puts on new state: no borrowed-'staticfields anywhere ViewState can reach —Deserializecannot produce one, and the error surfaces on every enclosing variant rather than at the offending field.view_state_survives_a_json_round_tripguards it.ViewStateandSnapshotalso deriveCloneandPartialEq, so the bootstrap message can be compared in a round-trip test. -
Axis presentation lives on
Axis, colors resolve indraw.rs. Four vocabularies hang off the enum inview/axis.rs—plain_label()("Row"/"Col"/"Page"/"None"),glyph()(whichDisplayappends, giving "Row ↕"),short_label()(three columns for the tile bar;Noneis "·", not a truncation), andinitial()(one letter for the view panel;Noneis empty). Pluscolor() -> AxisColorandnext()for the Row→Column→Page→None rotation.AxisColoris a semantic palette:improvise-coremust not name a ratatui type, sodraw.rs::axis_fgdoes the one mapping to a terminal color, exactly asminibuf_fgdoes forMinibufColor(improvise-rml). The tile bar and category panel each carried their own copy of the color table before, with nothing keeping them in agreement. Not covered here:persistence/format.rsmapsAxisto the on-disk keywords ("row"/"column"/"page"), which is a format concern and stays in the io layer. -
Axis::Filterconstrains without being navigable. Drilling fixes its coordinates onFilter, notPage(improvise-dz8): both restrict which records are visible, but onlyPageis something the user may move or page through. The split shows up as two different questions, and picking the wrong one is the trap: "what data is visible" isView::filtering_cats()(Page ∪ Filter — used byGridLayoutand byadd-record-rowwhen it builds a key from the current constraints), while "what can the user act on" iscategories_on(Axis::Page)(used by[/]page cycling) orView::tileable_cats()(used by all three tile-select sites).Axis::next()mapsFilterto itself and no navigable axis rotates into it, so tile select cannot hand one out. The.improvgrammar acceptsfilterand round-trips a selection with it — a Filter axis only ever occurs in the never-persisted_Drillview, but the alternative was writing it out as some other axis. -
The browser backend is sync on purpose, with an async seam.
OpfsFs(improvise-io/src/opfs.rs, wasm32 only) implements the same synchronousStoragetrait asPhysicalFs, becauseFileSystemFileHandle.createSyncAccessHandle()yields a handle whose read/write/truncate/flush are blocking — available only inside a dedicated Web Worker, which is where improvise-9x6 puts the app. SoStoragenever became async. What is still async splits out into four explicit methods the worker awaits from its message loop:open(),attach(path)(must precede any read/write of that document — acquiring a handle is async even though using it is not),refresh()(repopulates the indexlistanswers from), andcommit_deletes(). Consequence:deleteis visible immediately but lands in OPFS at the end of the batch — it drops the name from the index and queues aremoveEntry. Handles are exclusive per file, soattachcloses any handle it replaces anddetachreleases the lock.storage_contractinstorage.rsis the behaviour every backend owes; it runs against both host backends and is the written-down specOpfsFsmust satisfy in a browser, where the harness cannot reach. -
"Which view is active" is stored twice, and the copies must move together.
Workbook::active_viewis the document's;ViewState::active_viewis the client's, and it is what keysViewState::cursors. The second copy exists becausereduce_viewruns on a client that holds noWorkbook(improvise-cqi). Only two writers are allowed to touch either —App::switch_to_viewandApp::install_workbook— andthe_two_active_view_names_never_divergeguards it. Callingworkbook.switch_view()directly desyncs them and the cursor map is then read under the wrong key; that is exactly how the 8uz characterization test broke during the extraction. Whether the two are allowed to diverge — two clients on different views — is improvise-g0f, and the answer is yes, eventually: the model is shared, the UI is not, soWorkbook::active_viewbecomes the document's default (what a fresh client opens) andViewState::active_viewis authoritative per client. improvise-jytm owns the split; a "follow mode" binding two UIs together is deliberately not ruled out. Until thenSessions::applykeeps them in lockstep by routingSwitchViewthroughApp::switch_to_view— see gotcha 36. -
Two tests gate
ViewStatemembership.ViewStateis the portable session slice — the one thing serialized into the Snapshot a newly-connected client gets (improvise-q08). A field may live there only if it is portable (plain data from the allowlisted crates, noRc/Arc) and snapshot-safe (a fresh client inheriting the value is correct).wizardfails both —ImportWizardis animprovise-iotype, and a half-open dialog is not something to inherit — anddrill_statefails both — it holds anRc, andpending_editsare uncommitted writes belonging to whoever staged them. Both live onAppunder the session-local residue tag (improvise-h0b). TheViewState: Sendstatic assertion inapp.rs's tests is the compile-time proxy for portability:Rcis notSend, so a re-added shared pointer fails the build (theDeserializederive from gotcha 25 now rejects it too, serde'srcfeature being off).Senddoes not catch the origin-crate or snapshot-safety halves — check those by hand. -
:commands settle their mode by observation, not prediction.ExecuteCommandends every batch withReturnToNormalUnlessMoved(ctx.mode), which drops toNormalonly if the mode is still what the batch started in. There is noEffect::changes_mode()any more: it was a per-type constant, andViewBack/ViewForward/StartImportWizardswap the mode only on some runs, so no up-front answer was right —:importopened the wizard and was snapped straight back out (improvise-toc). A new effect that changes the mode needs no annotation; just don't return the batch's starting mode from a success path. -
There is no aggregate render cache — each widget folds its own stream.
improvise-protocol::renderholds one cache type per widget (GridCache,CategoryTreeCache,FormulaCache,ViewListCache,TileBarCache), each implementingWidgetCachewith its ownUpdatetype, and aProjectionis one such update addressed to one widget (improvise-rkeq).reduce_viewfolds onlyViewStatenow — it no longer takes a cache, andCommand::Projectpasses through it untouched. Two consequences. The overlap between caches is deliberate: the tile bar and the category panel both carry(category, axis)and are updated by separate projections, so neither is woken by the other's changes; do not "fix" it by sharing a variant. Bootstrap is a compaction, not a replay:Snapshot { view, seed: Vec<Projection> }seeds a new client with a batch of the same projections the steady stream carries, so there is no whole-state type that could disagree with the incremental one. The seed is the server's current state, not the head of the history —GridCache::seed()builds it, and the property that earns it isGridCache::folded(&cache.seed()) == *cache. Nothing is defined for the help overlay (its content is static andhelp_pageis already inViewState) or for the import wizard (its projection shape waits on improvise-764 deciding where the wizard state lives). -
The grid's labels frame its cells, and
applyenforces it — the fold is not commutative. Three consequences inimprovise-protocol/src/render.rs. (a) AGridUpdate::Cellsthat overtakes theRowLabels/ColLabelsnaming its positions is dropped, becausefit_to_labelstruncates to the labels it has; a client must not reorder the stream, anda_cell_update_that_overtakes_its_labels_is_lostpins it. (b) A label change that renames a position blanks the cell under it:fit_to_labelspreserves by POSITION, which is only right where the label did not change, soapplycallsinvalidate_relabelleditself rather than trusting a producer to sendGridUpdate::Invalidatedfirst.Invalidatedtherefore now means only "contents stale, frame unchanged". (c) every arm that touches labels or widths re-fits —col_widthsis re-fitted with the columns — a new column is drawn at its label's own character count untilColWidthscatches up — because a renderer zips widths with labels and a length mismatch indexes off the end. Fitting on the label arms alone is what let a lateColWidthsput the mismatch straight back — a short update keeps each unheard-of column at its label's width, and a long one is dropped as having overtaken the frame that names it, exactly as (a) holdsCells. There is still no producer in the tree; this is the shapefrom_appmust meet. Note the fold tests: assertingfold(prefix)+fold(rest) == fold(whole)is vacuous (it is the definition of a left fold), so the tests that earn their place are the order-sensitivity one and the compaction-equivalence one — seedoctrine(fold_tests_must_test_order_not_the_axiom). -
A formula target is unquoted, and pipe quoting has one home.
parse_formulastrips the pipes off a target exactly as it does off anExpr::Ref— the target names a_Measureitem, so a target that kept its delimiters madeadd_formulacreate an item literally named|Total Revenue|: a ghost column the user's real item never matched, andExpr::Ref("Total Revenue")never resolved to. Both the TUI formula minibuffer (AddFormula) and.improvload go through that one call, so both were affected. The writer emitsFormula::raw, nottarget, so file text is unaffected either way — which is exactly why the round-trip tests never saw it, and why the fix repairs existing files on next load with no migration. Pipe quoting has exactly one home:improvise_formula::quote, owning both halves —quote_name/is_bare_name,pipe_quote(unconditional, for a.improvtext cell that must not read back as a number) andunescape_pipe_body.improvise-gpre-exports it,improvise-iocalls it frompersistence/{format,parse}.rs,parser.rs::unquote_pipeis a shim over it, and io's own copy is deleted (improvise-i2m5). The keyword guard is canonical — user-confirmed, so do not revert it as over-caution: an item literally namedSUMis written|SUM|. Bare keywords do parse, but only becauseagg_callneeds a(and PEG backtracking falls through toref_expr— rule ordering, not a stated rule — and a## Formulasline carries formula text verbatim, so a nameformat_mdwrites is read back byparse_formula: one namespace, one spelling rule. Two things the guard does not cover: section headers are unquoted on both sides (category_sectionis"## Category: " ~ rest_of_line), so a name is quoted in every reference and raw in its own header; and the two former unquoting copies differed only on a trailing lone backslash, unreachable from the grammar, where io returned it andparser.rspanicked — the survivingunescape_pipe_bodyis total. -
Dirtyover-reports on purpose, and its doc comment is the derivation.Dirty::ofmaps anEffectKindto the widgets that kind can reach, and the two directions do not cost the same: over-reporting pays one redundantProjection, under-reporting leaves a client's cache wrong forever, with nothing in the stream to correct it.LayoutGeometrytherefore includesformulas, becauseRemoveCategorywears it andModel::remove_categoryends by dropping every formula targeting the category — a client's formula panel otherwise kept showing a formula the document no longer had. It now coincides withDirty::EVERYTHINGand is still spelled out, because the two are derived from different effects and need not stay equal. The doc comment onDirty::ofis the only record of that derivation, and it went stale once already — claimingAddCategorywasCellDataafter it was reclassified — so reclassifying an effect means re-reading it. -
A client's command has one door, and the door takes the
App.Sessions::apply(app, id, cmd)is the only way a connected client'sCommandreaches the server;ClientSession::foldand the mirror'sViewStateare private, so a transport cannot reach the mirror at all. The door takes the authoritativeAppbecause some commands have a document half:SwitchViewroutes throughapply_command/App::switch_to_view(gotcha 29), and every mirror then takes what the document accepted, not what was asked — so a switch to a view that does not exist leaves the mirrors put. Not just the asker's:grid_ofbuilds every client's grid fromworkbook.active_view(), so a bystander left naming the outgoing view has its cursor read under a stale key and is projected the new view's rows positioned by the old view's scroll — gotcha 24's mismatch arriving through somebody else's keystroke.apply_switchtherefore takes noSessionId; the switch is addressed to no one session and that absence is the statement. A client's own slice lives on the client and stays in step only by folding the same command, soSessions::applyreturnsApplied { dirty, broadcast }, wherebroadcastis the accepted command the host forwards to the other clients. Everything else is that client's alone and never touches theApp's own view slice: the model is shared, the UI is not. FoldingSwitchViewinto the mirror alone was the bug —viewport()then read the cursor under the new view's key and the nextproject()built that client a grid out of the other view. -
gp-treereturns data and never prints, and a fit takes two things to reproduce. Two rules with one root — the crate is a library a TUI, a wasm worker or a CLI may each host. (a) A decision the user should hear about comes back as data:Dataset::warningscarries the columnspreprocessdropped for being entirely missing (improvise-d44s), andfit.rshands back pooled(prediction, actual)pairs and a score per feature rather than tables.examples/fit_csv.rsowns everyprintln!, and that is the only reason its output stayed byte-identical across these moves — adding aneprintln!to either module is the regression. (b) The seed is necessary and not sufficient:data::Modelrecordsseed: Option<u64>andconfig: Option<FitConfig>, because the same seed under a different population/generations/elite/penalty lands on a different tree (improvise-ksst). Both areOptionfor one reason — everyFitConfigvalue and the seed0are legal, so nothing can double as "unknown". Note#[serde(default)]on anOptionfield is documentation, not the mechanism: serde defaults it anyway, so deleting the attribute fails no test, and a backward-compatibility test that only deletes it is not discriminating. Aim such a test at an absent field loading as the default instead of as unknown. The GA loop itself isfit::fit(improvise-0ai);breedremains one fitness-agnostic generation for callers whose fitness is not "predict this column". (c) A scoring function takes one slice of rows.Row<T>pairs a feature vector with what it predicts, so the parallelx/ylengths thatzipused to truncate silently cannot be built (improvise-dkqn,doctrine(an_invariant_is_made_unrepresentable_not_validated)— the same answerprune_axis_itemsgot, and the reason noResultentered five signatures). The pairing is made at the boundary that builds the data:preprocesswalks one row index and the rows live insideTarget, because aDatasetcarryingrowsandtargetseparately just moves the zip up one level into every caller. The empty row set is one shape, answered once inmean_over. Changing any of this needs bit-level evidence:tests/scoring_known_answers.rspins every path as f32 bit patterns plus anext_u64witness that catches a changed RNG call order, because the CLI prints three or four decimals and byte-identical output alone cannot see a moved accumulation. -
The wire vocabulary is derived twice, by the same recipe.
Command's view half is theEffectKind::ViewOnlyeffects targeting the portable slice (decision(improvise-cqi, command_set_derived_not_invented)); its model half,ModelCommand, is theEffectKind::Model(..)effects — 29 of them, one variant apiece, minusSwitchView, whichCommandalready names and routes (gotcha 36), leaving 28 (improvise-kmgs). Note the obviousawkextraction reports 30: it picks upHelpPageSet, which isViewOnly, because that is the lastpub structbefore#[cfg(test)]and the test module mentionsModelChange::— brace-match theimpl Effectblocks instead (doctrine(derive_then_verify)). Three things follow. AModelCommanddoes not mutate:ui/reduce.rs::model_effectsmaps it back to the effect it came from andapply_commandruns that throughApp::apply_effects, so effects stay the only mutation site and theDirtyis the effect's ownEffectKind, never a second classification beside gotcha 35's table. Two model effects are deliberately off the wire.ApplyAndClearDrillandAcceptWizardModelwere once kept on the grounds that the command is portable even where the operand is not; that was the wrong criterion (improvise-3x3u). ACommand::Modelis routed with noSessionId, which is right exactly when the change is addressed to no one session — and these are addressed to precisely one, never the sender: their operands aredrill_state.pending_editsand the built wizard workbook, session-local residue (gotcha 30) private to whoever staged them (improvise-tom1). Unaddressed, B'sApplyAndClearDrillcommits A's uncommitted typing and B'sAcceptWizardModelswaps the document out of a dialogue B never opened — both demonstrated againstSessions::applybefore removal. So 29 −SwitchView− these two = 26. The effects are untouched and still reached by the key path. Not permanent: multi-client drill wants the addressed form, which is where ADR 001 layer 2 goes anyway. Guarded byno_model_command_commits_another_clients_session_local_state, plus a companion asserting both effects still exist and are still model changes so the first cannot pass by naming nothing.Sessions::applyroutes the model half toapply_model, which owes the same lockstep asapply_switch:ViewBack,ViewForwardand a document-replacingLoadModelall end inApp::switch_to_view, so every mirror takes the accepted active view. Its broadcast is conditional whereapply_switch's is not, because no client optimistically folded an active view here. A keymap lookup still cannot return aCommand, and not for want of vocabulary: a binding names an intent resolved againstCmdContext(move-cursor down) while aCommandis the resolved mutation (SetSelected { row, col }) — improvise-gxi's AC 4 asks for something the split forbids. -
Appliedis an enum because a document swap is not a diff.Sessions::applyreturnsApplied::Diff { dirty, broadcast }orApplied::Rebased { snapshots }, and the enum is the mechanism: a host that wants theDirtymust match, and matching names the case where aSnapshotis owed. The server's mirrors self-heal —ClientSession::projectreseeds on thedocument_generationbump — butViewState::cursorslives on the client, keyed by the outgoing document's view names (gotcha 24), and only a freshSnapshotreplaces it.Command::Modelput the threeWholeWorkbookmutations on the wire (gotcha 38), so the host that must send the snapshots is no longer the host that asked.apply_modeltherefore callsrebaseitself rather than instructing the host to, exactly asapply_switchadopts the accepted view itself. The trigger is the generation moving, not the command variant: aLoadModelthat cannot read its path leaves the document alone, and reseeding on the variant would cost a client its cursors for a failed load —a_load_that_fails_is_not_a_rebaseguards that arm. Arebase: boolbesidedirtywas rejected, because the host that forgets to callrebaseis the host that forgets to read the flag. Fixture warning. When testing a document swap, the incoming document's active view must have the same name as the outgoing one. A rename makesapply_modelbroadcast aSwitchView; the client folds it, and the stale cursor is then read under a key nothing has written — right by accident, with the bug invisible.other_document()renames, and a first version of this test passed because of it;a_document_shaped_differently_under_the_same_view_name()keeps the name and changes only the shape, so there is nothing to hide behind. -
A batch's basis is three tiers, and only two of them can be read off the effect.
Appcarriesdocument_generation(bumped byinstall_workbook),layout_version(perModelChange::LayoutGeometryeffect) andrecord_versions(per record) — ADR 001's stale-basis check, and shared document state, so never onViewState(gotcha 30). The unit is the record:record_of(key)is the coordinate tuple minus_Measure(record_row_identity), because renaming a category coordinate in wide records mode moves every measure cell in the row (gotcha 1) — a cell-level unit is violated by one legal edit, so the unit must be at least as coarse as the coarsest legal write. Both tiers are read inapply_effectsoff the sameEffectKindtheDirtyis: one classification, two readers, no second table to drift from gotcha 35's.layout_versionbumps inside the loop for the reasonDirtyaccumulates there — an aborted batch must not claim effects it never ran (gotcha 21).record_versionscannot, because anEffectKindsays that a batch writes cells, never which; so the records are observed, via a per-record XOR fingerprint of the raw data taken when the batch declares aCellDataeffect and diffed after it. Three consequences. A write landing the value already there bumps nothing, which is correct — no basis was invalidated. ALayoutGeometryeffect that drops cells will bump their records when it shares a batch with aCellDataone: an over-bump costing at most an unnecessary rejection, the same directionDirty::ofis deliberately sloppy in. And only raw data is fingerprinted — formula results live inModel::formula_cache, and a record whose derived value moved because someone edited elsewhere has not itself been written. The diff collects into a set: chainingbefore.keys()withafter.keys()counts any rewritten record twice, a bug that survived one test run. -
Storageis not a VFS, and that closes the SQLite question. The trait is whole-documentread/writebytes (gotcha 2,invariant(storage_is_two_ops)) — no offsets, seeking, handles or locking — so ansqlite3_vfs, which needsxRead(offset,len)/xWrite/xTruncate/xLock, has nothing to bind to. SQLite can still sit behind it, as an in-memory database serialized to a blob (rusqliteserialize/deserialize_read_exact, proven with a probe before declining — declining on a capability that actually exists would have been the wrong decline). It is worth nothing: measured on 18,000 cells the SQLite container is 22% larger than.improv, 4.7× larger gzipped, ~160× larger at the floor (24 KB against ~150 bytes), and still rewritten whole on every save, so the one dimension SQLite normally wins — incremental page writes — is unavailable. improvise-9ix was declined on that evidence. Two things generalise past SQLite. A second serialization format is a second implementation of the grammar, obliged to trackimprov.pestforever; and its own round-trip test cannot detect drift from.improv, because it never compares the two — that missing cross-format equality test is what made the ticket look cheap. If a structured-query story is ever wanted it is a one-wayexport_sqlitebesideexport_csv, native-only and feature-gated:libsqlite3-sys' C build fails under this repo's nix cc-wrapper forwasm32-unknown-unknown, which would break thewasmCI gate the day it landed.
File Inventory
Line counts are static; test counts are informational — run cargo test --workspace for live numbers. Files under 100 lines and render-only widgets omitted.
improvise-core (crates/improvise-core/src/)
model/types.rs 2062 / 70t Model, formula eval (fixed-point), CRUD
model/cell.rs 650 / 28t CellKey (sorted), CellValue, DataStore (interned + index)
model/category.rs 222 / 6t Category, Item, Group, CategoryKind
model/symbol.rs 79 / 3t Symbol interning
view/layout.rs 1194 / 29t GridLayout, drill, records mode
view/types.rs 627 / 33t View config (axes, pages, hidden, collapsed, format)
view/axis.rs 205 / 5t Axis enum + label/color/next methods, AxisColor
workbook.rs 345 / 17t Workbook: Model + cross-view ops, item facade
format.rs 229 / 29t format_f64, parse_number_format (display only)
improvise-formula (crates/improvise-formula/src/)
parser.rs 1109 / 67t pest grammar + tokenizer → Formula AST
quote.rs 115 / 5t quote_name / is_bare_name: CL-style |…| quoting, the
writing half of the grammar's bare_ident/pipe_quoted.
improvise-gp re-exports it; improvise-io still has its
own unguarded copy in persistence/quoting.rs
ast.rs 77 Expr, BinOp, AggFunc, Formula, Filter
improvise-io (crates/improvise-io/src/)
storage.rs 288 / 12t Storage trait (read/write/list/delete), PhysicalFs, MemoryFs
injected into App as `storage`; effects use it, never std::fs
opfs.rs 253 / 0t OpfsFs: Storage over the browser's Origin Private File
System. wasm32 only. Compile-checked, not browser-tested.
persistence/improv.pest 124 PEG grammar — single source of truth
persistence/mod.rs 259 / 7t document lifecycle: save/load/gzip/legacy-JSON over Storage;
facade re-exporting format_md/parse_md/export_csv*
persistence/quoting.rs 63 / 0t CL-style |…| quoting, shared by format and parse
persistence/format.rs 350 / 11t format_md: Workbook → .improv text
persistence/parse.rs 1378 / 42t parse_md: pest tree-walk → Workbook (+ grammar generator)
persistence/export.rs 156 / 3t export_csv / render_csv
persistence/roundtrip_tests.rs
635 / 34t format_md ↔ parse_md characterization (tests only)
import/wizard.rs 1121 / 38t ImportPipeline + ImportWizard
import/analyzer.rs 292 / 9t Field kind detection (Category/Measure/Time/Skip)
import/csv_parser.rs 300 / 8t CSV parsing, multi-file merge
improvise-protocol (crates/improvise-protocol/src/)
lib.rs 483 / 1t ViewState, ViewCursor, ViewFrame, AppMode (16), Panel,
MinibufferConfig, MinibufColor, HELP_PAGE_COUNT
reduce.rs 509 / 9t Command + ModelCommand (the wire format), HelpPage, reduce_view
render.rs 838 / 17t CellDisplay, WidgetCache trait + one cache per widget
(GridCache/CategoryTreeCache/FormulaCache/ViewListCache/
TileBarCache), GridCache::seed (the bootstrap
compaction), Projection, bootstrap-only Snapshot
key.rs 101 / 3t KeyCode, KeyModifiers, KeyEvent (moved from command/)
gp-tree / improvise-gp (crates/gp-tree/, crates/improvise-gp/)
gp-tree/src/lib.rs 669 / 8t Tree, Spec, eval, simplify, render, breed, Population,
SplitMix64 (vendored seeded Rng; seed() keeps the seed so
a fit can be shown and recorded, pinned to the published
reference vectors)
gp-tree/src/data.rs 937 / 19t raw string rows -> f32 feature vectors: per-column type
inference, one-hot, mean-imputation + col.missing flag,
ColumnSpec/encode_row, Dataset (incl. warnings),
Model = trees + plan + seed + FitConfig
gp-tree/src/fit.rs 561 / 11t the search: FitConfig (Default = the CLI's numbers), fit()
-> simplified champion, cv_regress/cv_classify,
permutation_importance, folds/standardize/mse.
Returns data; never prints
gp-tree/examples/fit_csv.rs 601 CLI over the above: flags, demo dataset, all printing
improvise-gp/src/lib.rs 485 / 14t formula_text / fitted_formula: a Tree as IF(...) formula
text, validated back through parse_formula. A threshold is
the SHORTEST decimal that reads back as the same f64 (not
the exact expansion), so a row on a split boundary routes
the same in f64 as in f32 eval; non-finite values panic
Command layer (crates/improvise-command/src/command/)
cmd/core.rs 319 / 2t Cmd trait, CmdContext, CmdRegistry, parse helpers
cmd/registry.rs 758 / 3t default_registry() — all registrations
cmd/navigation.rs 534 / 12t Move, EnterAdvance, Page*
cmd/cell.rs 198 / 6t ClearCell, YankCell, PasteCell, TransposeAxes, SaveCmd
cmd/commit.rs 517 / 9t CommitAndAdvance, CommitBuffer(CommitKind), CommitExport
cmd/effect_cmds.rs 466 / 5t effect_cmd! macro, 25+ simple wrappers
cmd/grid.rs 519 / 9t ToggleGroup, ViewNavigate, DrillIntoCell, TogglePruneEmpty
cmd/mode.rs 394 / 11t EnterMode, Quit, EditOrDrill, EnterTileSelect
cmd/panel.rs 587 / 13t Panel toggle/cycle/cursor, formula/category/view panels
cmd/search.rs 202 / 4t SearchNavigate, SearchOrCategoryAdd, ExitSearchMode
cmd/text_buffer.rs 240 / 7t AppendChar, PopChar, CommandModeBackspace, ExecuteCommand
cmd/tile.rs 160 / 5t MoveTileCursor, TileAxisOp
cmd/wizard.rs 199 / 4t HandleWizardKey — per-step import-wizard key tables
keymap.rs 1747 / 33t KeyPattern, Binding, Keymap, TextEntry, ModeKey, 16 mode keymaps
parse.rs 236 / 19t Script/command-line parser (prefix syntax)
Semantic UI state (crates/improvise-command/src/ui/)
ui/effect.rs 2644 / 63t Effect trait, 50+ effect types
ui/app.rs 2528 / 78t App, ModelState, ViewState, AppMode (16) + dispatch methods, Panel, handle_key, dispatch_key, document_generation, autosave, HELP_PAGE_COUNT
ui/project.rs 1618 / 20t Dirty, Viewport, grid_of/category_rows/formula_texts/
view_summaries/tile_entries, grid_updates (the diff),
ClientSession + Sessions (connect/apply/rebase/project_all)
ui/reduce.rs 766 / 15t apply_command / model_effects / reduce_full / reduce_key — one door
from a Command or a keypress to the authoritative App
ui/cat_tree.rs 156 / 6t Category tree flattener for panel
ui/viewport.rs 128 compute_col_widths / row_header_width / visible_cols (pure geometry)
TUI rendering, draw, main (src/ui/, src/draw.rs, src/main.rs)
ui/grid.rs 998 / 13t GridWidget (ratatui); geometry helpers live in improvise-command viewport.rs
ui/help.rs 603 5-page help overlay (render only)
ui/import_wizard_ui.rs 347 Import wizard rendering
draw.rs 422 TUI event loop, frame composition
main.rs 388 CLI entry (clap): open, import, cmd, script
# other ui/*.rs are small panel renderers — skip unless changing layout/style
Examples
examples/gen-grammar.rs Grammar-walking random .improv generator (pest_meta)
examples/pretty-print.rs Parse stdin, print formatted .improv
Context docs
.claude/skills/code-review/ Review skill: checks a change against the doctrine in these docs + kb.pl
scripts/nix-seed-flake-inputs.sh Seeds locked flake inputs over git so `nix develop` works in cloud sessions
prolog-sessions/kb.pl Executable knowledge base: invariants, doctrine, decisions (CI-checked)
context/adr/003-live-backed-models.md A model may be backed by a live query source —
improvise as a DB client. Accepted, NOT a priority.
context/adr/002-naming-a-cell.md How a client names a cell: an opaque surrogate on
CellDisplay, whose absence means the cell is an
aggregate. Records why _Index cannot serve.
context/adr/001-concurrent-edits.md Concurrent edits between clients: record-level atomicity,
optimistic basis check per batch, advisory reservations.
Records why a causal clock was rejected.
context/cloud-agents.md Cloud-session environment: tracker sync, bd pitfalls, what is blocked
context/design-principles.md Architectural principles & testing doctrine
context/plan.md Show HN launch plan
context/repo-map.md This file
docs/design-notes.md Product vision & non-goals
Wasm
The browser bundle is improvise-core, improvise-formula, improvise-io
and improvise-command. All four cargo check --target wasm32-unknown-unknown clean with no feature flags (improvise-ywd), gated by
the wasm job in .github/workflows/ci.yml. The root improvise crate is
the terminal binary and does not build for wasm — it depends on
crossterm; that is correct, not a gap.
cargo check passing is not the same as running. Two known runtime hazards
it cannot see: Instant::now() panics on wasm (hence web-time in
improvise-command), and any std::fs call compiles but fails in a browser
— see improvise-fuj for the two effects that still bypass Storage.
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.
Testing — the short version
Full guidance lives in context/design-principles.md §6. Quick reminders:
- Suite runs in <2s. Don't let that drift.
- Commands: build a
CmdContext, callexecute, assert on returned effects. No terminal needed. - Property tests (
proptest, default 256 cases) cover invariants: CellKey sort, axis consistency, save/load roundtrip,parse(format(parse(generate())))stability. - Bug-fix workflow: write a failing test before the fix (regression guard). Document the bug in the test's doc-comment (see
model/types.rs::formula_tests). - Coverage target ~80% line/branch 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 + pest_derive, flate2 (gzip), csv, enum_dispatch. Dev: proptest, tempfile, pest_meta.