A collapsed group is written to CSV as its own total row, because an export
writes what the grid draws (improvise-iot6). core.md §14 carries the argument;
the evidence entries record each rival and where it failed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
improvise-k84e, scoped to what PR #109 left: a frame whose LoadModel
SUCCEEDS, rebases every mirror and emits Snapshots, and is THEN rolled back
by a later command in the same frame.
The document half was already covered by
a_rolled_back_load_leaves_no_ghost_view_and_no_false_status. This covers the
Sessions half, which nothing touched: mutating_frame clones the whole
Sessions before the batch and undo_frame puts the clone back, so the reseed
is undone with it and the computed Snapshots are discarded rather than sent.
It reproduced nothing — the mechanism is sound, as the bead predicted. Its
value is the fault it can see. Injected: `self.sessions = mirrors;` replaced
by `let _ = mirrors;`. The test failed on the projection assertion, naming
the asker's mirror and printing the whole correcting batch — the restored
document being re-sent to a client left holding the discarded one, which is
a client hearing about a load that never happened.
Three things keep it non-vacuous: a separate control asserting the fixture's
load really does rebase both clients, the generation check made before
anything is concluded from the rollback, and a bystander seated where a
wrong relay could move it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
Conflict was context/test-evidence/command.md only — the third evidence-file
conflict in this batch, since every agent appends at EOF. Both sides kept and
verified line-by-line against both parents.
1727 passed / 0 failed over 38 binaries.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
`Dirty::of(ModelChange::CellData)` reports `formulas: true`. That was deliberate
over-reporting in the cheap direction, costing one redundant `Projection` and
buying nothing — until improvise-g3no made `Model::remove_item` drop the formulas
targeting the item. `effect::RemoveItem` is `CellData`, so a deletion can now
take a formula out of the document, and tightening that arm would leave every
connected client's formula panel naming a formula the document no longer has,
forever, with nothing in the stream to correct it.
`removing_a_category_empties_the_clients_formula_panel` covers the
`LayoutGeometry` twin only. This is its item-level sibling.
The setup is deliberately not a mirror of the sibling's. Written that way —
connect, `AddFormula`, `RemoveItem` — it does fail under the rival, but on the
SETUP assertion, because `AddFormula` is itself `CellData` and with the arm
tightened the formula never reaches the client at all. That answers whether
`AddFormula`'s projection arrived, which is the neighbouring question. So the
formula is added before `Sessions::connect` and the client takes it from the
bootstrap seed, which no `Dirty` gates; the rival then lands on the assertion
that means something. The sibling does not have this problem against its own
rival and is left alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
`law_toggle_group_involution` called `execute` twice against the SAME immutable
`CmdContext`, so it asserted `f(x) == f(x)` for a deterministic `f` — an
assertion no rival of any kind can fail. It was vacuous twice over: its fixture
`two_cat_model()` has no groups, so both sides were the empty string.
Applying the effects between the calls, which is what the bead asked for, does
not rescue the law — it refutes it. A collapsed group contributes no
`AxisEntry::DataItem`, and `row_group_for` is indexed by data row, so with two
groups on the row axis and the cursor at row 0: `z` collapses Q1, `z` again
names Q2 (the neighbour, not the group just collapsed), and a third press
returns nothing because the axis has no rows left.
So this replaces the law with three tests of what the command does, each naming
a category and a group by name. Both rivals were run: an `execute` that always
returns `ToggleGroup { Month, Q1 }` — the stateless one the old test exists to
miss — passes the old law and fails two of the three; an `execute` returning
`vec![]` fails all three.
The behaviour those tests now pin is unchanged and is filed as improvise-6qet:
a collapsed group is drawn but not addressable, so there is no way back to it
from the grid. It is the third bead in that family after improvise-lhi4 and
improvise-0o6x, both of which fixed the header's visibility rather than its
addressability.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
`view-forward` was bound in NO keymap, in either front end, from the day it was
written. The TUI reached it as `:view-forward`; a browser could not reach it at
all, because `declined()` refuses to enter CommandMode at the door. It survived
because pressing `<` twice is the only way to observe the forward stack, and
until PR #115 no client maintained one.
THE KEY IS `,`, AND `>` IS WHY THE OBVIOUS ANSWER IS WRONG. `<` is `view-back`
and `>` is `drill-into-cell`, so the pair that LOOKS symmetric is not one: `>`
moves within the grid, `<` moves between views. Binding the forward half to
anything reading as `>`'s partner would deepen the confusion that hid the gap.
`,` is the unshifted twin of `<`, so both halves of the history sit on one
physical key, and it carries no direction-in-the-grid meaning.
IT IS DELIBERATELY NOT A CTRL- KEY. Ctrl-R, the universal "forward through a
history" spelling, is browser reload. The keymap is SHARED between the two front
ends, so a key chosen for the terminal is one the browser must live with, and
improvise-clso is already open for exactly this against Ctrl-S.
The absence assertion PR #117 placed in
`the_forward_step_mirrors_its_cmd_and_no_key_reaches_it` is INVERTED rather than
deleted: renamed `..._and_one_key_reaches_it`, it now asserts the binding exists,
keeps `view-back` as the control, and pins the key itself so the binding cannot
drift while the presence check stays happy.
Reasoning: context/code/tui.md.
Evidence: context/test-evidence/{command,clients}.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
Conflict was context/test-evidence/command.md only: improvise-gjat (PR #144)
and improvise-lm5e appended different sections at the same point. Both sides
kept — the entries are disjoint, 16 from lm5e and one from gjat — and verified
line-by-line that neither side lost content, because a clean-looking textual
resolution is exactly how evidence entries go missing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
`App::switch_to_view` is guarded — it writes `active_view` only if the document
has the view. `SwitchView::apply` pushed the leaving frame onto
`view_back_stack` BEFORE calling it, under the prediction `previous != self.0`.
That predicate answers "did the user ask for a different name", where the
history means "did this client go somewhere". The two differ on exactly one
input: a name the document has not got. So `:view NoSuchView` left the user
where they were with their own current view pushed onto their own back stack —
`<` then "went back" to where it already was, and the real frame behind it
needed a second `<` nobody knew to press.
The wire path was already correct and is not the model for the fix: `reduce_view`
cannot check (the client holds no `Workbook`), so `apply_switch` relays the exact
inverse `[ViewBack, ForgetView]` (improvise-6c6q). The host holds the document,
so it does not need an inverse — it needs to look. `apply` now clones the
previous name, switches, and pushes only if `active_view` actually differs
afterwards.
That is `ReturnToNormalUnlessMoved`'s rule one effect over, and the same answer
`cursor_epoch` gives instead of matching on `CycleAxis` and `document_generation`
gives instead of matching on `LoadModel` (gotchas 24, 39, 45): observe the state
that moved, never predict it from the request. It also subsumes the case the
prediction was written for — switching to the view you are already on changes
nothing — so the special case is gone rather than joined by a second one.
A guard spelled here as `workbook.views.contains_key(&self.0)` would have been
correct today and a second copy of `switch_to_view`'s own condition, free to
disagree with it the day that condition grows a clause.
Reasoning: context/code/command.md. 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
Sixteen command tests asserted `effects_debug(..).contains("<EffectName>")` and
nothing about the effect's fields, so they answered *which effect was emitted*
where they meant *which category, item, view, path, cell or axis it named*.
Run rather than argued: one patch made fourteen command sites emit the right
effect type carrying a wrong constant field — add-category naming "Wrong",
set-cell writing 0.0 at the wrong key, set-axis ignoring its argument,
`:w <path>` saving elsewhere, paste writing a constant instead of the yanked
value, the tile bar acting on a fixed category, page cycling always landing on
the first item, the panel cursor landing on 0 of the wrong panel, and the
formula and view panels deleting and switching to "Other". Against that,
`cargo test -p improvise-command --lib` reported 525 passed, 6 failed, and
fourteen of the sixteen were among the survivors. With the strengthened
assertions and the same rival: 510 passed, 21 failed.
Each test now asserts post-fold `App` state where the effect is cheap and safe
to apply, and the effect's own fields by `Debug` where it is not. Assertions
naming a fieldless effect are left alone: there the type name is the whole
claim.
One thing the ticket did not predict: reading the effects showed `TileAxisOp`
indexes every tileable category including the virtual ones, so tile cursor 0 is
`_Index` and not the first regular category. The tests now say so.
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
`law_sequence_associativity` asserted `effects_debug((a ++ b) ++ c)` equals
`effects_debug(a ++ (b ++ c))` — the associativity of `Vec::extend`, true for
any deterministic `execute`, and never applying an effect. No command behaviour
could move it. `doctrine(fold_tests_must_test_order_not_the_axiom)` names
exactly this shape.
Replaced with the property production relies on: a `Binding::Sequence` flattens
into one effect vec (gotcha 21), the batch folds onto the `App` in order, and
each `Move` composed against the same ctx emits an absolute `SetSelected` — so
the moves do not compose and the last write wins. Four orderings are asserted,
two of which land somewhere the others do not.
Two injected faults, each leaving the old assertion passing and failing the new
one: `Move::execute` skipping a move that moves nothing (old ok, new
left: (0, 1) right: (0, 0)), and `App::apply_effects` folding in reverse (old
ok, new left: (1, 0) right: (0, 0)). The second is why the batch is flattened
rather than applied per command — a first version applied each separately and
could not see it.
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
The first version of this test had the exact defect it was written to expose.
It derived the grid from a hard-coded `grid_in(&app, "Other", WHOLE)`, so the
session, the switch and `apply_checked` could ALL be deleted and every
assertion still passed. It proved a grid built for a NAME where its own name
claims a grid a SESSION WAS SHOWN -- a check adjacent to the one it means,
which design-principles S7 calls this repo's characteristic defect.
The view is now read off `sessions.get(id).view().active_view`, asserted to
have moved, and asserted to differ from the host's; the grid is derived from
that and from the session's own viewport. Injected fault: delete the
`apply_checked` line -- the test now fails with `left: "Default" right:
"Other"`, which it could not do before.
Found by the ChatGPT Codex review on the PR, which was right about this for
the same reason it was right about the two predicates on #140.
1700 passed, 0 failed, 7 ignored. --check-evidence unchanged at 824/302.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
improvise-gd28 records browser drill as blocked on the addressed command form,
because `ApplyAndClearDrill` is session-local and unaddressable. That argument
is about the COMMIT path. Entering a drill commits nothing, and after the
view-stack work already on this ticket a browser has `<` -- so what is missing
is only the step that builds `_Drill`.
Every command that step needs is already on the wire and already unaddressed:
`CreateView`, `SetAxis` and `SetPageSelection` each NAME their view
(improvise-lltt), and `SwitchView` is view-half and per-client. What a browser
cannot supply is the operand. It holds `CellId::Aggregate(AggregateHash)`, an
opaque u128, so it cannot compose the filter coordinates itself -- ADR 002
working as designed, and it points at the host resolving the surrogate.
`App::resolve_aggregate` does exactly that and is tested. This test shows it
answers only where the HOST stands. A `RecordHash` falls back to `DataStore`'s
maintained index and resolves against the document, which is why a connected
client's `SetCell` works from any view; an `AggregateHash` has no such index
and cannot have one, since an aggregate has no existence outside the layout
deriving it. So it is layout-scoped, and that layout is built from the host's
active view -- which since improvise-jytm is not the client's.
The test seats a session on a view the host is not on, takes a surrogate out of
the grid `grid_in` derives FOR THAT SESSION, and shows the host answering
`None` to an identity it had just projected. The control is what makes it
discriminating: the same surrogate resolves once the host is moved onto that
view, so the failure is the host's view rather than the surrogate. Without it a
blanket `None` would pass.
What this rules OUT is the useful part. It is not ADR 001 layer 2: a drill's
mutation is addressed to no one session -- it configures the shared `_Drill`
view exactly as `CreateView`/`SetAxis` already do -- and only its operand's
resolution is session-scoped. It is not improvise-fdk1 either: `edit_or_drill`
declines records-mode editing out loud before reaching a cell, so a browser
cannot edit inside a drill grid at all, no coordinate rename is reachable, and
no second staging scheme is created. The remaining work is one seam, and the
cheap shape is the one every other view-scoped variant already uses: let the
command name its view and resolve against a layout derived for that name.
No behaviour changes here. 1699 -> 1700 tests, the one added.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
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
improvise-z05h, three bugs stacked on one surface.
`CmdRegistry::resolve` rewrites `formula` to `add-formula` *before* the arity
check runs, so `formula Product Total = Widget + Gadget` was refused with
"add-formula requires 1-2 argument(s), got 6" — a command nobody typed, and
an arity where the user needed to be told to quote.
The name now lives in the one place that knows which of the two was written:
`require_args` no longer takes a `word` and `CmdRegistry::parse` prefixes
`"{typed}: "` onto any `Err`. That removes 41 name literals from `cmd/`,
which is the argument gotcha 15 already makes about `registry.rs`. The
`AddFormulaCmd` validator states the shape rather than the arity.
`interactive` deliberately does not prefix — `dispatch` in
`command/keymap.rs` already formats `"{name}: {e}"` with the bound name
(improvise-55l5, PR #126), and doing both would double it.
All three documented spellings of `:formula` taught the unquoted form and so
were ones the parser refuses. help.rs carried the quick-start line **twice**,
on two different pages, which is gotcha 48's "changing a fact stated twice is
a grep, not an edit".
`every_ex_command_the_help_pages_show_is_one_the_parser_accepts` closes the
surface rather than the instance: `HelpLine::Keys` rows are derived from the
keymap and cannot go stale, but `Literal` and `Accent` rows are prose with
nothing checking them, and that is the whole unguarded surface. Run against
bf83294 it fails naming the Welcome page and the exact line.
What it still cannot see, stated in `context/code/cli.md` §6:
`draw.rs::draw_welcome` builds its lines inside a ratatui render function, so
no walk reaches them; its line was corrected by hand.
Checked and not needed: none of the four asciinema casts in `docs/` shows the
`:formula` ex-command — `formulas.cast` uses the formula panel — so none
inherits this and none needs re-recording.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
improvise-vw47. Create View 2 and View 3, configure View 3, delete View 2,
then ask for a new view: both resolvers spelled `format!("View {}", len + 1)`,
which is the count and not a free name. `Workbook::create_view` replaces
whatever holds the name, so View 3's format reset to defaults and no view was
added -- a symptom that looks like nothing happened.
The name is derived at two sites, `cmd/panel.rs::CreateAndSwitchView` and
`resolve.rs`'s "create-and-switch-view" arm, so fixing one would have left the
TUI and the browser naming different views. `view::unused_view_name` in
improvise-core is the one derivation both now call. It takes an iterator of
names because the browser holds no `Workbook`, only the `ViewListCache` that
`view_summaries` maps one-for-one off `workbook.views`.
`Workbook::create_view` is deliberately untouched. Its replacement semantics is
what the `_Records`/`_Drill` rebuilders need -- both reach it through
`effect::CreateView` on every entry -- so refusing or renaming there would turn
"a view was reset" into "the key does nothing". The argument is in
`context/code/core.md` section 11.
Three tests, written failing first. The helper's own cases pin that a run of
taken names is searched rather than bumped once. The `Cmd` test uses the
ticket's fixture and asserts the count is below the highest name before asking
anything, because the pre-existing sibling test's fixtures all leave the
derived name free -- which is how the bug lived under it. `lockstep.rs`
witnesses the agreement: note that `both_ends` PASSES against the unfixed
source, both ends being wrong identically, so that test carries the count and
format assertions too.
Rivals injected: fixing panel.rs alone fails lockstep on the document text;
starting the search at 1 fails the pre-existing incremental-naming test.
Baseline 1667 passed / 0 failed / 7 ignored over 37 result lines; now 1670.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
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
Both findings from ChatGPT Codex's review of PR #125. Both are real, and both
are the same mistake in two places: answering "whose format is this" from
something other than the rule that answers "whose value is this".
**1. `FormatPlan` indexed formats in a `HashMap` keyed by target.** A target
category need not be `_Measure` — `[CustomCat]` is in the format's own
documentation — so a key can carry coordinates two formulas claim, and
iterating the map answered from whichever category hashed first: the displayed
format was nondeterministic per process while the value was not. That is
gotcha 18's shape. Worse, only *formatted* formulas were indexed, so a later
formula's `FORMAT` reached a cell whose value an earlier unformatted formula
produces.
`FormatPlan` now holds every formula's `(target_category, target,
Option<format>)` in the model's own order and takes the first coordinate match
— `Model::formula_for`'s rule and order exactly. A match naming no format falls
through to the view; it is a match, not a miss. The list is dropped entirely
when no formula names a format, so the common document still pays one
`is_empty` per cell.
**2. A column total took its format from row 0.** `context/code/core.md` argued
that was safe because "where `_Measure` is on the row axis a column is not a
measure at all". Wrong: a formula's target category is any category, so a
formula can own a row-axis item — the total then inherited a row-local override
for a row it had summed away, and re-ordering the rows changed its spelling.
New `GridLayout::column_key(col)` gives the coordinates that survive the row
aggregation (page/filter constraints plus the column's tuple), and `col_formats`
is resolved from that, once per column, outside the row loop.
Three regression tests, each with the rejected version injected as the fault:
- `the_formula_that_owns_a_cell_names_its_format_even_when_it_names_none` puts
the same two formulas in both orders, so preferring the formatted one fails
one half and ignoring formats fails the other.
- `the_owner_is_the_one_model_order_names_and_not_the_one_a_hash_does` rebuilds
the plan five times, since each `HashMap` gets its own `RandomState`.
- `a_column_total_does_not_inherit_a_row_local_format` builds one grid with the
owned row first and one with it last and requires the strips to agree. Every
raw cell holds the same value per measure, so the totals are numerically
identical and only the format can differ.
`context/code/core.md`'s wrong paragraph is replaced rather than amended, and
says what it got wrong.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
improvise-e0ex. `Margin = Profit / Revenue` drew as `1`: `View::new` seeded
`,.0` — zero decimals — so a 0.66 ratio rounded half-away-from-zero. The cause
is the format, not the arithmetic; exact decimal math would not change the
rendering by one character, so nothing here touches the evaluator and gotcha 8
is unchanged.
Two halves, per the user's ruling:
* `View::new` seeds `,.2`. Revenue draws `18,400.00` and Margin `0.66`. The
noise on whole numbers is the cheaper error — a currency column with two
redundant zeroes is legible, a ratio with none is wrong — and a format cannot
be guessed from a value.
* A formula may carry its own format, which wins for its target; a raw measure
keeps the view's. `formula.pest` gains
`format_clause = ${ ^"FORMAT" ~ sp+ ~ format_spec }`, with `sp+` for `WHERE`'s
reason (a predicate is invisible to the grammar-walking generator) and
`FORMAT` added to `quote::KEYWORDS`.
`improv.pest` is deliberately unchanged: `formula_raw` already carries the
`## Formulas` line verbatim, so the format rides in the formula text with
nothing to add — and the generator, which reads `improv.pest`, sees no new
spelling to violate.
Resolution has one home. `FormatPlan::of(model, view)` replaces the
`(comma, decimals)` pair every render path threaded — `GridLayout::display_text`,
`viewport::grid_body`/`compute_col_widths`, `project::grid_in` and
`persistence/export.rs::render_csv` — rather than being added beside it, which
would be two answers to "what format is this cell". It is asked of the cell's
`CellKey`, so a cell aggregating over `_Measure` keeps the view's format by
construction. `GridBody::col_formats` carries the answer to the totals strip,
which is formatted on its own path.
Persistence is untouched except for metadata: `format_f64` is display,
`persistence/format.rs::format_number` writes round-trip precision, and the two
have no call between them. Newly created views write `format: ,.2`; existing
documents are unaffected, because `format_md` has always written the line
explicitly. `web/demo.improv` moves to `,.2` on both views.
The trap this sets is gotcha 34's: `format_md` writes `Formula::raw`, so a
`FORMAT` suffix round-trips whether or not the walker parsed it. The round-trip
test therefore asserts the parsed field — injecting a walker that drops the
clause fails that one test and nothing else in `improvise-io`, 242 tests
including the whole characterization suite and the grammar properties.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
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
A ChatGPT Codex review of #114 found the cursor fix wrong for a category with
exactly ONE item, and it is this PR's own argument turning against it: that is
the configuration where the conditional retreat degenerates into the
unconditional step-back the PR argued was worse than the bug. No sibling below,
so it stepped back onto the category's own header -- one d from RemoveCategory.
Reproduced on both paths first, asserting the CONSEQUENCE rather than a landing
index: rebuild the post-deletion tree, seat the cursor where the command put it,
and ask whether a second d composes a RemoveCategory. It did, on both.
The enumeration is what makes the second fix total. CatTreeEntry has two
variants and flatten_cat_tree emits a header then its items, so after deleting
the item at i: below is a sibling -> the list closes up, stay; above is a
sibling -> step back; NEITHER -> the category is now empty and both neighbours
are Category rows, so there is no safe ADJACENT landing at all. That third case
is structural, which is why the rule could not be patched in place.
The landing is therefore the nearest Item row, preferring above then below --
never a header. Cost, stated rather than hidden: the cursor may cross into
another category, and a held d then deletes one item per press, each with its
own status line, walking upward. It never reaches RemoveCategory, which is the
criterion the review asked for. Landing on the emptied category's own header
was rejected even though RemoveCategory there is cheap -- an empty category has
no cells, and a non-measure formula target IS an item, so no formulas. That is
a harm-magnitude argument where the repo prefers the bad state unreachable.
Residual, stated: if the deleted item was the only item in the whole tree there
is no Item row anywhere and the cursor does not move. Every category is empty
then, so no landing exists that would avoid it.
Both fixtures are fenced so they cannot pass for the wrong reason. The browser
one expands BOTH categories, because a first version expanded only the
singleton's and the cursor fell off the shortened tree, where the delete arm
answers that there is no category to delete -- green for a reason unrelated to
the fix. The TUI one seats the two-item category FIRST, so the backward search
(the preferred direction) finds a real landing.
Injected the pre-review rule on both paths: each new test fails, and the two
adjacent-case tests still pass. The singleton is the discriminating input and
nothing else in the suite sees it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
Found reviewing the commit before this one, and the defect is that commit's
own: giving clients a back stack gave them dead frames.
`effect::DeleteView` moved `active_view` off the doomed view and left both
stacks alone. That was already wrong for the TUI -- `<` could reach a frame
naming a view the document had lost -- and invisible only because no client
had a stack at all.
Worse, the guard the previous commit added made it a loop. `apply_step`
corrected a dead landing by folding `SwitchView(default)`, which pushes the
dead view straight back; `<` then popped it, was corrected, and pushed it
again forever.
`ViewState::forget_view` is the one home, called by `effect::DeleteView` for
the host and reached by `Command::ForgetView` for every client. Which views
vanished is OBSERVED by diffing the name set across `apply_command`, for the
reason `adopt_cursor_epoch` watches a counter rather than a command variant.
`Sessions::apply_step` is DELETED. The pruning makes a dead frame
unrepresentable, so the guard had no reachable input, and a guard whose fault
cannot be injected is indistinguishable from one that always passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
Self-review under the code-review skill, two findings against this branch.
**doctrine(binding_dump_diff) had not been run**, and it is the doctrine
aimed at exactly this change: a green suite is not evidence for a keymap
refactor. Diffed: 84 added lookups, 0 removed, 0 CHANGED -- no binding
anywhere answers differently. 40 of the 84 are not a binding change at all
but the widened probe space hitting ImportWizard's catch-all on F(3)..=F(12).
`:` produced zero rows, because every mode that now inherits it already bound
it locally: the duplication was real and consistent, which is also why nothing
in the suite could have caught the two spines drifting apart.
**The fallback arm was vacuous.** `shadowing_answer` returned `None` for the
six non-shadowing modes, so the test asserted only that a global key resolved
to SOME plain command -- "is this key bound", where it means "does this key
reach the global". It now answers with the global's own command name, read off
the global rather than restated, leaving Help's `?` as the only hand-written
expectation because it is the only decision.
Proved by injecting the rival fix rather than only the bug: giving TileSelect
`Arc::new(global_keymap())` -- its own equal-but-separate copy -- leaves this
test GREEN, since every key resolves to exactly the right command. Only
`every_modes_keymap_chain_ends_at_the_same_global` fails, which is the
clearest statement of why that one compares by pointer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
context/code/core.md gets the two model rules (why removing a name removes
the formulas targeting it, and why a required virtual category refuses) and
the axis rule (why leaving the constraining axes forgets the page selection,
and why the predicate is filters_data rather than 'is it Page').
context/code/clients.md gets improvise-koh6's decision in full -- including
why the state-dependence rule is about Upstream and not about outcomes --
plus the cursor rule and the shared axis sentence.
Evidence entries are module-qualified where they must be: three leaf names
are answered by a test in both resolve.rs and panel.rs, and one in both
model/types.rs and workbook.rs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
Two user rulings, 2026-09-21: the state drill manipulates (the view stack)
is local to the client, and a drill is an ephemeral view. The first one is
what this lands; the second is scoped in the hand-back.
`effect::SwitchView` pushes the view it leaves onto `view_back_stack` and
clears the forward stack. `reduce_view`'s arm set `active_view` and nothing
else -- so a connected client's own history was empty however many views it
visited, and `view-back` had no stack to pop. Both write the same portable
field, and the fold is meant to be the mirror's copy of the effect.
`reduce_view` now mirrors all three effects line for line, and `ViewBack` /
`ViewForward` join the view half of `Command`. They carry no basis and dirty
nothing: the stack is in `ViewState`, so this is UI, not document.
The case a client-local stack creates is a frame naming a view somebody else
deleted. `Sessions::apply_step` guards the landing exactly as `apply_switch`
guards a switch, correcting the asker alone.
Also: `drill_constraint` and `drill_snapshot` get one home in
`improvise-core::view`, where the addressed-drill work will share them with
`DrillIntoCell` rather than freezing a second record order (gotcha 18).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
`every_bound_command_name_is_registered` had two blind spots, both found by
injection rather than by reading, and both inside the region improvise-636d
works in.
Its walk matched `Some(Binding::Prefix(_)) | None => {}`, so it never
descended into a prefix sub-keymap: everything under `g`, `y` and `Z` was
unchecked in every mode, and twice over for RecordsNormal, which reaches
Normal's three through `Keymaps::prefix_keymap`. And its key space was a
second copy of `tests_support::probe_keys_pub`, identical but for the two
F-keys the public one carries, so a name bound on any function key was
invisible here while `dump_every_binding` probed it.
Two unregistered names -- one under `g`, one on `F(5)` -- left the workspace
byte-identical at 1595/0/7. The positive control separating "untested path"
from "broken test" is the same bogus name on `Char('~')`, which fails.
The walk is now `reachable_names`, recursing into `Binding::Prefix` and
terminating because `prefix_keymaps_are_flat` says the nesting is one deep.
`probe_keys` DELEGATES to `probe_keys_pub` rather than restating it, which is
the part that matters: two lists of "the keys a test probes" is the same
disease as two keymap spines, and the F-key gap was the drift between them.
`probe_keys_pub` now covers F(1)..=F(12) rather than the two that happen to be
bound, since the point is to see a binding nobody expected.
Acceptance is the two injections failing SEPARATELY -- a fix for one need not
close the other -- and both do.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
Integration review of 455e8614 found clause 4 delivered for the document
and the mirrors and NOT for `App::view_state`.
F1. `restore_document` said `view_state` was "deliberately untouched",
citing improvise-yqpd's note that rolling back `status_msg` would undo
an AddCategory refusal's explanation along with the damage. **That note
is about an apply_effects-level rollback, which is not what landed.** At
the FRAME level the asker receives ServerFrame::Rejected(reason) — the
explanation is delivered on the wire, addressed to the client that earned
it — so the host's status line has no explanatory duty for a client's
frame. The host's own key path is untouched and still aborts without
undoing, exactly as gotcha 21 describes.
Measured consequences, all now closed: the TUI host's cursor still moved
and cursor_epoch was still bumped; a rolled-back LoadModel left
`view_state.active_view` naming a view of the DISCARDED document; and
`status_msg` kept the rejected frame's sentence, so a frame that did
nothing claimed it had loaded a file.
The fixture had to be renamed before any of that was visible.
`host_over_a_loadable_document` built its incoming workbook with
`Workbook::new`, whose default view is `Default` — the same name the
outgoing document uses — so a ghost active_view was indistinguishable
from a correct one. Gotcha 39's trap one level up. Checked first that
neither user of the fixture depends on the names matching, and that
gotcha 39's own same-name fixture lives in project.rs and is untouched.
Restoring the whole slice cannot clobber a TUI user's in-progress typing:
`Host::commands` is synchronous, so snapshot and restore bracket only the
frame's own writes.
F3. `batch_aborted` was left true after a rolled-back frame — inert, but
the same shape as the stale-flag bug the previous commit fixed. `App`'s
doc comment names four more "last batch" fields and calls
last_reshaped_views "the shape batch_aborted takes"; restore cleared
three of the four and snapshotted none. All six now round-trip, which is
why DocumentSnapshot is renamed AppSnapshot: it is no longer only the
document.
F2 is NOT closed and is stated as scope in the evidence and the PR body:
broken_chain asks records_named, so a frame can still apply partially and
silently through a command that names no record.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
Both were in the code that had just landed, and both are the repo's
characteristic defect: a check adjacent to the thing it names.
1. `broken_chain` carried a create-exemption copied from the pre-frame
check, and the hand-back recorded that arm as "correctness-preserving but
unobservable" because removing it failed no test. Wrong in both halves.
`[AddItem(West), HideItem(West), SetCell(West)]` observed rejected=false
landed=false — the frame reported ACCEPTED with the write silently gone,
which is exactly the partial-and-silent failure clause 4 exists to
eliminate, surviving inside clause 4. `resolve_cell` is gated by
`resolve_record`, so a non-resolving record is precisely the condition
under which `model_effects` returns an empty vec; and SetCell/ClearCell
are the only variants `records_named` names, so refusing is strictly
better than dropping. The exemption belongs at CHECK time — where a
record the frame will create legitimately does not exist yet — and
nowhere else. kb.pl's the_create_exemption_is_a_property_not_a_list is
unchanged: it describes `stale_records_in_a_frame`, which keeps it.
2. `batch_aborted` is reset ONLY inside `apply_effects` (gotcha 21), and
the frame loop read `last_batch_aborted()` after every command. A command
running no effects — every view command, and SwitchView, which
`apply_checked` routes past `apply_command` — leaves the flag where some
earlier batch left it. So a TUI `:w /bad/path` made the next frame a
browser sent refuse itself. Guarded per command, with a mixed-frame arm
because frame-level gating alone does not reach `[SetSelected, SetCell]`.
The same Command::Model-presence predicate now spares a frame of pure
cursor keys both the Workbook snapshot and the Sessions clone — the
browser's common path, one frame per keypress.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
improvise-el9h. `ClientSession::fold` settled `view.mode` against the grid
that session was LAST projected, and `project_diff` then assigned
`self.grid = desired` and never settled again. The client has the other
shape — `ClientState::fold` settles on every fold, `Command::Project`
included — so for `[SwitchView(records-shaped view), ChangeMode(Normal)]`
the mirror kept `Normal` while the client ended on `RecordsNormal`.
The test came first, as the ticket demands, and failed exactly that way.
`settle_mode` is now called from `fold` and at the end of `project_diff`
once the grid has moved, which also covers the three doors that write the
mirror with bare `reduce_view`: whatever they leave, the next projection
settles.
The fixture's own precondition earned its place on the first run — a view
built with `View::new` carries no axis entry for `_Index`/`_Dim`, so
`is_records_mode` can never fire on one, and without the assertion the
test would have compared two `Normal`s and passed against the bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
improvise-yqpd. `Host::commands` snapshots the document and every session
mirror before a frame and restores both if any command in it fails, so a
chain whose later mutation cannot run leaves the document exactly as its
stated basis described.
Two failure signals, because a command can fail two ways. An effect that
sets `abort_effects` is `Rejection::MutationRefused`. A command whose
record no longer resolves is `Rejection::ChainBroken` — and that is the
half the ticket costed wrongly. It argued that interleaving the record
check with the applies would close clause 4's silence but would sell the
legitimate double-write. That is true of the record tier's COUNTER half
and not of its STRUCTURAL half: writing a record twice cannot make it
stop resolving. So the tier splits — "does it resolve" is asked again
before every mutation, "is its counter what you claimed" stays pre-frame
— and clause 4 gets its loud failure at no cost to clause 2.
`a_later_write_in_a_chain_still_answers_for_its_own_record` arm 3 still
passes, which is the guard that the trade was not taken by accident.
The snapshot primitive is `App::snapshot_document`/`restore_document`, so
a future RunScript opens the same transaction; only the trigger is at the
frame. It is deliberately NOT taken inside `apply_effects`, which runs on
every keypress — an unconditional Workbook clone there would put a full
document copy on the held-key path.
`a_frame_whose_later_mutation_fails_is_not_yet_undone` is inverted to
`..._is_undone_whole`; all three assertions flip and a fourth is added
that the record counter goes back too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
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
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
changed_mode settled only Normal -> RecordsNormal, but the host's
settle_normal_mode settles BOTH members of the pair, so a RecordsNormal
over an ordinary grid stayed put on the client and became Normal on the
host. Esc out of RecordsEditing resolves through entered() to
`enter-mode records-normal`, so the mirror-image drift was reachable by
the same route the fixed one was. Now total, with the pass-through arm
and both negative rows asserted.
a_compacted_seed_folds_to_the_same_cache_as_the_whole_history left
GridCache::records at its default, so a seed() that OMITTED the arm
compacted to an equal cache and the check could not see the omission.
The history now sets it; deleting the arm from seed() fails the test.
grid_updates' records arm had no test at all: grid_of setting the field
is covered by lockstep, but a client that connects before records mode is
entered learns only through the diff.
Evidence for all three, with the injected faults:
context/test-evidence/{clients,command}.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
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
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