220944e26c69c6066a68d33ecd206efeb58f1784
207 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3d0850c132 |
Merge main (d7bfcbb, PR #96) into wave/operandless-commands
Two textual conflicts, and one the merge resolved cleanly and wrongly. effect.rs: #96 extracted the LayoutGeometry list into layout_geometry_cases() shared by two tests, one of which compares it against the wire vocabulary; my side had added ViewTarget arguments and carried the base's assert_eq!(cases.len(), 13). Kept #96's structure with my arguments and dropped the count -- the list-versus-vocabulary check is strictly stronger and a count beside it would be a second answer to the same question. Verified the count came from the merge base, not from my side, before dropping it. test-evidence/command.md: both sides appended entries at the same point. Kept both. app.rs auto-merged CLEANLY and did not compile: improvise-4dpj's new a_batch_that_leaves_records_settles_out_of_records_normal constructs effect::SetAxis, which now carries a view operand. Fixed with ViewTarget::Active -- the host's own key path, which is what that test is. Checked rather than assumed, per the brief: - Test names diffed BOTH ways against the union of the two parents, not the count. Union 1510, merged 1509, exactly one name absent and it is the intended one: a_hosts_none_cats_leak_into_another_views_formula_columns, the ac2z characterization test my own branch replaced with a_formulas_columns_survive_a_host_that_aggregates_differently. Nothing else lost, nothing unexpected gained. - #96's the_enumeration_names_every_variant_the_derive_declares is still discriminating against the reshaped ModelCommand variants: dropping TransposeAxes from every_model_command() fails it by name. - The test-support feature stays off in shipped builds: cargo tree --edges normal --target wasm32-unknown-unknown finds no serde_json. And the finding the brief asked for, which is the opposite of a disagreement: #96's switching_views_across_records_ness_settles_against_ the_view_switched_to is the HOST-side twin of the client fix in this branch. Same batch, same crossing in both directions, and independently the same phrase in its message. Neither is redundant -- nothing in the projection stream carries the host's ViewState, so a host-side guard cannot see a client's mode or the reverse. Cross-referenced in context/test-evidence/clients.md so neither is deleted as a duplicate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
81fe546594 |
Settle the mode by observing the grid, not at each composition site
ChatGPT Codex's P1 on #98: switch_view_at composes [SwitchView(target), ChangeMode(Normal)], and changed_mode settled that against the grid the client STILL HOLDS -- the outgoing view's. Records -> pivot emitted RecordsNormal and pivot -> records emitted Normal, which is the cross-view drift this PR exists to close, by a second door. The hand-back predicted this as follow-up 3 and called it unreachable because R is a stranding decline in the browser. That is right about the TOGGLE route and wrong about this one: records-ness is auto-detected from the axes (gotcha 1), so a view switch changes it with nothing in the batch saying "records". One door was enumerated and there were two. So the remedy is general rather than a patch to switch_view_at. changed_mode is DELETED; every site emits the plain AppMode::Normal; and ClientState::fold settles after every fold against the grid it HOLDS. Route-independent, so no enumeration of composition sites is load-bearing any more -- the same shape the repo already prefers in cursor_epoch over matching on CycleAxis, document_generation over matching on LoadModel, and the dirty flag over a table of command variants. improvise_protocol::settled_mode is the one rule and all three ends call it: ClientState::fold, ClientSession::fold (the server's mirror, so the two cannot diverge in a field nobody currently reads today) and App::settle_normal_mode. App::normal_mode is gone -- it was that function's host-side duplicate, and my change orphaned it. Bidirectional coverage drives a real Host: a session connected to a records-mode host, the pair folded locally and sent as a ClientFrame::Commands, every reply delivered in arrival order. The composed ChangeMode carries the STALE answer deliberately, because composing the friendly value would let the records -> pivot direction pass under the fault. Both directions fail with settle_mode no-opped, each proved separately since the loop stops at the first. Evidence, the injections and why the first fix's unreachability argument was too narrow: context/test-evidence/clients.md, context/code/protocol.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
498582deb1 |
Bound every_model_command() with the derive (improvise-26dm, Codex P1)
The right-hand side of the LayoutGeometry completeness diff was a second hand-written list. Both this branch's own review and ChatGPT Codex found it, and the previous commit corrected the PROSE; Codex asked for the FIX. This is it. the_enumeration_names_every_variant_the_derive_declares diffs every_model_command() against variants::declared::<ModelCommand>() -- serde's derive-generated variant list, improvise-5ubs's mechanism. The chain is now derive -> every_model_command() -> model_effects -> case list, every link checked, which is what the ticket asked for. Injection is Codex's own, run end to end: a ModelCommand::ProbeGeometry whose effect declares LayoutGeometry, with the model_effects and variant_name arms the compiler forces, omitted from BOTH lists. Before: the whole workspace green. After: staged, each stage naming the probe -- every_model_command() never constructs ["ProbeGeometry"] then, with the enumeration repaired and only the case list short, the_layout_geometry_cases_are_every_wire_effect_of_that_kind right: [... "ProbeGeometry" ...] The staging is the point: the two tests answer different questions. SCOPE OVERRUN, declared. `variants` was `#[cfg(test)] pub(crate)`, not merely `pub(crate)`, and `declared()` needs serde_json, which improvise-protocol carries only as a dev-dependency -- so the authorised one-token change could not work. What it took instead, all plumbing, no behaviour change: improvise-protocol/Cargo.toml serde_json optional + a `test-support` feature improvise-protocol/src/lib.rs #[cfg(any(test, feature = "test-support"))] pub improvise-command/Cargo.toml dev-dep enables that feature The feature is OFF in every shipped build: `cargo tree --edges normal --target wasm32-unknown-unknown` shows no serde_json, and all eight wasm-gated crates still check clean. Drop this commit if the crossing is unwanted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
ab6bb41da3 |
Review of this branch: two checks that could not fail, one drift left live
changed_mode settled only Normal -> RecordsNormal, but the host's
settle_normal_mode settles BOTH members of the pair, so a RecordsNormal
over an ordinary grid stayed put on the client and became Normal on the
host. Esc out of RecordsEditing resolves through entered() to
`enter-mode records-normal`, so the mirror-image drift was reachable by
the same route the fixed one was. Now total, with the pass-through arm
and both negative rows asserted.
a_compacted_seed_folds_to_the_same_cache_as_the_whole_history left
GridCache::records at its default, so a seed() that OMITTED the arm
compacted to an equal cache and the check could not see the omission.
The history now sets it; deleting the arm from seed() fails the test.
grid_updates' records arm had no test at all: grid_of setting the field
is covered by lockstep, but a client that connects before records mode is
entered learns only through the diff.
Evidence for all three, with the injected faults:
context/test-evidence/{clients,command}.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
|
||
|
|
f6fca7ec98 |
A formula is evaluated for the view being projected (improvise-ac2z)
rebuild_layout recomputes formulas for the HOST's none_cats, and grid_in's elsewhere path built another view's GridLayout against that cache. The cache is keyed by the coordinates the recompute used, so a Total keyed without a Product coordinate is not found by a view with Product on Column, and prune_empty drops the formula's columns entirely. Absent columns, not wrong numbers. grid_in::rederived_for clones the model and recomputes for the target view's none_cats. Three conditions gate the clone -- another view, different none_cats, at least one formula -- so a client on the host's own view pays nothing and a formula-free document pays nothing ever. This is an interim and the cost is why: a whole-model clone plus a fixed-point recompute per projection on exactly the broken path. The ticket's first candidate, keying formula_cache by none_cats so one Model holds more than one view's derived values, is the durable answer and is an improvise-core change outside this wave's ground. The characterization test is replaced by the positive one, and the none_cats carve-out is dropped from a_clients_grid_does_not_depend_on_which_view_its_host_is_on -- which is the general property this is an instance of, and which fails under the same injection. Evidence: context/test-evidence/command.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
618801701e |
The browser asks the grid what kind of grid it is (improvise-e5pf)
One substitution at three decision points: wherever the host asks layout.is_records_mode(), resolve.rs asked state.view.mode.is_records(). The two agree only while the mode and the layout agree, and Command::ChangeMode(AppMode::Normal) is the message that separates them -- the host settles Normal against the layout the batch ends on, reduce_view folds the variant it was handed. GridCache::records carries the fact, projected by grid_of off layout.is_records_mode() and diffed like the extent. Deriving it on the client from row_frame.cats would have been a second implementation of is_records_mode (improvise-edp's disease); deriving it from the mode is the bug. Like Extent it frames nothing, so apply does not re-fit and its batch position is not load-bearing. The client's settling has one home, resolve::changed_mode, reached by every site that can emit AppMode::Normal -- including entered(), which is how a bound `enter-mode normal` gets there. The three decision points ask state.grid.records, so a commit at a records cell declines rather than composing typed_cell_command against a grid whose columns are [coords.., +cat, measures.., +]. lockstep now DRIVES the keys: in_lockstep presses on both ends and refreshes the client from a real projection rather than from the host's slice, which is what let the old records coverage start from agreement. The reachable path is Tab (category panel) then Esc; c/f/v are Ignored in RecordsNormal and R is a stranding decline, which a first draft of the test found the hard way. Two resolve.rs fixtures put a records mode over a non-records grid -- a state no host can produce -- and had to be corrected with the fix. Evidence and both injected faults: context/test-evidence/clients.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
7bc26f80d5 |
Review round 1: three checks that did not check what they named
All three found by the adversarial review of
|
||
|
|
1dd6950ed3 |
A view-scoped ModelCommand names its view (improvise-lltt)
Nine ModelCommand variants configure a view and named it implicitly: the effect read App::view_mut(), the HOST's slice. Since improvise-jytm made ViewState::active_view authoritative per client, a client on another view moved the host's view instead of its own -- its grid did not change and every other client's did. Each of the nine now carries `view: String`; model_effects maps it to effect::ViewTarget::Named, and the nine effects carry a ViewTarget rather than reading an ambient one. There is no operandless form left: App::view_mut() is deleted and replaced by view_mut_of(&ViewTarget), so a future geometry effect has to say which view it writes. The host's key path passes ViewTarget::Active, which is right -- the TUI is the host and has no session to ask. A Named view the document has not got is a no-op rather than resolve_view's fallback onto the default. Second half, which the ticket did not predict: naming the view made a reframe of a view the host is NOT on reachable, and adopt_cursor_epoch could not see one -- cursor_epoch is bumped only by the host's own reset_cursor, so those clients were reset by nobody and told nothing (gotcha 24's strictly-worse-than-nothing state). App::last_reframed_elsewhere is the second trigger; its opening frames are taken lazily inside view_mut_of, the one door such a write goes through, so the held-key path still hashes one view and improvise-mcq4's declined cost is not paid. Evidence and the three injected faults: context/test-evidence/command.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
019cbc1f72 |
Check the LayoutGeometry case list for completeness (improvise-26dm)
assert_eq!(cases.len(), 13) catches a case being DELETED from the list and never one being ABSENT from it, and the two are written identically as an assert_eq over a number. The adversarial review of PR #78 added a fourteenth pub struct declaring LayoutGeometry, pointed at by nothing, and the suite stayed green. doctrine(a_list_checked_for_length_reads_like_a_list_checked_ for_completeness). improvise-5ubs's mechanism cannot reach this one -- an effect is a struct implementing a trait, so serde has no derived enumeration to diff against. The WIRE vocabulary does have one, and model_effects maps each ModelCommand variant back to the effect it came from (gotcha 38), so the list is now diffed against the LayoutGeometry effects reachable through ModelCommand. The two sets are equal today, which is the evidence the substitution lost nothing. Injection: drop TogglePruneEmpty -- the old count said 12 != 13, this names the effect. reduce.rs's test module, every_model_command and effect_type_name become pub(crate) so the enumeration and the name reader have ONE home rather than a second copy in effect.rs. Residual, stated in the evidence rather than closed: total over the wire, not over the effects. An effect no ModelCommand names is still invisible -- exactly PR #78's probe. A second [...; 13] array survives one function away in every_layout_geometry_effect_is_visible_to_the_narrow_fingerprint; filed, not smuggled in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
78ca25b5e0 |
Test both arms of the records-mode settle (improvise-4dpj)
settle_normal_mode matches `Normal | RecordsNormal`, and replacing that with a
one-way `Normal` left the ENTIRE workspace suite passing: the existing crossing
test drives the layout both ways, but both branches end in `enter-mode normal`,
so the settle is handed `AppMode::Normal` either way. It discriminates "settles
at all" and "settles against the ending layout", never "settles from either
mode".
Gap 1, a_batch_that_leaves_records_settles_out_of_records_normal: the
production repro -- SetAxis { "_Index", Axis::None } from a records grid.
Injection is the one-way guard; it is the only test in the workspace that fails.
Gap 2, switching_views_across_records_ness_settles_against_the_view_switched_to
(raised by ChatGPT Codex on PR #91, verified correct on main first): the batch
SwitchViewAtCursor emits, both ways. A genuinely different path -- records-ness
is auto-detected from the axes, so nothing in the batch says "records".
Injection is moving the settle back above rebuild_layout; both fail by name.
Scope note in the evidence: this drives the effects, not the view-panel key
path, which needs a CmdContext in another wave's ground this round.
Also carries the command.md half of improvise-hsoe's three duplicate headings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
|
||
|
|
d41c8b8e6f |
Merge main (1a9f45a) into wave/protocol-render-gaps
#91 and #93 landed while this wave was in flight and appended to the same four context/ files. That conflict is why CI never ran: a pull_request workflow builds refs/pull/N/merge, and a CONFLICTING PR has no merge ref, so no run is ever created. Neither the close/reopen nor the empty commit could have helped. Resolution: keep both sides. Only context/test-evidence/protocol.md conflicted textually; the other three auto-merged. Verified with --no-ext-diff that nothing of this branch was dropped -- the only removals are main's own rewrites, of the frame-basis paragraph in context/code/command.md and of the minibuffer-exit entry (renamed _to_plain_normal -> _to_records_normal) in test-evidence/command.md. NOTE the first three "nothing was dropped" checks were WORTHLESS and read as clean: an external diff tool is configured, so `git diff | grep '^-'` was grepping a side-by-side format for unified-diff markers and matched nothing whatever the content. --no-ext-diff is what actually answers the question. A check that returns empty for the wrong reason is indistinguishable from one that passes. Fixed while re-reading, and pre-dating the merge: this branch had TWO entries headed a_window_narrows_the_cells_and_neither_the_totals_nor_the_widths -- the original, describing a fixture that no longer exists and repeating the claim the PR #92 review measured false, and the superseding one. Merged into a single entry whose subject is what each of the three fixtures was blind to. The evidence checker cannot see this: it verifies that a heading names a real test, and a duplicate heading names one just as well. Merging gh/main ( |
||
|
|
902a77c174 |
review: two corrections from the #92 adversarial review, and a P2 they found
1. THE JMJZ WIDTH ASSERTION COULD NOT SEE A WINDOWED WIDTH, and the evidence
said it could. Injecting `if drawn { widen(..) }` -- widths from the window only
-- left 1476 passing, 0 failing. Two reasons, the second structural: the measure
was named `Revenue` (7 chars) against a value of `300021` (6), so the assertion
passed on the header alone; and comparing grid_body's widths against
compute_col_widths is INCAPABLE of testing windowing, because compute_col_widths
IS grid_body with an empty window. Windowing degrades both sides identically.
The same function twice is not a check, and it reads exactly like one.
Fixture is now the reviewer's shape -- a total that cancels and a one-character
header -- reordered once more so BOTH assertions discriminate: with the large
values last, the windowed row and the cancelled total were both 1.0, so the
totals assertion went blind to the row-window fault while the widths assertion
happened to catch it. Large values first:
for ri in rows.clone() -> totals [888888888.0] against [1.0]
if drawn { widen(..) } -> widths [10] against [11]
10 rather than 5 is a smaller gap and a better witness: the missing character is
the minus sign of a row outside the window.
2. THE T6DB none_cats PARAGRAPH REPORTED A NEGATIVE RESULT FROM A SETUP THAT
LACKED THE CONDITION -- and correcting it found a P2. The fixture was said to put
Product on Axis::None in one view and on Column in another. It did not:
add_category defaults to Column and nothing moved it, so all three views had
none_cats {_Index, _Dim} and the stated input to the hazard was absent.
Setting it explicitly failed the property on the first run, and not on a cell
value: Second loses BOTH Total columns when the host stands on Default.
rebuild_layout fills Model::formula_cache for the HOST's view, and grid_in on the
elsewhere path builds another view's layout against that stale cache -- the Total
cells are keyed without a Product coordinate, the other view asks for one, finds
nothing, and prune_empty drops the columns. Filed improvise-ac2z (P2). Not local:
grid_in takes &App and recompute_formulas needs &mut Model.
Guarded by a_hosts_none_cats_leak_into_another_views_formula_columns, a
characterization test whose message says that failing means the bug is fixed. The
property skips pairs whose none_cats differ and asserts compared > 0, so a future
fixture cannot skip everything and pass.
3. Smaller, all taken. "Nothing else fails" on the t6db injection was wrong: it
fails three tests, including the pre-existing prune-invalidation one -- the #78
fix had more coverage than it was credited with. And the 5ubs should_panic named
one thing and exercised another: pointed at a struct it reached only the
split_once branch, and all three panics shared one literal so `expected` could not
discriminate. The parse is now a `parse_expected` seam fed hand-written mutated
messages, each refusal with its own wording, plus a happy path.
1470 -> 1475 passing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
|
||
|
|
a10e95eb5f |
fix(mode): settle Normal/RecordsNormal once, after the rebuild (review of #91)
Two blockers from the adversarial review. BLOCKER 2, a new wrong state this PR introduced: resolving `AppMode::Normal` per-effect reads the batch's OPENING layout (gotcha 23), so a batch that CROSSES — `:toggle-records-mode . enter-mode normal` — settled against the grid it started on and left `RecordsNormal` over a pivot grid. Base was correct there. The fix moves the resolution to one place, `App::settle_normal_mode` called after `rebuild_layout`, where there is no crossing case to get wrong. That is strictly simpler than what it replaces: `ChangeMode` is an ordinary `simple_effect!` again, `ReturnToNormalUnlessMoved` and `CloseWizard` write plain `Normal`, and the per-edit coverage question the review filed as finding 3 is gone because there is one edit. Removing its call site fails exactly three tests, workspace-wide. And the prose defended the staleness with an example that does not bear the weight: `:toggle-records-mode` works, but because `ToggleRecordsMode` and `ViewBack` overwrite the answer in both directions, so it exercises nothing. Replaced by a test that drives the crossing both ways. BLOCKER 1, a false claim in `context/`: "keys.rs emits no ChangeMode" is true and irrelevant — keys.rs holds only `BrowserKey`. The live browser key resolver is `improvise_protocol::resolve`, which emits `Command::ChangeMode(Normal)` in five places, and `reduce.rs` folds it with no resolution. The browser front end still has the sihr bug; it is LIVE, not latent, and it compounds because the client's only records-ness fact is the mode it just lost. Named honestly, with improvise-e5pf cited. Still not closeable here — a client holds a `GridCache`, not a `GridLayout`. Also the two corrections: the thpe test's doc comment no longer claims a discrimination it does not have, and the 2ud deferral notes that `registry.rs`'s `visible_cols: 8` is production, not a test helper. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
822547a332 |
perf(project): one walk of the grid instead of three (improvise-jmjz)
grid_of evaluated the whole grid roughly three times per frame: compute_col_widths walked every cell for its text, then every cell again for the column totals it must size, and grid_of then evaluated the window a third time. Every result was discarded at the end of the frame. ui/viewport.rs::grid_body is now the one walk. It evaluates each cell once and derives all three answers from that value -- widen the column, add to the column's sum, and if the position is inside the requested window keep the CellValue for cell_display to dress. compute_col_widths survives as grid_body with an empty window, because the keypress path wants widths and no cells, and two width rules would be the disease improvise-edp names. MEASURED, orchestrator's machine, cargo bench --bench grid_projection, before and after: typical (400 cells) 1.840ms -> 0.687ms large (9,600) 172.6ms -> 50.0ms 18k (gotcha 41) 294.4ms -> 95.2ms pathological (90,000) 4.481s -> 1.320s About 3x, which is the number of passes removed -- the shape the acceptance criterion asked for rather than a speedup argued from the structure. Three things the ticket did not predict. The benchmark's col_widths column now measures something ELSE. It read 45-47% and now reads 64-82%, which looks like a regression and is the opposite: compute_col_widths IS the fused walk, so its share rose while the frame fell by two thirds. A ratio whose denominator moved is not comparable across the change, and the benchmark now says so. The windowing ratio did not improve, and that is correct: the pass that remains is exactly the one a viewport cannot narrow. improvise-i5tk's decline stands and the two findings are now cleanly separated. It is not solved, only three times cheaper -- 18k cells is still 95ms, 5.7 frames. The next lever is caching ACROSS frames. Guard: a_window_narrows_the_cells_and_neither_the_totals_nor_the_widths. Fusing three consumers into one walk creates a hazard the separate passes did not have, and the natural "optimisation" (iterate rows rather than 0..row_count) makes a column total the sum of the visible rows, silently. Watched fail with left: [1.0] against right: [300021.0]. All 1469 pre-existing tests unchanged, which is also the evidence the outputs did not move. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
877d3e193b |
refactor(help): a chord is recognised by its modifiers, not its spelling (improvise-thpe)
`compact` tested whether a label it had just formatted began with "Ctrl-" or "Alt-". No false positive today — the reviewer enumerated every `format_key_label` output — so this is the shape, not the behaviour. `Hint::keys` is now `(KeyCode, KeyModifiers)` and `compact` takes the pairs, so the string is not in scope to parse; `Hint::labels` and the help pages format last through the one spelling in `keymap::key_label`. The regression guard is the signature, as it is for `Checked`. `Keymap::labels_for` — the "throw the modifiers away" step the ticket names — has no caller left and is deleted: a consolidation that keeps its old path keeps the defect available. The rule also got wider and total over the bitset: a chord is any modifier beyond shift. Injected a temporary `KeyModifiers::SUPER` spelled `Super-x` and ran it — the old filter keeps it, `compact` drops it. Binding dump byte-identical across the change (4406 lines). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
8e9842b89f |
fix(cursor): the frame basis follows active_view, not the next geometry effect (improvise-9c9o)
PR #78 round 2's `was == self.view_name()` guard fixed a spurious invalidation and introduced its mirror: the first geometry effect after a mid-batch `SwitchView` skipped the check entirely, because there was no basis for the new view yet, so the code re-based and skipped. Under-reporting, which gotcha 35 says is the expensive side — a stale cursor has nothing in the stream to correct it. The re-base now happens at the switch, in the loop, as soon as the name moves, so by the time the geometry arm runs the name and the basis agree by construction and the guard is gone as unreachable. `SwitchView` stays `ViewOnly`: the basis is `App`'s own bookkeeping and none of it is on the wire, so this is not the protocol change the ticket feared. Also deletes a fixture line in the sibling test that was a complete no-op — `views["Second"].clone_into(&mut views["Default"].clone())` clones into a temporary that is dropped — and replaces it with the assertion it was pretending to be. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
6782e8555c |
fix(mode): a minibuffer opened over a records grid returns to it (improvise-sihr)
`AppMode::Normal` is now resolved through `App::normal_mode()` at the moment the mode is set, so no keymap binding and no commit command carries an exit mode. Two readers, one function. The ticket proposed carrying the entry mode on `MinibufferConfig`; declined, with the reason in context/test-evidence/command.md — carried generically it also returns `CategoryAdd` to `CategoryPanel`, and carried for three modes it is the one-arm shape the ticket itself argues against. The binding table is untouched, so `doctrine(binding_dump_diff)` has nothing to diff: what was lost was the runtime mode, not a binding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
20e9bd4106 |
test(project): pin host-view independence for grid_in (improvise-t6db)
PR #78 fixed grid_in to build a non-active view with with_frozen_records rather than GridLayout::new, which skips prune_empty, and landed with no test of its own. This is the general property: which view the host stands on is not an input to any client's grid. The fixture is the load-bearing part. The reviewer's first formulation made the two views differ by turning prune_empty OFF in the second, which passed on the buggy tree -- with nothing to prune, both constructors agree. So the fixture gives one region no cell at all, leaves all three views pruning, and asserts every view really is two rows before comparing anything. Widened while writing it: an aggregate formula, so the views also differ in none_cats, which grid_in likewise does not rebuild for the elsewhere path. That asymmetry is argued and NOT observed -- see context/test-evidence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
d9cb16c397 |
Review round 2: the narrowing bought its speed with a correctness hole
Merge of `gh/main` at
|
||
|
|
bcccfdef6a |
Harden the fixture that made improvise-mcq4 visible, after merging #81
Merge of `gh/main` at
|
||
|
|
ec236da7c5 | Merge remote-tracking branch 'gh/main' into wave3/protocol-session | ||
|
|
313e02f522 |
Merge #79, and fix improvise-mcq4: the frame observation costs one build, not four
Merge of `gh/main` at
|
||
|
|
0947fd0c6c |
Merge pull request #81 from fiddlerwoaroof/wave/core-formula-io
core/formula/io: a duplicate-section round trip, the WHERE word boundary, and a parameter that promised a filter |
||
|
|
a808c3d7fd |
Merge #76: one cursor mechanism, not two, and the cost that exposes
`main` moved to #76 (improvise-748f, improvise-7kvw) while this branch was in
review. #76 approaches cursor invalidation from the per-effect side; this
branch (improvise-ni6k) replaced that table with an observation. The merge is
the interesting part.
TWO CONFLICTS, both resolved keeping both sides:
- `document_content` (#76's new O(cells) hash for the CellData arm) lands
beside `document_config`, which stays `pub(crate)` for improvise-1xf4's test.
- Three pre-loop bases now: #76's `content_basis` (writes_cells) and
`config_basis` (moves_geometry, their narrower gate), plus this branch's
`frame_basis`. Mine stays on `touches_model` and the comment says why: a
`CellData` write under `prune_empty` reframes.
THE ONE THAT DID NOT CONFLICT IS THE ONE THAT MATTERED. `effect.rs` auto-merged
clean and left #76's new per-effect `SetPageSelection` reset_cursor standing
beside the observation — two live mechanisms, 1435 tests green, nothing failing.
That is the failure mode, not a merge error. The stray is removed; one mechanism
remains, and `reset_cursor` now has exactly one caller in the crate.
#76's OWN TESTS ARE NOW THE GUARD, which is the outcome to want:
`a_page_flip_under_prune_empty_invalidates_the_cursor` and its negative twin
both pass against the observation with the per-effect call deleted, and
disabling the observation fails the positive arm while the negative one still
passes. So the observation subsumes the per-effect fix rather than merely
coexisting with it.
A MEASURED REGRESSION, FILED AS improvise-mcq4, NOT HIDDEN. 748f exists to keep
a no-cell batch off an O(cells) walk. `view_frame` builds through
`with_frozen_records`, which applies `prune_empty`, which walks cells. One
`CycleAxis` batch with pruning on, fingerprint off vs on: 3.0ms -> 12.4ms at
18,000 cells and 28.5ms -> 91.7ms at 90,000 — 3-4x, crossing the 16.7ms budget
where the tree before it sat at 3.0ms.
It was nearly missed twice, both times by a fixture that could not reach the
expensive branch: the SetCell bench shows +2-5% because #76's own walk dominates
that arm, and the geometry bench, written first against the existing fixture,
had `prune_empty` OFF and read 17-176us. `benches/reshape_observation.rs` now
carries the held-key case with pruning ON, and context/code/command.md carries
the argument.
Test function names diffed BY NAME across the merge, whole workspace including
harness/: nothing lost from either side. The seven present at
|
||
|
|
158beb3bc9 |
test: read the view the effect writes, not the document's default
Self-review finding on the assertion the previous commit moved. `selecting_the_page_already_selected_invalidates_no_cursor` read its page precondition off `Workbook::active_view()` — the document's *default* view — while `SetPageSelection` writes through `App::view_mut()`, this client's view. Gotcha 29 makes those two deliberately free to differ, so an assertion about what an effect did was reading a view that effect need not have touched. Latent, not live: the fixture is single-client and the two agree, and the Q2 injection fails identically either way. But the sibling `prune_empty` guard added alongside it already reads `App::view()`, and one test reading one view through two accessors is the drift this repo names as the disease. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
b78299036c |
test: close two guard holes and correct an evidence claim measurement contradicts
Three findings from adversarial review of PR #76, after it merged. None is a shipped-behaviour defect; the arguments live in context/test-evidence/command.md. 1. The entry for every_layout_geometry_effect_is_visible_to_the_narrow_fingerprint said the `dirty` clear after `prepare` is what stops the preparation satisfying the assertion. It is not: `dirty` is raised only inside `App::apply_effects`, and no `prepare` in that test runs an effect. Removing the clear and blinding `document_config` to 0 together leaves all thirteen cases reading false. The clear stays as hygiene for a future `prepare`; what makes a case honest is the no-op-fails property — and measurement shows only two of the four `prepare` steps are load-bearing in that way. `ToggleGroup` toggles collapse unconditionally (membership gates only `reset_cursor`), and `TransposeAxes`'s step restates axes `on_category_added` has already assigned. 2. selecting_the_page_already_selected_invalidates_no_cursor was vacuous against a non-pruning grid: `paged_model(false)` plus the deleted `moved &&` guard left it green, because with pruning off the reset never fires whatever `moved` says. Its two sibling arms both carry an anti-vacuity guard; this one had none. `assert!(app.view().prune_empty)` closes it. 3. The same test's page-selection assertion ran after the effect, which sets the page to Q1, so it could not detect a fixture that started elsewhere. The property was guarded — a Q2 fixture failed at the row-labels assertion — but not by the sentence naming it. Both preconditions now assert before the effect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
3f8f7cb765 |
refactor(core): evaluate_aggregated takes Aggregation, not a list it cannot read
The parameter was none_cats: &[String] and the body consulted it exactly once,
for emptiness -- a boolean wearing a list's type, since the aggregation runs
over key.0 through matching_values. git log -S back to the commit that
introduced Axis::None shows it never examined an element, so the name promised a
filter the function has never performed.
Aggregation::{Exact, OverHidden} admits only the two states the body
distinguishes, and Aggregation::of reduces the list once at the boundary that
holds it. The regression guard is the signature: two lists with the same
emptiness and different contents is no longer a constructible call.
recompute_formulas and aggregate_raw keep &[String] -- they walk the elements.
improvise-5jm5
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
|
||
|
|
6978a4e899 |
Merge pull request #76 from fiddlerwoaroof/wave/command-effect-gaps
command: seven edges — dirtiness, aborts, the cursor and three unguarded mappings |
||
|
|
f3b775dc88 |
Answer AxisEntry::GroupEnd in view_frame, after merging #77
PR #77 added `AxisEntry::GroupEnd` — the entry that ENDS a group's run, so
membership is not inherited (improvise-gd83). `App::view_frame` matches
`AxisEntry` exhaustively, so the merge compiled nowhere until it was
answered, which is the compiler doing the job an exhaustive match is for.
Hashed as a third discriminant rather than dropped into a `=> {}`. Where a
`GroupEnd` sits is what a row's group attribution depends on, and group
attribution is drawn frame — the thing `view_frame` exists to observe. A
silent arm would have made that class of reshape invisible to the cursor,
which is the failure mode improvise-ni6k replaced a per-effect table to
avoid.
It is not a spurious-reset risk of the kind the `page_selections` component
was: a `GroupEnd` moves when a group's extent changes, and that moves the
`DataItem` sequence in the same breath in every reachable case.
MERGE NOTES, since a clean merge is the dangerous kind here:
- Zero conflicts. #77's ground (`protocol/src/render.rs`, `improvise-core`,
`ui/viewport.rs`, `src/draw.rs`) and mine are disjoint — my frame
fingerprint reads `GridLayout` from improvise-core directly and never
touches `AxisFrame`, so the only collision was the enum arm above.
- Test function names diffed by NAME across the whole workspace, not by
count. Nothing lost from either side. The five names present at
|
||
|
|
5e957e1f58 | Merge remote-tracking branch 'gh/main' into wave3/protocol-session | ||
|
|
6f98bfd995 |
Review round 1: green CI, the SortData hole reopened, the paging regression removed
Four findings from the adversarial review of #78. Two blocked; all four are
real and one of them was a straight error on my part.
1. CI WAS RED AND I NEVER RAN THE CHECKS. Nine comment runs over the
two-line cap, all mine, all introduced this PR; both checks exit 0 at
|
||
|
|
87696d8b9b |
command: guard the page-flip no-op, and stop the geometry arm walking the cells
Two review findings on #76, both about code this PR itself introduced. **`SetPageSelection`'s `moved &&` guard was unguarded.** Deleting it left the whole workspace green at 1416, and it is not an edge case: `PageNext`/`PagePrev` are odometer-style and emit a `SetPageSelection` for every page category on every keypress, so the non-carrying categories are always no-ops and a single one-item category re-selects the same item. Unguarded, each such keystroke bumps `cursor_epoch`, sends the host cursor home and broadcasts `ResetCursor` to every client on that view — improvise-75ld's "a no-op that refused everybody else", one tier down. `selecting_the_page_already_selected_invalidates_no_cursor` fails under exactly that deletion, and asserts the fixture is really on Q1 and the row labels really did not move first. **The `LayoutGeometry` arm no longer reads the wide fingerprint.** Pointing both arms at `document_content` was an asymptotic change on the held-key path, not the constant factor the first hand-back claimed. Benchmarked at 18,000 cells, release, 200 batches after warm-up: a `SetCell` batch 8.07 ms → 10.21 ms (+27%, a constant factor, since `record_fingerprints` already walked the cells), but a `SetNumberFormat` batch 78 µs → **2.63 ms, 33×** — O(config) to O(cells+config), covering `[`/`]`, axis cycle, hide/show, group toggle, transpose and prune toggle, ~380/sec where they had been ~12,800/sec. `document_config` comes back for that arm and `document_content` is now `document_config` plus the cells and formulas, read only by `CellData`. It costs no correctness because all five 748f-blind effects are `CellData` — but that is a reading, and a reading is what this PR exists to distrust, so `every_layout_geometry_effect_is_visible_to_the_narrow_fingerprint` asserts it over all thirteen. Dropping `view.number_format` from `document_config` fails it naming `SetNumberFormat`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
585f25bf78 |
Invalidate the cursor by observing the grid's frame, not by a per-effect table
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
|
||
|
|
8f3a9768b7 |
fix: group membership, leading headers and one run rule on AxisFrame
improvise-gd83, improvise-0o6x and improvise-8qlr. One commit because they are one change to `AxisFrame`/`AxisEntry` — a variant, a field and a method — and all three move `tests/grid_frame_agreement.rs`, whose two ends must be changed together or the test stops comparing anything. improvise-gd83 — AxisEntry carries a group-end boundary ------------------------------------------------------- `expand_category` emitted a header on ENTERING a group and no boundary on leaving one, so `group_of` answered with the nearest preceding header rather than membership, and the TUI painted that span. Copilot was right on PR #23; this is the filing gotcha 44 said the fix needed. `AxisEntry::GroupEnd` is the boundary, carried onto the wire as `FrameEntry::GroupEnd`. Two arms, and a fix can close one and miss the other: * within one expansion — Jan[Q1], Feb[Q1], Sep — a `Some -> None` transition; * across expansions — `cross_product` expands the inner category once per prefix and each call starts with `last_group = None`, so Food/Bob[Family] is followed directly by Clothing/Carol with no transition to hook onto. That needs a `GroupEnd` at the END of an expansion whose last item was grouped. `GroupEnd` is NOT emitted between two groups: the next header already ends the previous run, and emitting one there moved `data_row_to_visual_skips_headers` and its column twin for no gain. Those two are untouched, which is the evidence the boundary is as narrow as it claims. A trailing marker is trimmed, since the axis itself ends the last run. Nothing draws a `GroupEnd` and it consumes no data index. `THE_MEMBERSHIP_CORRECT_SPANS` and its `assert_ne!` tripwire are gone, and the value they predicted is now asserted element for element — it was right to the element, which is worth recording. improvise-0o6x — a leading group header is drawn ------------------------------------------------ `body_from(idx)` started at the first ITEM, so at the top of an axis a leading `FrameEntry::Group` was dropped on the way to the screen, in both front ends. A collapsed group first on its axis therefore had its rows hidden and its header nowhere: no affordance to get them back. It is a boundary condition as the ticket suspected, but the boundary is not `idx == 0`. The windowing rule carries the nearest header ABOVE a scrolled window for `group_of` and `body_from` discards it — correctly — and a frame cannot tell that from the top of the axis using `idx` alone, because the browser's frame is already windowed and always passes `0`. So `AxisFrame` gained `first`, the absolute data index of its first item, and the rule is `first + idx == 0`. Without it the browser would draw an extra header row at every scrolled position that the TUI does not. RE-PINNED DELIBERATELY, the improvise-q0vb acceptance shape: `GROUPED_FRAME` in `src/ui/grid.rs` moves by exactly one line — the leading `▼ Co…` now draws above `East` and everything below shifts down one. The group line, the column headers, the separator, the data rows and the totals strip are byte-identical, which is the evidence this is the boundary condition and not a reshuffle. `harness/tests/grouped.spec.js` asserted TWO group rows against a fixture with three, naming this ticket on the line and saying the count becoming 3 would be the record of when it was fixed. It is 3, verified in Chromium. Three assertions in that file took "the first tbody row" to be a data row; a `firstDataRow` helper now skips the header, which is the same re-pin. improvise-8qlr — the run rule lives once ----------------------------------------- Column-group run coalescing was derived in `src/ui/grid.rs` and again in `improvise-wasm-client/src/render.rs::col_groups`, held in step by a comment saying so. `AxisFrame::group_runs`/`group_run_heads` is the one home and both ends call it. Runs still coalesce by NAME, which is what both ends did before and is preserved rather than quietly changed. The TUI keeps one divergence, stated: it labels its first VISIBLE column even mid-run, or a scrolled grid draws an unlabelled group line. The agreement test's TUI arm was `group_line.contains("▼ Q1")` — the unpinned arm the ticket names. It now compares the painted group line cell for cell against spans derived from the browser's own runs. The test is fixed, not deleted. Injected faults, each failing by name ------------------------------------- * gd83: delete either `GroupEnd` emission — the two new layout tests fail with the leaked group, and the column-header agreement test fails with them. * 0o6x: force `body_from`'s match to `false` — `the_whole_frame_is_unchanged` and the new leading-header agreement test fail. * 8qlr: the ticket's own recipe, forcing the TUI to emit the group label on every column. `cargo test -p improvise` was GREEN before this commit and now fails with ["▼ Q1","▼ Q1","",...] against ["▼ Q1","","",...]. What the ticket did not predict ------------------------------- A collapsed leading group has no item to carry its header into a window, so it reaches the frame only through `axis_frame`'s `pending` queue. The first version of the windowing change had `GroupEnd` CLEAR `pending` above the window, which deleted exactly that header — the affordance 0o6x exists to restore. `GroupEnd` is now queued like a header and clears nothing. Found by driving the new agreement test in both collapse states; the uncollapsed arm alone passes against the broken version. 1411 Rust tests pass (baseline 1405, +6). Playwright 28 passed + 3 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
05a28b0d45 |
Point the geometry test at the production hash, and test both abort arms
improvise-1xf4: `layout_geometry_effects_actually_change_view_config` hand-listed 6 of the 13 LayoutGeometry effects and measured them against `view_config_fingerprint`, a THIRD fingerprint existing only in test code beside the production `document_config` and `view_shape`. Since PR #70 decides `model_state.dirty` for LayoutGeometry solely from `document_config`, an effect writing something the test-only hash sees and `document_config` does not would pass and never mark the document dirty — i.e. never be saved. It is now `layout_geometry_effects_actually_change_document_config`, measured against `document_config` itself and listing all 13, counted off the `impl Effect` blocks rather than trusted. Two fixture facts the rewrite exposed and the old six never could: `ShowItem` on a visible item and `ToggleGroup` on a nonexistent group are no-ops, so `grouped_app` supplies a hidden item, a real group and a second view. `view_config_fingerprint` SURVIVES, deliberately, and is not a third derivation any more: it is used only by the NEGATIVE arm (`view_only_effects_leave_shared_state_alone`), where a hash stricter than `document_config` is what that test wants. The doc comment says so. improvise-2avl: PR #70 moved the `layout_version` bump out of the effect loop. Gotcha 40 records the in-loop position as load-bearing, and the argument that end-of-batch is safe for `layout_version` specifically was written into context/code/command.md and never tested. Both arms now are: a batch that reshaped before aborting must bump, one that aborted before reshaping must not. Proved independently — bumping unconditionally fails only the second, skipping `observe_reshape` on abort fails only the first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
1ea067c9ab |
command: dirtiness, aborts and the cursor — five edges in the effect layer
improvise-748f — a drill exited with no edits, and view-back on a document with no drill, marked it dirty. `Effect::kind` takes no `App`, so the classification cannot be state-dependent; the answer is to observe. The ticket proposed observing the per-record raw-cell fingerprint, and that is **unsound**: five of the ten `CellData` effects change the document without writing a raw cell — `AddItem`, `AddItemInGroup`, `SortData` (order is in the file), `AddFormula` and `RemoveFormula`. Observing raw cells would have left every one reading clean, which is a lost edit rather than a spurious prompt. So `document_config` is widened into `document_content` — cells in iteration order and formulas as well as the categories, items, groups and view configuration it already carried — and `CellData` joins `LayoutGeometry` in being read against it, in the loop, at the position gotcha 45 requires. `WholeWorkbook` stays declared, and that is the remaining over-report: a `LoadModel` does not move `file_path`, so the in-memory document really does differ from the file the path names. `start_drill_and_apply_clear_drill_with_no_edits` pinned the old behaviour and is inverted. improvise-z05r — `CommitExport` did not bail on an empty path where `CommitBuffer`'s three kinds all do, and `ExportCsv` did not abort the batch where `Save` and `SaveAs` both do. All three writers now report through one `report_file_write`, and the test names the three so a fourth has to come there. The commit reorders its batch too: the mode change goes at the head, because an abort at the tail strands the user in `ExportPrompt`, where the minibuffer is drawn *instead of* the status line and the export error is never seen (gotcha 21). improvise-7kvw — `SetPageSelection` renumbers the grid under `prune_empty`, because which rows survive pruning is a function of the page. It now resets the cursor in exactly that case, and only when the selection really moved: sending the cursor home on every `[`/`]` with pruning off is worse than the hole. improvise-pubt — the ticket argued the drill arm of `display_value_in` was probably unreachable with a foreign layout. It is reachable: records mode auto-detects from the axes, so an ordinary saved view gives `GridLayout::new` a records layout whose `cell_key` returns the synthetic key the arm matches. The test fails on the old code. `staged_drill_edit` now consults `drill_state` only for the host's own layout, by pointer identity, which is the question `grid_in` is actually asking. improvise-h074 — `resolve_view`'s fallback carries back the `.expect` message `Workbook::active_view()` had. The second half, the host-side `DeleteView` bypass of `adopt_deleted_views`, is untouched: it is a decision about `Sessions`/`Host`. Every guard here was proved by injecting the fault it names and watching it fail by name; `context/test-evidence/command.md` records each injection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
472f7e5548 |
command: one Panel→flag mapping, and a guard that can see a permutation
improvise-y4xg and improvise-qsmp are two shapes of one defect. `registry.rs` mapped `Panel` to a `CmdContext` field in two places and `App::cmd_context` copies the three flags out of `ViewState` in a third, and all three were demonstrated unguarded: swapping Formula and Category at the registry sites left the workspace at 1400 passing, and hardcoding the `category_panel_open` propagator to `true` left it at 1394. The registry mapping is gone — both sites call `CmdContext::panel_open(panel)`. The remaining two permutable sites are guarded from a path neither controls: the tests write `view_state`'s three fields **by name** and read back through the enum accessor on a context built by the real `App::cmd_context`. PR #70's review caught the weaker shape of this, where zeroing and reading through the same accessors passed a consistent permutation. Both faults were injected and both fail all three new tests by name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
bc77e68f40 |
One classifier for a typed string, one exhaustive walk over Rejection
improvise-j7v6: the rule turning a typed string into a `CellValue` was written twice — `resolve::typed` (already moved into improvise-protocol since the ticket was filed) and `commit_regular_cell_value`'s if/else-if/else on the host's key path. The host now calls `improvise_protocol::typed`, which is `pub` for that reason. Per doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished), `a_typed_string_is_classified_as_the_host_classifies_it` did not survive as a comparison: type identity replaced its claim. It is re-scoped to the thing this crate still owes — the FRAME the string composes — and renamed `a_typed_string_composes_the_frame_that_carries_it`. The ticket's flagged risk (gotcha 34: a shared classifier that disagrees with the persistence layer is a third answer) is now asserted rather than argued: tests/typed_string_agreement.rs round-trips what `typed` calls text and what it calls a number through format_md/parse_md. Proved discriminating: dropping `pipe_quote` from the writer fails it. improvise-8uj8: `every_rejection_message_says_the_edit_did_not_land` drove a hand-written five of seven `Rejection` variants while claiming the fact held "over all of them". `Rejection::all` (the same cyclic walk `AppMode::all` uses) and `Rejection::refused_an_edit` (exhaustive) now drive it, and its claim is re-scoped to what is true: a rejection that refused an EDIT says nothing was applied, and the two Save answers — which refused no edit — say the document was not written. Renamed `every_rejection_message_says_what_it_refused`. Proved both ways: wording SaveFailed as "hmm" fails the test, which the old list could not have seen; adding a Rejection variant fails the build at basis.rs:101 and :126. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
e89b6467e1 |
Derive the AppMode lists from AppMode, and close minibuffer()'s wildcard
improvise-e1z5 (half) and improvise-g9jb.
`AppMode::minibuffer()` ended in `_ => None` where its sibling `panel()`
is exhaustive, so a text-entry mode could be added and be invisible to
`EnterMode`'s clear and to the `TextEntry` builder. It is now exhaustive.
`AppMode::all()` and `AppMode::text_entry()` are the one enumeration.
`all()` walks a total, cyclic `after()` rather than listing: with no
`None` arm, the only way to answer for a new variant is to name another,
which is what splices it into the walk. `text_entry()` filters `all()`
by `minibuffer().is_some()`, so the set is derived from the thing that
makes a mode text-entry rather than restated.
Four hand-written lists removed, enumerated by grep before the claim:
1. protocol/src/help.rs::all_modes() 17 removed
2. command/src/ui/app.rs::tests::all_modes() 16 removed — it was
missing SavePrompt, latently
3. protocol/src/resolve.rs::tests::text_entry_modes() 8 removed
5. wasm-client/tests/lockstep.rs::text_entry_modes() 7 removed — also
missing SavePrompt, latently
One list survives and is not a copy: command/src/command/keymap.rs's
`text_entry_modes_with_exits` carries commit/leave KEYS, which are not
derivable from AppMode. Its mode set now comes from `AppMode::text_entry()`
and only the keys are stated, in a match with no wildcard.
e1z5's second half needs no change: `strands_the_user` was deleted by
improvise-rmvx and replaced by name-driven `declined`. Only a stale doc
reference to it survives, at resolve.rs:1948.
Verified by injecting a throwaway `AppMode` variant: the build fails at
lib.rs:220 (`minibuffer`) and lib.rs:266 (`after`), neither of which
could have failed before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
|
||
|
|
922eb82d6b |
One home for the published basis, one fold for a mirror's active view
improvise-23qd: `ClientSession::basis_update` and the `#[cfg(test)]` `server_truth` each derived "which records does this client's grid name" from the same filter, and `server_truth` is the yardstick the fold tests measure against — so a change to which records get published moved the thing and its measure together. Both now call `published_basis`, and the property has a yardstick that is spelled out rather than derived: `the_published_basis_names_the_records_the_grid_draws_and_no_others` names North/South/East by hand and asserts an aggregate grid publishes none. Proved discriminating by injection: `.take(2)` on the one surviving filter fails that test and nothing else in the crate. improvise-w3bf: the four writers of `ClientSession::view.active_view` — `apply_switch`, `adopt_deleted_views`, and `reseed`/`connect` through `fresh_view` — each reproduced `reduce_view`'s one-line `SwitchView` arm. All four now run that arm. Proved by injecting a second statement into the arm and watching a probe read "now Default" from a pre-fix `apply_switch` and "now Second" after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
14757a753f |
fix(command): the observation goes in the loop, or Save cannot clear it
Review of PR #70 found a regression the 1400-test suite did not name. `observe_reshape` ran AFTER the effect loop and re-raised model_state.dirty from the document_config comparison, while `Save` clears it INSIDE the loop. So gotcha 45's ordering property -- "Save clears it in loop order, so a batch that edits and then saves ends clean" -- survived for CellData/WholeWorkbook and was broken for LayoutGeometry. [SetNumberFormat, Save] -> reported a successful save, document dirty [SetCell, Save] -> fine on both Reachable from `:format ,.2f . w` and from any Binding::Sequence pairing a geometry effect with a save. The TUI draws "Saved to ..." while :q refuses; the worker, whose persist trigger IS the flag, pays a whole-document OPFS rewrite. The exact failure class this PR set out to close, one level up. The fix is position, not logic: the comparison is taken after each LayoutGeometry effect against the value held after the previous one. `layout_version` stays after the loop, which is not the same hazard -- nothing clears it, and an aborted batch's effects never ran, so an end comparison sees only what did. Neither arm of that is tested; filed. Two more review findings: a_panel_command_writes_that_panel_and_no_other asserted THROUGH the mapping it tests -- zeroing via panel_open_mut and reading via panel_open, the same four accessors. A consistent permutation of all four is a self-consistent bijection and passed. It now reads the three field pairs by name once per panel, and the consistent permutation fails it. It had been catching only a non-injective mapping, which is precisely the injection the evidence recorded. And improvise-56ek's claim of "no second copy left to agree with" was false when written: resolve.rs carried a fourth copy of the same Panel -> ViewState match, in the same crate on the same type. Deleted; its three call sites go through ViewState::panel_open. The claim is corrected rather than overstated -- registry.rs maps Panel to CmdContext fields, a different type, and the Panel -> (cursor, length) pairs are a different derivation and lockstep's subject. 1401 tests pass. Both new guards proved by injection: reinstating the after-loop comparison fails the ordering test by name with 454 others passing; a consistent Formula<->Category permutation across all four accessors fails the panel test, where before it did not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
beef2587ad |
fix(command): a counter is bumped on an observed change, not a declared intent
`EffectKind` says what an effect *means* to do. `layout_version` and
`model_state.dirty` were believing it, and failed in both directions.
improvise-pdvm: AddItem/AddItemInGroup/RemoveItem declare CellData and
reshape the grid. Since improvise-9mo9 they call reset_cursor, so
cursor_epoch moved while layout_version did not -- one event moving one of
two counters that answer the same question, and a client told its cursor is
invalid while its layout basis tier still reads current.
improvise-75ld: a refused AddCategory (MAX_CATEGORIES) and a *duplicate* one
both bumped layout_version and raised dirty. The duplicate is the reachable
silent case -- Model::add_category returns Ok for a name it already has, so
it does not even abort -- and it refused every other client's next batch with
Rejection::LayoutMoved and made the worker rewrite the whole OPFS document.
Both are answered the way record_versions and cursor_epoch already are:
observe. Two fingerprints are taken around a batch that contains any
EffectKind::Model(_).
view_shape one view's DRAWN shape (axes, effective items minus this
view's hidden ones, collapsed groups with something drawn
under them, page selection, prune_empty). Per view name, so
it answers both "did anything move" and "which views moved".
document_config everything a LayoutGeometry effect can write that the
file would carry. Raises dirty. A different hash on purpose:
a number format and a hidden ghost are bytes on disk and not
shape.
No `Effect::kind()` declaration changed, so Dirty::of (gotcha 35) and
record_versions (gotcha 40) are untouched. What moved is which reader
believes the declaration: CellData and WholeWorkbook still raise dirty off
it, in an exhaustive match so a fourth ModelChange must answer for itself.
improvise-nynk: Sessions::adopt_cursor_epoch reset every mirror and returned
one Broadcast::ToAll, which improvise-jytm made wrong -- a reshape of view A
threw away the cursor of a client on view B. The trigger stays the epoch
moving (gotcha 24); only the audience narrows, to the observed reshaped-view
set, as one Broadcast::To per affected client. An empty set means "not
known", never "nobody", and cursor_invalidated is a named function with both
arms tested so that fail-safe cannot be tidied away.
improvise-56ek: SetPanelOpen and SetPanelCursor matched a Panel to a field
twice, in two crates. One home now -- ViewState::panel_open_mut /
panel_cursor_mut and their read halves -- and no compatibility test between
them, because there is no second copy to agree with.
1400 tests pass (baseline 1394: +7 guards, -1 replaced). Every guard proved
by injecting the fault it names; the injections and what each one broke are
in context/test-evidence/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
|
||
|
|
1922beca06 |
fix(tests): a launch default disarms every test that asserts what it supplies
USER RULING (2026-09-20): the category sidebar is the launch default in the TUI as well as the browser. `improvise model.improv` opening with the sidebar showing is intended, not collateral. The behaviour is unchanged; what changes here is that it is now a stated, tested decision. Three tests asserted `category_panel_open` was TRUE after doing something to make it true. The default now supplies that, so all three passed without the code under them working. **`set_panel_open_and_cursor` is the serious one.** With it vacuous, `effect::SetPanelOpen`'s Category arm could be replaced by a no-op — `C` doing nothing in the terminal — and the whole workspace stayed green at 1391 tests. Now closes the panel first and asserts it closed, so the effect has to move the flag. **`panel_commands_address_the_named_panel_only` is the subtle one, and my first repair of it was ALSO vacuous.** Folding `[open:false, open:true]` and asserting the final state is true still passes against a reducer that folds nothing: the default already supplies `true`, so neither command needs to land. Only asserting the FALSE direction discriminates. Caught by injection reporting NONE failing after the "fix" — it would otherwise have shipped looking repaired. The rule this leaves, now in `context/code/clients.md`: **assert the direction the default cannot supply.** `tests/tui_launch_state.rs` gives the ruling a guard on the front end it newly changes — the TUI's launch frame had no test at all. Not a rendered frame: §7 says not to test `Widget::render` and the repo has no `TestBackend`, so the target is `App::new`. One of the three walks `Panel::ALL` comparing `App::new` against `ViewState::default()`, which is what would catch a TUI-only launch state — the divergence wsaw closed. **The view-row click test was vacuous for the property it is named for.** `Default` is index 0 and holds `view_panel_cursor`, so clicking it made `index` and the cursor the same number. Injecting the cursor-vs-index fault into `click_view_row` passed the contract suite 3/3 — the exact fault the ticket, the evidence and the Rust test exist for was invisible to the browser half. Now switches to Default by keyboard, then clicks Plan, and restores the two assertions the relocation had dropped. Inherited from the deleted `pointer.spec.js`, not introduced here. Filed rather than fixed: improvise-56ek (`SetPanelOpen` maps a Panel to a field twice, in two crates — each copy needed repairing independently, and the harness passing against a no-op'd effect is correct rather than a gap) and improvise-qf57 (the formula row's dropped `toHaveCount(0)`). improvise-4ibz is closed by the ruling. Every claim here proved by injection under `--no-fail-fast`, which is the method note: `cargo test --workspace` is fail-fast and understates blast radius. Gates: 1394 Rust tests, 26 + 3 Node, 16 Playwright (3 WebKit OPFS skips), clippy -D warnings, fmt, eight wasm crates, comment runs, 516 evidence entries. `.beads/` untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b06d0d054e |
Merge remote-tracking branch 'gh/main' into wave/f-route-then-aggregate
# Conflicts: # context/code/core.md |
||
|
|
3fe4da52b0 |
Merge pull request #49 from fiddlerwoaroof/wave/n-cleanups
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Browser bundle + static site (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
wave 2 / WU-N: cursor invalidation, the reduce_full deletion, and two declines with evidence |
||
|
|
22ea527837 |
Merge remote-tracking branch 'gh/main' into wave/f-route-then-aggregate
# Conflicts: # context/code/core.md |
||
|
|
b03d924be0 | Merge remote-tracking branch 'gh/main' into wave/f-route-then-aggregate | ||
|
|
e176a29e75 |
fix: the no-op guards must ask what the grid draws, not what the model stores
Second review round. Six accepted-but-no-op commands still reset the cursor and
bumped the epoch; five are fixed here and the sixth is filed.
The one to remember is AddItem { _Measure, <a formula target> }. The guard read
category.items, but _Measure's effective items are explicit UNION formula targets
(gotcha 7), so it called "new" a column the grid was already drawing. The three
item effects now compare effective_item_names ACROSS the mutation rather than
predicting from one side of it, which is correct in both directions at once and
needs no separate rule for dedupe, for formula targets, or for an unknown
category. Prediction is what was wrong; observation is the fix.
Four smaller ones: SetAxis on a category the view does not register (set_axis
no-ops, but try_axis_of returns None, which compares unequal to Some(axis));
HideItem/ShowItem naming something the grid never drew (hide_item records any
string); and ToggleGroup on a group that does not exist, which never got a guard
at all because it merged CLEANLY through the #48 merge -- the clean-merge hazard
arriving in the one place nobody re-read.
TogglePruneEmpty on a grid with nothing empty is NOT fixed. Guarding it means
computing the pruned layout, which is building the frame improvise-ni6k proposes
fingerprinting. It is one keystroke in ordinary use, so it is filed loudly rather
than patched quietly.
The fixture could not reach any of these by construction -- no formula, no
category on Axis::None, no unknown-name case: the one-category-fixture shape the
doctrine names. It now carries a Profit formula, a Channel category on Axis::None
and a Ghost entry hidden under a name the model never had.
Two of the five injections initially failed to reproduce, and BOTH TIMES THE
INJECTION WAS WRONG RATHER THAN THE GUARD: ShowItem needed a hidden ghost name,
since is_hidden is false for a name nobody hid; and AddItem had to compute its
predicate BEFORE the add, because afterwards the item really is in category.items.
An injection that does not reproduce the original ordering tests nothing.
ToggleGroup's first guard was also wrong -- add_item_in_group sets item.group
without pushing to Category::groups -- and the existing reshape test caught it.
All nine earlier injections re-run and still caught.
Filed: improvise-nynk (adopt_cursor_epoch resets clients on views nothing
reshaped -- bisected to main/#48, but this branch takes it from 2 effects to 12),
and TogglePruneEmpty plus the Axis::None class onto improvise-ni6k.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
850ddcf0eb |
Merge gh/main (#48 active_view, #50 shared front end) into wave/b-projection-gaps
Eight conflicts, all resolved keeping both sides. The three in context/ were append-vs-append. The five in code were the restructure #50 declared in advance: - render.rs: render_model became a free fn over &ClientState and the mode label moved to improvise_protocol::mode_label. Took that shape and re-added the three doc_* fields. - state.rs -> improvise-protocol/src/client.rs: only the import path conflicted. The file now lives IN improvise-protocol, so `crate::` is right, plus DocumentCache. - keys.rs went 1387 -> 187 lines because resolve/step moved to improvise-protocol/src/resolve.rs. Took gh/main's slim file and ported every addition into resolve.rs: the Derived and Create arms of edit_or_drill, the or-patterns in record_at_cursor and record_cell_at_cursor, page_cycle with its Odometer, the two bindings, and `[`/`]` leaving the still-needs-the-host list. - project.rs: kept the value-based numeric/error from improvise-tom1 with gh/main's cell_id(app, layout, ..) signature; kept BOTH document_update and view_in; kept our title-bar projection inside their per-view projection set. - lockstep.rs: both sides appended tests and the hunks interleaved mid-function, so took gh/main's file and re-appended our page-turn block whole. THE CLEAN MERGE WAS THE DANGEROUS ONE, exactly as warned. `cargo check --workspace --all-targets` passed with three of our tests SILENTLY GONE — they lived in keys.rs's test module, which moved wholesale. i_on_a_derived_cell_names_ the_formula, the_page_keys_turn_the_odometer_as_the_command_does and a_filter_axis_is_projected_but_not_cycled are ported into resolve.rs, with client_on now returning a ClientState rather than a Client. One assertion changed meaning under us and is corrected: lockstep's page_selections read workbook.active_view(), which since improvise-jytm is the document's DEFAULT. SetPageSelection writes App::view(), the client's. They coincide in a one-view fixture, so it was passing by fixture luck; it now reads App::view() and still fails under the injection. All four injections re-run on the merged code and all four still discriminate: reversed odometer, dropped records formula_for lookup, title bar gated on Dirty, and filtering_cats leaking into the protocol. 1348 passing, clippy clean, fmt clean, all eight wasm crates clean, both comment-run checks clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a6258f7c50 |
Merge gh/main (PRs #48, #50) into wave/n-cleanups
Two conflicts, both in ui/effect.rs and ui/reduce.rs, both resolved by keeping BOTH sides rather than either. #48 introduced App::view_mut() as shorthand for model_state.workbook.active_view_mut(). SetAxis, HideItem and ShowItem conflicted because this branch had just given each a no-op guard (improvise-9mo9 review, F1). Resolution keeps the guards, expressed through the new accessor. ToggleGroup merged cleanly and kept its reset, which is the case worth noting: git had no reason to flag it. #50 moved records_named into improvise-protocol and re-exports it from ui/reduce.rs, and re-added reduce_view to the import list. reduce_view's last use in this file was deleted by improvise-s17o on this branch -- apply_command's fallback arm no longer folds -- so the resolution keeps the re-export and drops the now-dead import. A CLEAN MERGE IS THE DANGEROUS ONE, so all nine fault injections were re-run against the merged tree rather than trusting a green suite: SetAxis's moved check, AddItem's fresh check, AddCategory's Err arm, SetAxis's reset removed, SetNumberFormat gaining a reset (the negative arm), the ShowItem non-vacuity guard, reset_cursor clearing the whole cursor map, the CommitAndAdvance advance ordering, and apply_command folding again. All nine still caught, and the tree restores byte-identical. That matters most for the cursor ones, since #48 changed which view a session is projected against. 1333 passing (1328 from main plus this branch's 5), clippy and fmt clean, both comment-run checker modes clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |