4 Commits

Author SHA1 Message Date
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 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 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
2026-09-20 20:45:34 -07:00