Commit Graph

53 Commits

Author SHA1 Message Date
Edward Langley d5201d984b fix(command): naming a measure inserts a column, so Tab must not advance (improvise-0ygd)
Typing a name into the trailing `+` column of a records grid and pressing Tab
created the measure and then threw the user across the grid: it added a record
row nobody asked for and parked the cursor at (row + 1, 0), leaving the column
the commit had just made unreachable. Enter on the same cell was correct.

The cause is a stale index, not an abort and not an ordering. `AdvanceDir::Right`
reads `at_bottom_right` off `CursorState::col_count`, the PRE-commit column count
— `apply_effects` rebuilds the layout only after the whole batch (gotcha 23). A
`+measure` commit inserts its column at the cursor's own index and pushes the `+`
one to the right, exactly as `+cat` does, so after the commit the cursor is no
longer at the right edge.

`context/code/command.md` argued the opposite in prose: that the measure `+` has
no column to its right for an index to shift into, and that both advances are
already special-cased at the bottom-right edge. The first is false — the column
that shifts is `+measure` itself — and the second names the bug rather than a
safeguard. That paragraph is corrected here.

`inserts_a_column` therefore covers both creating columns, and takes the value,
because an empty name creates nothing: `created_name` is now the one home of that
rule, read by `add_measure`, `add_category` and the predicate. Splitting on the
buffer rather than the column is what keeps bulk entry, whose Tab-at-the-bottom-
right path is reachable only from `+measure` — it is always the rightmost column
— by tabbing onto the `+` with nothing typed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 15:43:09 -07:00
Edward Langley 907a3aa4c7 fix(command): two P1s in the key path, and both were about silence
improvise-55l5 (P1): `KeymapSet::dispatch` spelled command construction
`registry.interactive(name, args, ctx).ok()?`, so `None` carried two facts —
*no binding matched* and *a binding matched but would not build* — and
`App::dispatch_key` answers `None` with `Dirty::NOTHING` and no status. The
class that reaches a user is `no cell at cursor`, returned by six registrations
that read `ctx.cell_key()`: `clear-cell`, `yank-cell`, `paste-cell`,
`drill-into-cell`, `commit-cell-edit` and `commit-and-advance-right`. It is
state-dependent, so nothing checkable at registration time can see it. Every
other constructor error is a keymap-authoring mistake that
`every_bound_command_name_is_registered` already refuses at test time
(gotcha 15), so surfacing them is free rather than the point. `None` now means
no binding matched and nothing else; a construction failure becomes a status
naming the binding. `an_unbound_key_stays_silent` is the control, and the
rival that reports a failed *lookup* fails it.

improvise-o9uq (P1, silent data corruption): `o` in records mode is
`[add-record-row, enter-edit-at-cursor]`, and `AddRecordRow::execute` never
placed the cursor. Appending a row changes `view_frame`'s hash, so the frame
observation inside `apply_effects` invalidated the cursor to the origin
mid-batch (gotcha 24) and the editor opened pre-filled with row 0's category
coordinate; Enter then went through `rename_record_coordinate`, which by
gotcha 1 moves every measure cell of that row. Demonstrated through
`App::handle_key`: `East`'s Revenue cell became part of a record named
`EastNorth`. The command now names the row it lands on, and the ORDER is the
repair — the injected rival is the swap, not the omission, because
`viewport_effects` before the `SetCell` is the bug verbatim. The arm where the
record already exists stops claiming it added one; it is not made a refusal,
because `CommitAndAdvance`'s Tab path seeds the same empty-coordinate row.

Reasoning in `context/code/command.md`; evidence, injections and the two
things this deliberately does not close in `context/test-evidence/command.md`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 00:13:45 -07:00
Edward Langley fa1f572335 Merge remote-tracking branch 'gh/main' into wave/t-drill-wire
# Conflicts:
#	context/test-evidence/command.md
2026-09-21 21:28:00 -07:00
Edward Langley 67c390687d fix(protocol): a refused switch is undone, not overwritten
ChatGPT Codex, PR #115, and it is this PR's own regression. Once `reduce_view`
records history, the CORRECTION for a refused switch records history too: the
asker folds `Missing` (back gains the view it left), then folds the correction
(back gains `Missing`), while the guarded mirror holds neither. `<` afterwards
lands on a view the document never had, and `forget_view` cannot help --
nothing vanished, the name was never real.

Reachable rather than theoretical: no shipped client can invent a view name,
but one can pick a name from its projected list that another client deletes
before the frame arrives.

Two changes. The mirror now folds what the client folded, refused or not --
a correction can only undo what BOTH ends hold, and guarding one end is what
let them drift. And the correction is `[ViewBack, ForgetView(asked)]` rather
than a `SwitchView` back, because `ViewBack` is the exact inverse of the push
while a `SwitchView` pushes another frame and leaves the back stack one entry
longer than it started.

The residue is asserted, not hidden: the optimistic fold clears the forward
stack and no correction can refill it, so a refused switch costs the redo
history exactly as an accepted one does.

