a61f: `cargo test -p improvise-wasm-client --lib` failed with E0277,
because `FormulaEntry: From<&str>` exists only under
improvise-protocol's `test-support` feature, which that crate's
dev-dependencies never asked for. `--workspace` hid it: feature
unification pulled the feature in through improvise-command's dev-dep.
A sweep of every member with `cargo test -p <crate> --no-run` found
the same failure in the root `improvise` crate
(tests/bug_hunt_reproductions.rs). Both now request the feature. The
normal wasm tree still carries no `test-support`.
aopa: eight helpers in resolve.rs's tests folded client-composed
commands through `fold`, the host's door, so a composed Command::Model
was never awaited in `in_flight`. All eight use `fold_outgoing`, one
frame per key, as web/app.js does.
`a_folded_browser_commit_waits_for_the_host` pins press_folded; the
rival that folds through `fold` again fails it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
improvise-6yvg reproduced. A JSON import in improvise-command and a legacy
JSON .improv load in improvise-io stored 0.24815714285714285 as
0.24815714285714283 in each crate's own test build. The root binary's
`improvise import` used the same lossy parser. Now float_roundtrip is on every
normal serde_json dependency in the workspace: root, improvise-command,
improvise-io, and the optional test-support dependency of improvise-protocol.
improvise-dz8c: the hand-listed cargo-tree check over three frame-parsing
crates is replaced by tests/json_numbers.rs. It reads
`cargo metadata --no-deps` and names every package, including target-specific
entries, whose normal serde_json dependency lacks the feature. The rule and why
it beats "parses a frame", the build graph, and enabling the feature once in
the protocol crate are in context/code/protocol.md.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
`run_headless_commands` read `crossterm::terminal::size()`, and the page size
decides where `page-scroll` lands, so one script wrote a different record in a
different window — and CI (no tty, 80x24 default) disagreed with a shell. Pin
it to 80x24 in `HEADLESS_EXTENT`. The test drives two sizes through LINES/
COLUMNS with the child detached by `setsid`, and asserts the same record.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
improvise-ni6k. Twelve effects called `app.reset_cursor()`; that table is
the shape gotcha 24 names as the disease, and it had a hole the day it
was written. The observation replaces it: `view_frames` fingerprints
every view's drawn frame, `apply_effects` diffs it per model effect, and
the cursor resets when the view it is in reframes. No effect declares
anything, and `adopt_cursor_epoch` reads the same set.
FOUR THINGS THE TICKET DID NOT PREDICT, each one a fixture or a design
question rather than a line:
1. The frame fingerprint and `layout_version` answer DIFFERENT questions,
so they are two observations and not one. `AddCategory` of an empty
category moves no row or column but does change the coordinates a
client's CellKey hangs off — layout_version must bump, the cursor need
not. `view_shape`/`last_reshaped_views` (config) is untouched and still
drives `layout_version`; `view_frame`/`last_reframed_views` (drawn) is
new and drives the cursor. Unifying them broke two tests in both
directions, which is how the distinction was found.
2. The observation must run INSIDE the effect loop, not after it. At
end-of-batch it clobbered deliberate cursor placement in the same batch
— `[AddItem(measure), <place cursor>]` sent the cursor home, failing
three records-mode tests. In-loop, a later effect wins, which is the
same ordering property gotcha 45 gives `document_config`.
3. `GridLayout::new` does NOT apply `prune_empty`; `with_frozen_records`
does. A frame hash built on `new` cannot see a pruned row, which is
exactly the class improvise-7kvw is in.
4. Two per-effect no-op guards were carrying the whole weight and are now
orphaned: `effective_items` and `item_is_drawn` are deleted, along with
six inline `moved`/`drawn`/`real`/`existed` predicates.
COST, measured as the ticket required, `benches/reshape_observation.rs`
(new), one SetCell batch, same fixtures as `grid_projection`:
typical 400 cells 172µs -> 206µs (+20%)
large 9.6k cells 3.70ms -> 4.17ms (+13%)
18k cells 7.09ms -> 8.41ms (+19%)
pathological 90k 35.2ms -> 39.8ms (+13%)
The batch is dominated by `rebuild_layout`/`recompute_formulas`, not by
the fingerprint.
WHAT CLOSES AS A CONSEQUENCE: improvise-7kvw, asserted by
`paging_across_a_prune_that_drops_a_row_invalidates_the_cursor`.
AddFormula/RemoveFormula join the shared `reshaping_commands` table (a
formula target is a `_Measure` item, so it is a column — gotcha 7), each
proved reshaping by the table's own `assert_ne!` on the grid shape.
WHAT DOES NOT CLOSE, and this is the honest part: `SortData`. A pure
record reorder changes no label and no count, and records-mode `row_items`
are positional, so a row_items fingerprint cannot see it. I wrote the
mechanism (hashing `layout.records`' coords) and then REMOVED it, because
I could not build a fixture where `SortData` actually reorders anything —
records came back in key order whatever order the cells went in. Shipping
a confident comment over a mechanism I could not demonstrate is this
repo's named defect, so it is reported instead. See the hand-back.
Negative arms unchanged and still passing, including the refused-command
arm and `SetNumberFormat`; disabling the observation fails the whole
reshaping table, which is the proof it is what does the work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
improvise-i5tk's acceptance criterion 1 was a benchmark, on the ground that "this
can dominate TUI rendering" is exactly as unevidenced as "it is fine".
benches/grid_projection.rs is that benchmark, and it says no.
One grid_of call, dense pivot grids, this machine:
typical 100 x 4 = 400 cells whole-grid 1.902ms ratio 1.2x col_widths 45%
large 1200 x 8 = 9600 cells whole-grid 185.644ms ratio 1.3x col_widths 64%
18k cells (gotcha 41) 3000 x 6 = 18000 cells whole-grid 402.435ms ratio 1.5x col_widths 42%
pathological 9000 x 10 = 90000 cells whole-grid 5.235s ratio 1.5x col_widths 44%
ratio is whole-grid over a 45x12 terminal window: 1.2-1.5x, against a fix whose
stated cost is changing what a cache index MEANS in the TUI and putting every
scroll site in scope. Wrong trade, so i5tk is closed on the measurement.
Why the ratio is so low is the bigger finding. Two of the three whole-grid passes
a frame performs are viewport-independent by construction:
ui/viewport.rs::compute_col_widths takes no viewport and walks every (row, col)
twice -- once for cell text, once for the column totals it must also size -- and a
column total must sum every row including scrolled-past ones (gotcha 44).
grid_of's own cell_display loop is the third pass and the only one windowing would
touch. So the whole grid is evaluated about three times per frame, every result
discarded, and at 18,000 cells a keypress costs four tenths of a second. Filed as
improvise-jmjz; its fix is caching or one fused pass, which this benchmark has now
distinguished from the windowing it rules out.
No criterion crate: plain Instant, and test = false so cargo test does not run a
target that takes seconds by design.
Closes improvise-i5tk
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The check gotcha 44's divergence never had. Each front end is driven the way
it is really driven — the widget into a ratatui buffer, the browser client
through a `Snapshot` of that same cache's `seed()` — and they are compared on
what they *draw*, not on what they were handed; comparing the caches would be
vacuous, since it is one cache.
Injected both faults and watched them fail before keeping it:
`labels: vec![rows.item(r).join("/")]` in the client's `render_model`, and the
same join in the widget's row header. Each names the row and both spellings.
`improvise-wasm-client` becomes a dev-dependency of the root crate for this.
It builds natively — that is how its own tests already run — and being a dev
dependency the terminal binary does not grow a wasm client.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
improvise-9x6: the server half of the standalone static-web deployment
(epic improvise-tm6). `improvise-worker-server` hosts the authoritative
`App`, the command pipeline, the projection layer and VFS persistence behind
`self.onmessage`/`self.postMessage`.
It is small because everything it needs already existed: the frames are
`improvise_protocol::frame`'s (improvise-xks6), the session loop is
`Host` (previous commit), the basis check is `Sessions::apply`/`Checked`
(improvise-ondj), and persistence is `Storage` + `OpfsFs` (improvise-6mq,
i34). What is genuinely new is three things.
`vfs.rs` — how a document gets INTO a browser's empty filesystem. Loading is
unchanged: it is `persistence::load_from` on a `Storage`, the same call the
native binary makes. Seeding is the new problem, and it is a
`Storage::write`, so a fetched demo, an upload and a saved document take the
same parse path and a seed never clobbers an edit. `list` is the document
picker a browser has no OS file manager for.
`worker.rs` — the loop: one port, one client, the `Viewport` handshake
(dropped rather than closed, since there is no connection to close), and the
filter from `Addressed`. Every decision is here and every test points at it.
`bindings.rs` — `cfg(wasm32)`, decision-free, and the only part no test here
can reach: it awaits `OpfsFs::open`/`attach`/`refresh`/`commit_deletes` and
forwards strings.
Two gaps found and reported rather than papered over. No client on any
transport can ask its host to save — `effect::Save` is `ViewOnly`, so the
wire vocabulary derived from the model effects cannot name it — and a wire
`ModelCommand::SetCell` never sets `model_state.dirty`, because dirtiness is
raised by a separate `MarkDirty` effect the key path emits. So the worker
persists on its own behalf, keyed off the command variant rather than off a
flag that is never set; `touches_document` carries the argument.
`ClientFrame::Key` is still answered with nothing. A worker CAN call
`reduce_key` — it holds the `App` — but nothing in the projection stream
carries the host's own `ViewState`, so a half-wired arm diverges rather than
under-delivers. `a_key_resolved_on_the_host_moves_state_no_client_can_hear`
demonstrates it against a real `App` and `Sessions` (improvise-bpq2).
22 tests, all host-side: 16 in the crate and 6 in `tests/post_message.rs`,
which drives the worker with nothing but the JSON a `MessagePort` carries.
Bundle: 1,334,892 bytes raw, 496,965 gzipped — 485 KB against a 3 MB budget.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
A new crate `improvise-ws-server`: a testable session loop plus a thin
tokio/tungstenite shell around it.
The ticket predates the session machinery and describes building it by
hand; most of what it asks for now exists, so what this adds is the
transport and the loop (doctrine(check_the_ticket_premise)):
- `Host` is one authoritative `App`, one `Sessions`, and every client
attached to them — the unit of authority is the *document*, not the
connection (the ticket's premise) and not the server. A multi-document
registry is a map of `Host`s and is deliberately not built yet; the
seam is in `transport`, where `serve` takes the document it hosts.
- `handle(id, msg) -> Vec<Addressed>` makes every decision and is tested
without a socket. `Applied::Rebased` sends every client a fresh
`Snapshot`; `Applied::Diff.broadcast` is forwarded to the *other*
clients and never back to the asker.
- The wire adds exactly one thing to the shared vocabulary: the extent a
client can display, which no `Command` carries by construction and
which doubles as the handshake, since the bootstrap `Snapshot` is
built at it.
No auth, no TLS, loopback only: a stated MVP limit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
A new crate `improvise-wasm-client`: a `ViewState` plus one widget cache
each, moved by folding the same `Command` stream the host folds, with a
wasm-bindgen surface over JSON.
The ticket's premise does not survive contact with the architecture
(`doctrine(check_the_ticket_premise)`). It asks `on_key_event` to
"resolve via the keymap and dispatch through reduce_view", but a binding
names an *intent* resolved against a `CmdContext` — a `Model`, a
`GridLayout`, an `App` — while a `Command` is the *resolved mutation*
(gotcha 38, improvise-gxi). A thin client holds none of that, so keys
travel upstream unresolved and `improvise-command` is a dev-dependency
rather than a dependency: the keymap could not be used here even if the
bundle could afford `App`, `Workbook`, persistence and the wizard.
The one exception is minibuffer text entry, whose target buffer is
`AppMode::minibuffer().buffer_key` — the same field the host's
`TextEntry` builder reads (gotcha 11), so the local rule is derived from
the same fact rather than copied. `tests/lockstep.rs` drives the
authoritative host and the client over the same keypress in all seven
text-entry modes and asserts the two `ViewState`s agree.
Rejected for local resolution, with reasons in `keys.rs`: cursor
movement (`GridCache` is viewport-indexed, so a client cannot clamp),
panel cursors (the flattening depends on `expanded_cats`), anything
modified (`AnyChar` matches under any modifier on the host), and
CommandMode's Backspace (the one `TextEntry::rebind`, which leaves the
mode).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
improvise-izk. New crate `improvise-gp` — the seam between gp-tree and the
formula language. It cannot live in gp-tree (that inverts the dependency) and
it cannot live in improvise-formula (which must not know GP exists).
`formula_text(tree, spec, target)` turns Split{feat,thresh,lo,hi} into
`IF(<name> < <thresh>, <lo>, <hi>)` and a Leaf into its number;
`fitted_formula` hands back the parsed Formula, so the parser is the emitter's
own contract check.
Two things the emitter owes its caller:
* Names round-trip. Feature names come from user data — spaces, operators,
pipes, keyword collisions, the one-hot encoder's own `col=value` labels —
so every name goes through CL-style pipe quoting, and a keyword is quoted
even though it would parse bare.
* Thresholds route identically. A threshold is emitted as the f32's *exact*
decimal, not its shortest round-trip form. `0.7f32` is 0.699999988…, and
"0.7" parses as an f64 strictly greater than it, so a row whose feature
value equals the threshold would fall on the other side of the split in
formula evaluation than it does in `gp_tree::eval`. There is a test for
that boundary, and it fails against the naive rendering.
Tests: parse round-trip for leaf/nested/awkward-name/awkward-target cases, the
threshold-boundary regression, and the acceptance property — over random trees
and random feature vectors, `Model::evaluate` of the emitted formula equals
`gp_tree::eval`, exactly (f32→f64 is lossless, so no tolerance is needed).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
improvise-v9w: the root `improvise` crate's [dependencies] listed pest,
pest_derive and dirs, none of which appear anywhere under src/. pest and
pest_derive remain genuine dependencies of improvise-formula and
improvise-io (persistence/improv.pest); examples/gen-grammar.rs reaches
pest_meta through [dev-dependencies], which examples do get, so the
examples still build. Verified with cargo build --workspace, cargo build
--examples, cargo test --workspace and the wasm check. Cargo.lock loses
only dirs and its transitive deps.
improvise-wg1: add the GNU Global tag files (GTAGS/GRTAGS/GPATH/GSYMS) and
the root graphviz artifacts (/graph.dot, /graph.html, /graph.svg) to
.gitignore. Named exactly rather than globbed so no checked-in asset is
swallowed; `git ls-files | git check-ignore --stdin` prints nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
The crate the browser epic hangs off: Command (the wire format),
ViewState, RenderCache/CellDisplay/Snapshot, and reduce_view. It depends
on improvise-core and serde and nothing else -- no IO, no command layer,
no terminal -- and improvise-command now depends on it.
ViewState, ViewCursor, ViewFrame, AppMode, Panel, MinibufferConfig,
MinibufColor, HELP_PAGE_COUNT and the key types move here. Everything
that moved is re-exported from ui::app and command::key, so no existing
path changed. The three tickets before this one are what made the move
mechanical: h0b evicted the unportable fields, a2x added the derives,
8uz took the cursor out of the shared View.
Command's client->server half is not invented. It is exactly the set of
EffectKind::ViewOnly effects whose target is the portable slice -- h0b
had already sorted the portable state from the host-local, so the
enumeration was done. Effects touching the wizard, staged drill edits,
storage or the layout cache are absent by construction. The other half
carries projections: already-formatted display values and their labels.
One thing had to be added rather than moved. reduce_view runs on a client
that holds no Workbook, and ViewState::cursors is keyed by view name, so
ViewState needs its own active_view. That makes two copies of "which view
is active"; App::switch_to_view and App::install_workbook are the only
writers, and the_two_active_view_names_never_diverge guards it. The 8uz
characterization test caught the desync the moment it appeared, because
it was calling workbook.switch_view directly.
Whether the two are *allowed* to diverge -- two clients on different
views -- is improvise-g0f, deliberately left open. It changes the shape
of improvise-cqq, and nothing here forecloses either answer.
reduce_view has 12 tests including a determinism check: folding the same
stream twice gives the same state, which is what stops the server's
mirror drifting from the client it mirrors. Every Command round-trips
through JSON. The crate is in the CI wasm job.
Closes improvise-cqi
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V69PGQfMJuGRmtSWpfVAqn
Command layer + semantic App state move below the TUI: command/ (cmd,
keymap, parse, key), ui/{app,effect,cat_tree}.rs, and the pure viewport
helpers now live in crates/improvise-command, which depends on core +
formula + io and provably not on ratatui/crossterm (cargo tree clean).
The root crate remains the TUI binary and re-exports improvise_command
under the old crate::command / crate::ui::{app,effect,cat_tree} paths,
so widgets, draw.rs, and main.rs are unchanged. App moves whole (per
the improvise-ltq design note) rather than splitting an AppState out —
its fields are all command-safe after the key/color decoupling.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lay groundwork for Phase 3 of the workspace split (improvise-8zh): a new
`crates/improvise-io/` sub-crate that will house the persistence and
import layers once the files move in the next commit.
Only scaffolding here:
- New `crates/improvise-io/Cargo.toml` declares deps on improvise-core,
improvise-formula, and the external crates persistence+import already
use: anyhow, chrono, csv, flate2, indexmap, pest, pest_derive, serde,
serde_json. Dev-deps: pest_meta, proptest, tempfile.
- New `crates/improvise-io/src/lib.rs` re-exports the core modules under
their conventional names (`format`, `model`, `view`, `workbook`,
`formula`) so in the next commit the moved code's `crate::model::*`,
`crate::view::*`, `crate::workbook::*`, `crate::format::*`, and
`crate::formula::*` paths resolve unchanged.
- Root `Cargo.toml` adds the new crate to workspace members and the main
crate's `[dependencies]`, ready to receive the move.
No source files change yet; cargo check --workspace still compiles as
before.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Lay the groundwork for Phase B of improvise-36h: a new
`crates/improvise-core/` sub-crate that will house the pure-data core
(model, view, workbook, format) once the files move in the next commit.
Only scaffolding here:
- New `crates/improvise-core/Cargo.toml` mirroring improvise-formula's
structure; declares deps on improvise-formula, anyhow, indexmap, serde.
- New `crates/improvise-core/src/lib.rs` with the single re-export
`pub use improvise_formula as formula;` so that in the next commit the
moved code's `crate::formula::*` paths resolve unchanged.
- Root `Cargo.toml` adds the new crate to the workspace members and the
main crate's `[dependencies]`, ready to receive the move.
No source files change yet; cargo check --workspace still compiles as
before.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract the formula AST and parser into a dedicated `improvise-formula`
crate and convert the project into a Cargo workspace.
The root crate now re-exports `improvise-formula` as `crate::formula` to
maintain backward compatibility for internal callers. The repository map is
updated to reflect the new crate structure.
Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
- gen is a reserved keyword (→ renamed module to generator)
- ref in patterns with implicit borrowing (→ removed ref)
- & pattern with implicit borrowing (→ added & to outer pattern)
Add repository, homepage, readme, keywords, and categories fields.
Update description to match project vision. Use GitHub URLs for
public discoverability.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace manual dynamic dispatch using Box <dyn Runnable> with enum_dispatch
for improved performance and cleaner code.
Add a new Open command to allow opening the TUI as the default behavior
when no subcommand is provided.
Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
Add a new [profile.profiling] section to Cargo.toml.
This profile inherits from release but with:
- strip = false: Keep debug symbols for profiling
- debug = 2: Full debug information for analysis
Useful for generating profiling data with symbol information.
Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
- Use csv crate for robust CSV parsing (handles quoted fields, empty values, \r\n)
- Extend --import command to auto-detect format by file extension (.csv or .json)
- Reuse existing ImportPipeline and analyzer for field type detection
- Categories detected automatically (string fields), measures for numeric fields
- Updated help text and welcome screen to mention CSV support
All 201 tests pass.