The neighbouring test compared `active_view` alone and stayed green through
all of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 21:21:19 -07:00
Edward Langley 507447c48a docs: the arguments the two commits' doc comments point at
Every `context/code/...` pointer added by this branch now names prose that
exists. protocol.md §2 carries why `ViewBack`/`ViewForward` CLOSE a gap in the
view half's derivation rather than widening it, why the fold and the effect had
diverged, and why a guard was written and then deleted. command.md carries why
`forget_vanished_views` and `adopt_deleted_views` do not subsume each other.

A pointer to prose that does not exist reads as a promise the explanation does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 19:30:42 -07:00
Edward Langley 614d818e75 test: three self-review findings on the above
The code-review skill run against this branch, and the findings are about my
own tests rather than the code.

1. The formula sweep's predicate is (target_category, target), and nothing
   discriminated the first half: a sweep on `target` alone passed every test
   I had written. Two categories may legally hold a target of the same name,
   so removing Metric/Profit must not take _Measure/Profit. Proved by
   injecting `retain(|f| f.target != item_name)` and watching it fail.

2. Both `cycling_an_axis_...` tests read the status off `last()`, which a
   resolver emitting the status and NOT the mutation also satisfies. Both now
   assert the mutation is first.

3. Two things the fix leaves that nothing guards, both filed and both written
   down rather than left in a PR body. improvise-nwgk:
   doctrine(a_status_line_composed_beside_a_mutation_lies_when_the_mutation_no_ops)
   asks a fallible mutation to RETURN whether it acted, and remove_item still
   returns unit -- the reachable lie is gone, a wire RemoveItem naming nothing
   is not. improvise-en4s: Dirty::of(CellData) reporting formulas: true was
   slack over-reporting and is now load-bearing, because a delete can take a
   formula out of the document; narrowing that arm would strand every client's
   formula panel forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 19:28:40 -07:00
Edward Langley 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
2026-09-21 11:08:17 -07:00
Edward Langley 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
2026-09-21 10:37:46 -07:00
Edward Langley 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
2026-09-21 10:30:15 -07:00
Edward Langley 7bc26f80d5 Review round 1: three checks that did not check what they named
All three found by the adversarial review of 019cbc1, each by injection.

1. every_model_command() is NOT compiler-maintained, and both this PR's prose
   and reduce.rs's own doc comment said it was. variant_name's exhaustive match
   forces an arm in variant_name, not an entry in the value list -- deleting
   ModelCommand::TogglePruneEmpty from it failed the new completeness check and
   NOTHING ELSE in the workspace. So the diff is two independent hand-written
   lists: strictly better than one list against its own length, and not the
   derived enumeration improvise-5ubs got for the two enums. Doc comment
   corrected; the prose in context/code/command.md and the evidence entry now
   say what it is. Closing it properly wants improvise_protocol::variants made
   pub so declared::<ModelCommand>() can bound the list -- another wave's crate,
   reported rather than taken.

   The assertion message was also ONE-DIRECTIONAL ("a name only on the right
   ..."), which is the shape that injection produces and which invites the wrong
   repair: delete the case on the left, degrading both sides together. It now
   names both directions and says which is which.

2. SELF_TEST_DUPLICATES case 3 was named for the ambiguity guard and never
   reached it: both its headings were module-qualified, so canonical() returned
   on the exact-match arm before touching by_leaf. Dropping the guard
   (`if matches` for `if len(matches) == 1`) left --self-test at exit 0. The
   fixture is now one BARE heading over an ambiguous leaf plus BOTH qualified
   spellings, so the injection collides with one of them whichever way
   next(iter(set)) falls -- deterministic across five runs, verified.

3. The evidence entry said five pre-existing records-mode tests fail under the
   settle-before-rebuild injection; measured, it is six. And "three hundred
   lines above" is four hundred.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 10:27:24 -07:00
Edward Langley 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
2026-09-21 10:23:27 -07:00
Edward Langley 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
2026-09-21 10:19:10 -07:00
Edward Langley 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 (1a9f45a) rather than local main (1ec43a7): local main carries an
unpushed orchestrator kb.pl commit, and the PR's merge ref is computed against
what is on GitHub.

Gates on the merged tree: 1486 passing, 0 failing; clippy -D warnings, fmt,
wasm32, both comment-check spellings and kb check all clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 05:33:07 -07:00
Edward Langley 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
2026-09-21 04:36:42 -07:00
Edward Langley 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
2026-09-21 04:18:36 -07:00
Edward Langley 46c3cfa158 docs: two things self-review turned up
The lockstep fixtures no longer fabricate a basis, but they do still assume a
cursor at the origin: published_basis asks Sessions::connect for a window at
offsets zero while client_of builds its grid at the cursor's offsets. They
coincide in every fixture here. Recorded rather than closed.

And the keypress path got improvise-jmjz's fix for free: compute_col_widths is
one walk now rather than two, so App::apply_effects' own width computation
roughly halved (134ms to 68ms at 18,000 cells). That is improvise-748f's
held-key path and was not the ticket's subject.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 04:13:57 -07:00
Edward Langley a449149998 docs: cite the two follow-up beads filed for this wave
improvise-6nsd (a prune-by-write renumbers the grid without moving
layout_version) and improvise-26dm (the fourth length-not-completeness list, in
ui/effect.rs). Both read back after bd create.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 04:13:16 -07:00
Edward Langley ae1570b508 docs(command): every reframe is NOT also a reshape, measured (improvise-9v0j)
9v0j asks for the general relation between view_frame and view_shape, which are
separated by example and never stated as a property. One direction is the known
asymmetry -- AddCategory of an empty category reshapes without reframing. The
other was open, and the ticket guessed "probably yes, and if not, layout_version
under-reports, which per gotcha 35 leaves a client wrong forever".

Probed by driving each ModelCommand through apply_command and reading
cursor_epoch() and layout_version(). Every command in reshaping_commands() moves
both; a ClearCell that prunes a row out of a pruning view moves the cursor epoch
and NOT layout_version. That is improvise-mcq4 working as designed -- a cell
write changes data, not the view's configuration, and view_shape hashes
configuration. So the proposed property test would FAIL and writing it would be
wrong.

What remains open is narrower and stated as a question rather than answered: the
grid has renumbered while layout_version has not moved, so a client still
claiming the old layout tier is accepted. The record tier covers a write to the
pruned record; it says nothing about a write to a surviving one whose row index
moved. Whether that is a hole in ADR 001's middle tier or correctly delegated is
an ADR question.

No code change: view_frame/view_shape live in ui/app.rs, which is another wave's
ground this round.

Also recorded: the probe needed the right fixture to say anything. Against
app_with_two_pruning_views, seven of the twelve commands are no-ops because that
document has no Product category, and a table of false/false rows reads exactly
like a finding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 04:09:27 -07:00
Edward Langley 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
2026-09-21 04:07:51 -07:00
Edward Langley c186909de6 docs: the review's two findings on this branch
`Command::ChangeMode(AppMode::Normal)` is on the wire and `reduce_view` folds it
without the resolution, so a client that emitted it would keep improvise-sihr's
behaviour. Not live — `keys.rs` emits no `ChangeMode`, and `apply_command` never
folds a view-half command into the host's own slice — and not closeable here: a
client holds a `GridCache`, not a `GridLayout`, so records-ness is not a fact it
has. Named as a gap, with the shape that would close it.

And the improvise-9c9o re-base costs one extra `view_frame` per batch that both
switches and moves geometry, which the prose now states rather than leaving for
the next person to measure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 03:59:44 -07:00
Edward Langley 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
2026-09-21 03:53:37 -07:00
Edward Langley d9cb16c397 Review round 2: the narrowing bought its speed with a correctness hole
Merge of `gh/main` at 8bb1d53 (#83), plus the four findings from the adversarial
review of #78. The P1 is the one that matters, and it is mine.

1 (P1). improvise-mcq4 narrowed the cursor's frame fingerprint from every view
to the active one. I measured that the narrowing moved 12.41ms to 12.56ms, kept
it "as the honest shape", and reported that it bought nothing. It bought a
multi-client invalidation: a `CellData` write that prunes a row out of ANOTHER
client's view became invisible, because `view_shapes` hashes the `prune_empty`
FLAG rather than which rows survive. Gotcha 24's permanently-stale case.

**A measurement showing a change is free is not evidence that it is correct.**
I asked what it cost and reported the answer as though it had also answered
whether it was right.

The arms are now split by the thing that differs: `LayoutGeometry` keeps the
cheap one-view frame (`view_mut()` writes only the active view, and the
structural cases are already covered by `view_shapes`); `CellData` restores the
all-views frame. Measured here, not cited: **0%** on the held-key path, **+5.7%**
on an 18k two-view `SetCell`, scaling linearly in view count. Trade accepted and
recorded on improvise-mcq4 along with the absolute — this PR still roughly
doubles held-key latency on large pruned documents.

**Writing that regression test found the bug that was hiding it.** The
precondition failed: `project::grid_in` built a non-active view with
`GridLayout::new`, which does not apply `prune_empty`, so a bystander was
projected three rows where its own config gives two. Its grid never lost a row,
so the staleness was unobservable — a bug hiding a bug, and the THIRD instance of
the `new`-versus-`with_frozen_records` trap. Fixed; broke no other test. Filed as
improvise-t6db because the finding outlives the one-line fix.

2 (P2). `frame_basis` was taken once at batch open and compared across a
`SwitchView`, which is `ViewOnly` and so did not re-base it — the next model
effect hashed the new view against the old view's value. It is keyed by view name
now and re-bases when the name moves.

3 (P2). The `drill_state.is_none()` guard on the free opening basis was correct,
load-bearing, and tested by nothing — reverting it left all tests green. It has
exactly one test now, and removing the guard fails that test and nothing else.

4 (P3). `assert_eq!(cases.len(), 13)` claimed a totality it does not have; the
reviewer added a fourteenth effect and the suite stayed green. The comment now
says it is a count. Filed under improvise-5ubs as a fourth instance — inside the
PR that found improvise-5ubs.

THREE CORRECTIONS TO MY OWN REPORTING, all understatements:
- Disabling the observation fails **13** tests, not the ten I claimed (the review
  said 12; the P1 probe adds one).
- Moving the check after the loop: with the arms now split, moving the
  `LayoutGeometry` arm alone fails 2; the review measured 4 against the unified
  arm, and both are more than the three I named.
- `4c55341`'s subject said "Merge #81" and it was single-parent; the real merge is
  `ec236da`. Subject corrected by amend.

1456 tests. Both spellings of the comment-run script run separately. Test function
names diffed by name across the merge including `harness/`: nothing lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 22:01:07 -07:00
Edward Langley bcccfdef6a Harden the fixture that made improvise-mcq4 visible, after merging #81
Merge of `gh/main` at 0947fd0. No conflicts: improvise-5jm5's `Aggregation`
enum reaches `ui/project.rs` at three lines (one import, two call sites) and
none of them is one this branch touches. Re-checked here rather than trusted,
since `project.rs` was being reworked for improvise-mcq4 after that check was
made; the mcq4 code is in `ui/app.rs` and does not overlap.

Two things beyond the merge.

**The benchmark fixture now says not to simplify it.** `prune_empty` is the
only thing that makes the frame fingerprint walk cells, so a geometry case with
it off measures the cheap half — 17–176µs against the real 5.75ms — and reports
that as the cost. It is the reason improvise-mcq4 was nearly missed, and it is
the first thing a later reader would "tidy". The comment names the file that
carries the argument.

**`context/code/command.md` carries the fixed numbers and, more usefully, the
fact that the obvious fix bought nothing.** Narrowing the fingerprint from every
view to the active one was the ticket's first option and my own suggestion, and
it moved 12.41ms to 12.56ms, because a second view is typically fresh with
everything on `Axis::None` and costs nothing to build. That is written down,
because the next person to look at this will have the same idea.

Gate note: `--check-evidence` does not run the comment-run cap — they are two
modes of one script. Both are run separately here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:52:58 -07:00
Edward Langley 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 6978a4e and absent
after are this PR's own renames (j7v6, 8uj8, 1xf4), the two list helpers
improvise-g9jb replaced, and the two no-op guards improvise-ni6k orphaned.

Note: `git stash list` shows an `autostash` holding ci.yml, AGENTS.md and skills
files. It is not this session's — the tree was verifiably clean before the merge
— and it has been left untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:20:50 -07:00
Edward Langley 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
   8a559b2 and run in under a second. Behind it `--check-evidence` also
   failed, with two orphan pairs that are the direct product of my own
   renames: the consolidation renamed two tests and left their evidence
   naming the old names. All thirteen fixed. The renamed entries are
   rewritten rather than relabelled — each now records what the rename was
   FOR, which is the thing a relabel would have thrown away.

3. MY SortData CONCLUSION WAS WRONG, and the mechanism I removed was
   needed. I reported "could not build a fixture where SortData reorders".
   It reorders trivially. The cause was not the reviewer's guess (already
   sorted insertion) but better: ToggleRecordsMode pushes effect::SortData
   as its FIRST effect, so a records-mode fixture built by pressing R has
   an already-sorted store BY CONSTRUCTION and cannot demonstrate anything
   order-sensitive. My store was fine all along.

   The `layout.records` coords hash is restored, and
   `sorting_the_store_in_records_mode_invalidates_the_cursor` pins it with
   a fixture that sets the axes directly. It asserts the row COUNT and the
   LABELS are both unchanged, so it is the sharp case rather than a shape
   change wearing its name. Removing the hash again fails that test and
   NOTHING ELSE IN THE WORKSPACE.

   The process error is the part worth keeping: I concluded "cannot
   reproduce" from ONE fixture without varying it, then filed that
   conclusion as a finding. "I could not reproduce it" and "it does not
   reproduce" are different claims and I filed the second having earned
   only the first.

4. THE page_selections COMPONENT IS DELETED. With prune_empty off a page
   flip renumbers nothing and the cursor was still sent home — PR #76's own
   ruling is that this is worse than the hole improvise-7kvw names. My
   comment defended it with a basis argument for a cursor decision, which
   is how it got written. `paging_with_nothing_pruned_invalidates_no_cursor`
   is the negative arm that was missing; restoring the three lines fails it
   and nothing else, and the positive arm passes either way because
   `with_frozen_records` already applies pruning — which is exactly why the
   negative arm had to exist.

ALSO: context/code/command.md gains "Two observations, not one". My ni6k
reasoning lived only in a commit message, which design-principles.md §6
explicitly forbids — "a reason recorded only there is a reason nothing can
ever find wrong". `view_frame`'s doc pointed at a section that did not
exist; now it resolves. The stale "since improvise-9mo9 they call
reset_cursor" sentence is corrected: no effect calls it any more.

Finding 2 (the #77 merge conflict over AxisEntry::GroupEnd) is NOT in this
commit — #77 lands first and I merge main afterwards, handling the variant
deliberately rather than letting it fall into a `=> {}` that would make a
frame change invisible to the cursor.

Filed, not fixed: improvise-5ubs (a cyclic-successor all() can omit a
variant while its doc says it cannot — the reviewer's meta-finding, that a
consolidation owes a test its surviving derivation is TOTAL) and
improvise-9v0j (view_frame vs view_shape separated by example, not by
property). improvise-gfvj closed with the correction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:03:40 -07:00
Edward Langley 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
2026-09-20 16:54:57 -07:00
Edward Langley 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
2026-09-20 16:33:56 -07:00
Edward Langley db68b0ee5f Merge main into docs/orchestrator-doc-pass
Two conflicts. .beads/issues.jsonl restored to main's export so the branch
carries no beads diff. context/code/command.md: main rewrote the
Sessions::apply paragraph (now #[cfg(test)], improvise-s17o), which
supersedes this branch's 'verified at this commit' note; main's text is
kept, minus its closing pointer telling the reader to read
design-principles.md §5's 'Sessions::apply' as 'apply_checked', because
this branch corrects §5 itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:55:28 -07:00
Ed Langley 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
2026-09-20 12:23:56 -07:00
Edward Langley 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>
2026-09-20 12:17:20 -07:00
Edward Langley 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>
2026-09-20 12:15:14 -07:00
Edward Langley 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>
2026-09-20 12:07:09 -07:00
Edward Langley ed412a0256 fix: review round — the Ok-but-no-op resets, the measure regression, and a
loop-of-one test

F1 (HIGH). Gating the reset on Ok is not the same as gating it on "something
changed". Six accepted commands changed nothing and reset every cursor anyway:
SetAxis to the axis already held, HideItem on an already-hidden item, ShowItem on
a never-hidden one, AddItem of an existing item (Category::add_item dedupes and
returns Ok), and RemoveItem/RemoveCategory of something absent. Each now asks the
state before mutating. The case that made it urgent needs no second client:
TileSelect binds `r` to set-axis-for-tile row, which emits SetAxis with no
comparison against the current axis, so `r` on a Row category sent the user's own
cursor to the origin.

F2 (REGRESSION this PR introduced). Naming a measure in the trailing `+` column
at the bottom-right of a records grid sent the cursor home: AddItem now resets,
inserts_a_column matches only NewCategory, and EnterAdvance at the bottom-right
returns AbortChain with no SetSelected. The restore moves to BEFORE the advance,
where an aborting advance cannot skip it -- gotcha 21's rule applied to the
cursor rather than the mode -- which also subsumes the +cat arm's copy.

F3. a_refused_model_command_invalidates_no_cursor named a class and tested one
member; injecting reset_cursor into AddCategory's Err arm left it green. Now a
table over all three refusable effects, and that injection fails it by name.

F5. The benchmark could not reproduce its own figures: it took the mean over a
few iterations, and review measured the same share at 8% and at 131% -- a part
slower than the whole containing it, printed flat. It also passed (false, 2)
where grid_of passes the view's (true, 0), so it timed a call that never happens.
Now min-of-N with the view's own format, and a share above 100% prints UNSTABLE.
Figures are stable across runs at ratio 1.2-1.5x, col_widths 45-49%.

F6. The drg counts omitted the 8 simple_effect! invocations and misclassified
AddItem at base through a regex that cannot see `app` newline `.model_state` --
doctrine(derive_then_verify) catching the person citing it. Correct: 57 effects,
narrow set 32 -> 25, not 25 -> 17 of 49. The direction holds and is still the
argument; the headline was overstated by about half again. Corrected in
context/code/command.md and on the bead, not quietly restated.

F9. a_model_command_that_leaves_the_active_view_alone_broadcasts_nothing asserts
a ResetCursor broadcast, so the name read as a lie: ..._broadcasts_no_switch.

F4 filed as improvise-pdvm: AddItem/RemoveItem declare CellData so layout_version
does not bump while cursor_epoch does, and this PR's own reshape assertion is the
first evidence for it. Related to improvise-75ld, same question other direction.

Faults injected for every new guard: SetAxis's moved check, AddItem's fresh
check, AddCategory's Err arm, and the pre-fix advance ordering ((0,0) vs (0,3)).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:58:03 -07:00
Edward Langley 7368467ce9 test: prove the drill's cursor ordering instead of arguing it
improvise-9mo9 made SetAxis reset the cursor, and DrillIntoCell issues eight or
so SetAxis effects. The PR claimed that was safe and did not test it: the claim
was that SwitchView precedes the batch, so reset_cursor writes the ACTIVE view's
cursor -- by then the freshly created _Drill one -- while the view drilled from
keeps its entry in ViewState::cursors. That is an ordering argument about effects
inside a batch, which is the class of claim gotcha 23 exists because people get
wrong, and I had flagged it to the reviewer as unverified.

It holds. Two faults injected: removing SwitchView from the batch fails the
non-vacuity guard ("the drill did not switch views, so this proves nothing"), and
making reset_cursor clear the whole cursors map rather than the active entry
fails on the origin view's cursor (left: (0,0), right: (1,1)).

Both sides are asserted on purpose. That _Drill opens at its origin is half the
property; that the other view keeps (1,1) and row_offset 1 is the half that would
have broken, and a test asserting only the first passes against an emptied map.

The cursor_epoch half stays argued and now says so in context/code/command.md: a
drill bumps it once per SetAxis, adopt_cursor_epoch compares before and after per
command so it relays once rather than eight times, and DrillIntoCell is key-path
only today, so no client sees it. That changes when drill becomes addressable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:48:09 -07:00
Edward Langley 942e690601 test(active-view): close the three guards review found missing
All three were demonstrated by injection against a green suite — this repo's
characteristic defect, and the PR's own evidence table asserted coverage it did
not have.

- `ClientSession::view_in` had no guard: pointing it at the host's view left
  1313 tests green. Only `grid_in` was checked; `category_tree_in` and
  `tile_bar_in` were not, and the tile bar is the control a user reassigns axes
  with. `a_session_on_another_view_is_projected_that_views_axes` closes it.
- `adopt_deleted_views` and `apply_switch` had vacuous audience assertions:
  both bystanders sat on the document default, where a wrongly-addressed relay
  folds to a no-op. Both now sit on a view they can be moved off, and both read
  the audience off the `Broadcast` rather than inferring it from folded state.
- `ViewBack`/`ViewForward`'s `EffectKind` had no guard — reclassifying either
  was silent. `walking_the_view_history_moves_no_document_state` asserts the
  consequences (`dirty`, `layout_version`) rather than the declaration.

Four follow-ups filed: improvise-w3bf, improvise-pubt, improvise-h074, and the
reachability trigger appended to improvise-lltt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:35:26 -07:00
Edward Langley 233a5cc635 fix(core): one resolution of a records cell, so the producer stops guessing
improvise-tom1. The user has ruled that a remote client must NOT see another
client's staged drill edits, so the invisibility is correct and was never the
defect. What remained was that three places answered "what text is in this
cell" — and only two of them still do.

GridLayout::records_value is now the resolution: it returns the CellValue, and
records_display is one to_string over it, so the text can never disagree with
the value it spells. project::cell_display used to call records_display and
then GUESS numeric-ness with text.parse::<f64>() — the one place the producer
guessed rather than knew, and it was wrong: a records coordinate column holds
an item NAME, so a Year category with items 2024/2025 projected a coordinate
the browser right-aligns as a number. Nothing in the suite could see it,
because every records fixture in the tree names its items Region/East/North.

A correction to the ticket worth more than the code: src/ui/grid.rs is NOT a
third answer. Since improvise-anr9 the TUI grid takes its text off GridCache
and then overlays drill_state.pending_edits — one derivation plus a
session-local overlay, which is exactly what the ruling wants, and
App::display_value_of does the same for the editor. The ticket was written
before anr9 landed.

Both halves of the ruling are now asserted, with the leak injected as the
fault: teaching the producer to prefer pending_edits, which is the plausible
"fix" the ticket exists to forbid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:19:00 -07:00
Edward Langley fb41255c49 docs: quote the benchmark as ranges over two runs, not one run's numbers
A second run of benches/grid_projection put the 'large' row's col_widths share at
45% where the first said 64%. 45-46% is what reproduces; 64% was noise, and a
single-run number written down as a fact is the same defect as an argument
written down as a measurement -- which is what the benchmark existed to avoid.

What does not move between runs is what the decision rests on: ratio stays
1.2-1.5x and col_widths stays at roughly half the frame. improvise-jmjz's title
and notes corrected to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:18:28 -07:00
Edward Langley 72b0d09119 docs: finish the reduce_full deletion in the prose that outlived it
Self-review caught three references the deletion commit missed, which is gotcha
48's rule arriving on schedule: when a fact is stated in more than one place,
changing it is a grep, not an edit.

- context/code/command.md still had a `### reduce_full / reduce_key` section
  calling it "the signature the three hosts share" -- the exact claim the ticket
  was filed against, two hundred lines below the paragraph that corrects it.
- ADR 001 stated in the present tense that apply_command/reduce_full do not
  check, and recorded improvise-ondj giving both a Checked signature without
  saying that one of them has since gone. The ADR now says why deleting beat
  typing: having no caller was the condition that let the defect hide, and a
  Checked on a function nobody calls fixes the type and not that.
- The test-evidence entry's "no longer compiles" is now "no longer exists".

Also records the AddItem/AddItemInGroup success gating and the four effects that
reset unconditionally because they cannot report a miss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:14:28 -07:00
Edward Langley 93f795291e fix: a refused add must not invalidate anyone's cursor
Self-review of improvise-9mo9's own commit. AddCategory was gated on success and
AddItem/AddItemInGroup were not: the reset sat after the `if let Err`, so a typo'd
category name bumped cursor_epoch and broadcast Command::ResetCursor to every
client for a change the document never made. The epoch is document-wide, so the
cost was paid by everyone.

SetNumberFormat's negative arm cannot see this -- it tests a command that succeeds
and reshapes nothing, where this is one that reshapes nothing because it was
refused. a_refused_model_command_invalidates_no_cursor is the second arm, proved
by moving the reset back outside the match and watching it fail on the bystander's
cursor.

Also drops apply_command's now-dead `Command::Project(_)` arm: since the fallback
stopped folding, both returned Dirty::NOTHING and the first was unreachable
duplication.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:13:35 -07:00
Edward Langley 3d8ea00872 docs: decline improvise-drg's Effect::apply narrowing, with counts
improvise-drg proposed narrowing each Effect::apply to the slice it touches. It
was filed with three unpicked mechanisms and a note to decide before claiming.
Declined, and the counts are the argument.

Over the 49 impl Effect blocks: 8 touch only view_state, 9 only model_state, 32
cross-cut. At this wave's base commit the split was 9 / 16 / 24, so the narrow
set fell from 25 to 17 in one wave -- and the mover was improvise-9mo9, earlier
on this branch, which made nine reshaping effects call app.reset_cursor(). That
is architectural, not incidental: a reshape lives in model_state.workbook and the
cursor it invalidates lives in ViewState, so the effects that matter most are the
ones that reconcile the two slices.

Three more grounds. The type-level documentation already exists and is checked --
Effect::kind() has no default and apply_effects reads that one answer three times
-- so a narrowed signature would be a second statement of the same fact, free to
disagree. Every offered mechanism weakens a guard: three traits need three queues
(destroying batch order, which gotchas 21/23/43 lean on) or an enum wrapper (a
fourth classification beside EffectKind), and default methods give `apply` a
default whose wrong answer is silent, which is exactly what kind()'s no-default
rule exists to prevent. And finer-grained effect tests are not blocked: the 64
tests in effect.rs already drive apply against a one-line App fixture.

Approach (B) -- rebinding the slice at the top of a body -- stays available as a
per-function style choice and is not mandated.

The bead carries the `human` label, so this is recorded as a recommendation with
its evidence rather than settled unilaterally.

Closes improvise-drg

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:11:32 -07:00
Edward Langley 69fe27e1d6 fix(active-view): close the deleted-view hole and unify view resolution
Two findings from running the code-review skill on this branch.

`Workbook::delete_view` used to move the document's active view and
`adopt_active_view` pushed the result into every mirror, so deleting the view a
client was on moved that client. The split removed both, leaving that client
naming a view the document has not got — projected the default's grid while its
own slice says otherwise. `Sessions::adopt_deleted_views` answers it, observed
rather than predicted: the check is whether a mirror still names a view the
document has, not whether the command was a `DeleteView`.

That needs a one-session audience that is not the asker, so `Broadcast::ToAsker`
becomes `Broadcast::To(SessionId, Command)` and `Host::relay` reads the address
off the message rather than taking `from`.

And "what does this name name in this document" was answered twice — in
`App::view_name` and again inside `project::grid_in`. `App::resolve_view` is now
the one home both go through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:09:14 -07:00
Edward Langley 8a4ee06570 bench: measure grid_of's whole-grid cost, and decline the narrowing it was for
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>
2026-09-20 11:07:30 -07:00
Edward Langley 10cebfffd9 docs(active-view): record the split, its evidence, and the two gaps it opens
`context/code/command.md` gains the derivation for improvise-jytm: what moved,
what projects per client, why `effect::SwitchView` is `ViewOnly`, and why
`adopt_active_view`/`Broadcast::ToOthers` are deleted rather than left.

`context/test-evidence/command.md` and `clients.md` follow the tests that were
replaced, each entry naming the fault injected to prove the new check can fail.

Two gaps filed rather than smuggled in: improvise-lltt (a view-scoped
`ModelCommand` names no view) and improvise-azhb (`Initial View` no longer
follows the TUI user).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:06:18 -07:00
Edward Langley dc2f4a06ad docs: complete the corrections, per adversarial review of this PR
Four findings, all verified against the tree before acting.

F10 — this PR's own edit to design-principles §5 made a sibling note in
context/code/command.md false: it said §5 "still names Sessions::apply" and
recommended apply_checked. A PR whose whole subject is stale prose had left a
new stale copy behind, which is gotcha 48 exactly ("changing it is a grep, not
an edit"). Note deleted; the verified claim above it stands.

F11 — §5 named both apply and apply_checked as "the one door". apply has no
production caller anywhere in the workspace; Host calls apply_checked. Naming
two weakens the claim §5 makes, so it now names the one production uses.

F12 — gotcha 17 said "four call sites". There are six uses across the two
evaluators: a div/0 guard plus the Eq and Ne arms in each. In a file agents
follow literally, a reader who greps and finds six cannot tell whether the
prose or the code moved.

F13 — gotcha 17's sibling said the Dirty::of doc comment "keeps only the
one-line reason LayoutGeometry is spelled out rather than EVERYTHING". It says
no such thing: it points at context/code/command.md, and the EVERYTHING reason
is a two-line comment inside the match body. A correction to prose that
asserted the negation of the code should not itself misdescribe the code.

Two findings deliberately not fixed here and filed instead: improvise-ahdf
(repo-map gotchas 36 and 43 carry the same stale Sessions::apply claim —
pre-existing, and inside long paragraphs that need reading rather than a sed)
and improvise-rm4s (--check-evidence accepts headings naming tests that never
run).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:06:10 -07:00
Edward Langley 5b7b53466f feat(active-view): split the document's default from each client's view
improvise-jytm, improvise-6c6q, improvise-k69y.

`Workbook::active_view` becomes the document's *default* — what a freshly
connected client opens and what `.improv` writes as `Initial View` — and
`ViewState::active_view` becomes authoritative per client. Two clients may sit
on different views of one document: the model is shared, the UI is not.

- `App::switch_to_view` writes only the client's slice; `App::view()`/
  `view_mut()`/`view_name()` resolve it against the workbook.
- `effect::SwitchView`/`ViewBack`/`ViewForward` are `ViewOnly`, which takes
  `ViewBack`/`ViewForward` off `ModelCommand` — their operand is one client's
  history stack (the `ApplyAndClearDrill` argument).
- `grid_in`/`category_tree_in`/`tile_bar_in` project against the session's view;
  `ClientSession` observes its own move through `projected_view`.
- `apply_switch` takes a `SessionId`, moves only the asker's mirror, and relays
  `Broadcast::ToAsker` only when the document refused. `adopt_active_view` and
  `Broadcast::ToOthers` are deleted: nothing produces them any more.
- `draw_status` reads the client's copy (improvise-k69y).

`the_two_active_view_names_never_diverge` is replaced, not deleted, by
`the_documents_default_view_outlives_this_clients_switches` and
`a_fresh_session_opens_the_documents_default_view`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:02:59 -07:00
Edward Langley c8ebe3cae6 refactor: delete reduce_full and stop apply_command folding a client's view slice
ui/reduce.rs claimed every deployment mode reached the model through it. That was
aspirational, and it is what let improvise-ondj's missing basis check sit
unnoticed: reduce_full had no production caller anywhere in the workspace, so
nothing exercised its Command::Model arm and nothing could notice it skipped the
check.

Deleted rather than wired up, on the argument in its own doc comment: "Per-client
grid updates need a viewport and come from Sessions::project_all." It returned
half of what a host needs. The browser worker was the natural candidate caller and
reaches the model through Host::handle -> Sessions::apply_checked, which is the
half that has the viewport. Its only caller was a test helper, which now spells
the two lines it was.

apply_command's fallback arm no longer folds. It routed a wire view command into
app.view_state -- the authoritative slice -- which nothing in production did and
nothing should: Sessions::apply_checked folds it into the asking client's mirror,
because the model is shared and the UI is not. A client's SetStatus moving the
host's status line was behaviour that was safe only because the arm was dead.
view_slice_commands_land_in_the_authoritative_view_state pinned exactly that, so
it is inverted rather than deleted, and its fixture writes a value into the status
line first so the assertion can tell "the fold was removed" from "the field was
empty anyway". Restoring the fold fails it.

Sessions::apply is now #[cfg(test)]. Host::commands answers the whole frame with
CheckedBatch::against, so a door that answers one command and then dispatches is
the lost update improvise-955n fixed; cfg(test) makes "no production caller" a
fact the compiler keeps rather than a note verified at one commit.

Closes improvise-s17o

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:00:25 -07:00
Edward Langley 0e193ec5c8 fix: reset the cursor in every effect that renumbers the grid
Only TransposeAxes and CycleAxis reset the cursor, while eight siblings that
renumber just as thoroughly did not. SetAxis is the sharp case: CycleAxis *is*
SetAxis to Axis::next(), so a pair differing only by an argument disagreed.

reset_cursor is also the only writer of cursor_epoch, which is the only thing
Sessions::adopt_cursor_epoch watches, so each gap was invisible in both
directions at once: the host's own cursor stayed stale and no client was told.
Gotcha 24 records that the second half is strictly worse than doing neither.

SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory,
RemoveCategory, AddItem, AddItemInGroup and RemoveItem now reset. The item
effects are in because "an append cannot move an existing position" holds only
of the outermost category on an axis: expand_category runs inside the cross
product, so an item appended to an inner category repeats once per outer block.
SetNumberFormat stays out and keeps the negative arm.

CommitAndAdvance's +cat branch depended on the missing reset — it stayed put so
the cursor would land on the column it had just created (improvise-2zq). It now
says so with an explicit SetSelected, placed after AddCategory's abort so a
refused category still leaves the batch alone.

The new test asserts of every case that row_items/col_items really changed
before asserting anything about a cursor; two faults were injected to show both
arms discriminate. Reasoning in context/code/command.md, evidence in
context/test-evidence/command.md. SetPageSelection under prune_empty is the one
residual and is filed as improvise-7kvw.

Closes improvise-9mo9

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 10:57:34 -07:00
Edward Langley 17ad0f1d1d docs(command): move command/keymap.rs and cmd/** arguments to context/
keymap.rs: the dispatch/registry-vs-table split (this file stays the authority
on it) and the improvise-0hb Sequence-flattening decline, with its three
findings.

cmd/**: the two CommitAndAdvance orderings, CommitSave's mode-change-first,
add_category's deliberate no-coordinate-write, EditOrDrill's mode-agnostic
message choice, the SaveAndQuit and CreateAndSwitchView declines, and fit.rs's
whole module doc including the measured wasm bundle cost.

crates/improvise-command now reports no comment run over two lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 10:28:02 -07:00
Edward Langley 629f54919f docs(command): move ui/host.rs, viewport.rs and cat_tree.rs to context/
host.rs: the one-App-per-document argument, why ClientFrame::Key answers
nothing, why Host::save bypasses Sessions, and the broadcast-before-projections
ordering. The 955n evidence -- including arm 3's check-before-apply trade that
clause 4 must pay back -- goes to the evidence file.

viewport.rs/cat_tree.rs: the residue of two derivations that moved to
improvise-protocol, and why each residue is the shape it is.

ui/ now reports no comment run over two lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 10:28:02 -07:00
Edward Langley e9b800d7a9 docs(command): move ui/project.rs's arguments to context/
The diff-not-declare rule, Dirty::of's derivation (now recorded in
context/code/command.md, which gotcha 35 should point at rather than the doc
comment), axis_frame's three group-header edge rules, the Broadcast audience
argument and Applied's three cases all move. Test narratives, including the
injected faults for hkt6 and the renamed-axis case, go to the evidence file.

One orphaned doc paragraph dropped: a struct doc describing a type that no
longer exists at that site had fused with fn diff's. Noted in the evidence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 10:28:02 -07:00