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
`render_model` built `editing` as
`is_editing().then(|| buffers.get("edit").cloned().unwrap_or_default())`,
so an `AppMode::Editing` that arrived without its `SetBuffer` painted
`Some("")` — a blank editor drawn over a cell that still holds a value.
The page opens an `<input>` seeded from that string, so the next Enter
commits the empty string and the value is gone. An ABSENT buffer and an
EMPTY one are two different facts, and `unwrap_or_default` collapsed them.
Reachable because the pair is two `Command`s rather than one: this
client's own resolver emits `SetBuffer` then `ChangeMode`, and a
`Snapshot` or a host batch can carry the mode without the buffer.
The fallback is `improvise_protocol::edit_seed`, which is the EXISTING
home of "what an editor opened here starts with" — `seed_at` at the
cursor, the function `enter-advance` already seeds from. A second
reconciliation of the scroll offset inside `render.rs` would have been
gotcha 42's own trap, so this costs one `pub` rather than a copy.
Two tests, and the second is the one that matters for the shape of the
fix: an empty buffer is a state the user typed their way into with
backspace, so refilling it would make the last backspace un-doable. The
over-broad repair — "empty or absent, refill" — fails that test alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
improvise-m0sx. `Command::ClearBuffer(String)` was declared on the wire and
composed by nothing in production: its only occurrences were the enum, the
fold arm, and two of reduce.rs's own tests. Its fold arm removed the buffer
key, while the only two implementations of its own name — the host's
`clear-buffer` command and resolve.rs::write_buffer — both compose
`SetBuffer { value: "" }`, which inserts. So the variant was invented rather
than derived and disagreed with both ends.
Deleted rather than given an effect. Adding a ClearBuffer effect to justify
the variant reverses decision(improvise-cqi, command_set_derived_not_invented),
and it would buy nothing: both readers of the map end in unwrap_or_default(),
so an absent key and an empty one are indistinguishable to every reader.
The guard is the generalisation. vocabulary_is_composed.rs reads the variant
list off serde's derive — the one enumeration the compiler maintains — and
requires each name to be constructed in non-test source, or named in a short
NOT_COMPOSED list with its verdict, checked in both directions.
It found two more orphans nobody had connected to the vocabulary, both
already filed as resolver gaps rather than inventions: SetYanked
(improvise-lut7) and SetSearchMode (improvise-fo0w item 1).
Proved discriminating twice: removing SetTileCatIdx's three production
composers failed the first test naming it, and listing the composed
SetBuffer in NOT_COMPOSED failed the second.
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
`Repr::Nonfinite(&'a str)` could only be produced by a deserializer that
borrows from its input, so the new wire spelling round-tripped through
`from_str` and `from_slice` and through nothing else. Measured on the
pre-fix head:
from_str {"Number":"inf"} -> Ok
from_str {"Number":"inf"} -> Err (the SAME JSON document)
to_value then from_value -> Err (does not round-trip its own output)
from_reader -> Err
The escaped form is the serious half: escaping is not a distinction JSON
makes, so the wire accepted a subset of the encodings of the spelling it
claims to share with the file. `Cow<'a, str>` fixes all four; the serialized
form is unchanged.
The PR's own tests could not see this — both use `from_str` exclusively,
which is precisely the one path that borrows. A check adjacent to the
question it names, inside the change that defines the question.
Also tightened `a_string_that_is_not_one_of_the_three_tokens_is_refused`: its
second disjunct accepted `data did not match any variant`, which is exactly
the message the above bug produces, so it passed in both worlds.
Conflict was `context/test-evidence/core.md` only, against #143. Both entries
kept and verified line-by-line — neither test cites the file, so a dropped
entry would pass `--check-evidence`.
Gates on the merge: 1728 passed / 0 failed / 7 ignored over 38 binaries,
clippy -D warnings clean, fmt clean, and `cargo check --target
wasm32-unknown-unknown -p improvise-core` clean — the one gate the review
asked for and could not run.
Found by adversarial review (confirmed by injection, not reasoned).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
Conflict was `context/test-evidence/gp.md` only — this PR and #146
(improvise-ipqu) appended at the same point. Both entries kept, verified
line-by-line against both parents, because --check-evidence cannot see a
dropped entry: it checks that every heading names a running test, and neither
of this PR's tests cites the file, so losing one would pass the gate.
The format string was broken across source lines with no `\` continuation, so
eighteen spaces were baked into what a user reads on --predict against a
corrupt model. Found by adversarial review, which printed the message rather
than reading the source.
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
The comment added with the previous commit ran to three lines and
scripts/check-comment-runs.py failed the `test` job on it. The reason it was not
caught locally is worth recording: --check-evidence is ADDITIVE, so the run does
answer the cap, but the output was read through `tail -1` and the cap's line is
the FIRST of the two. A gate filtered down to the line you expect is not a gate.
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
Found by the ChatGPT Codex review of PR #151 at head 64bcb82, and correct.
`clear-cell`, `yank`, `paste` and `drill-into-cell` are registered with
`register`, and their INTERACTIVE arms took `|_args, ctx|`: they discarded the
arguments and used `ctx.cell_key()`. That was harmless while a script only ever
reached the pure arm, which reads the coordinates. The moment the headless loop
re-parsed interactively, `clear-cell Region/East` passed the syntax gate and
then cleared whatever the cursor was sitting on — the origin, `Region/North`.
So this ticket's own lesson has a third instance, inside its own fix: two
construction arms that mean different things demote whichever caller reaches the
weaker one, and here the "weaker" one was the arm that had the context. The four
arms now go through `key_from_args_or_cursor` — explicit coordinates win, the
cursor is the fallback, which is what the keymap path wants anyway since a
binding carries no arguments.
`an_explicit_coordinate_still_beats_the_cursor` writes three cells and clears
the one the cursor is NOT on, so it separates "cleared the named cell" from
"cleared the cursor cell". Restoring the arg-discarding arm fails it.
Evidence: context/test-evidence/cli.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
Found by ChatGPT Codex reviewing PR #150 at af7fae0e, and correct. The tally was
incremented while walking a record's categories, before the record was known to
survive that walk. A row whose Date is unparseable and whose LATER required
category is missing is skipped entirely — it never lands in the imported model —
yet it had already been charged to the derived column. So the warning counted a
row nothing else in the import reports, and its value depended on the order the
categories happen to be walked in.
Mismatches are now collected per record and merged only after missing.is_none().
The regression asserts the fixture's category order explicitly, because it only
discriminates while Date precedes the missing category: a later change to
proposal order would otherwise make the test pass by never reaching the bad
date. Before the fix it reports three warnings where one is correct; the rival —
merging before the missing check rather than after — reproduces exactly that.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
serde_json writes a non-finite f64 as null, and null does not read back as one,
so CellValue::Number(INFINITY) serialized to {"Number":null} and the frame
carrying it was unreadable. The websocket reader skips an unparseable frame and
the worker logs a console error, so one poisoned cell silently dropped a whole
batch — and to_string returned Ok, so the send site could not notice either.
The ticket offered two answers and the format had already chosen. Measured
before changing anything: inf, -inf and nan all round-trip losslessly through
.improv — format_number writes them, the grammar's bare_value accepts them, the
walker maps them back. So refusing them before sending would make a legal saved
document unprojectable to a browser, which is a new failure rather than a fix.
It also settles the ticket's second location: resolve.rs's typed() accepting inf
is correct, not half a defect.
Number now serializes through an untagged adaptor: a JSON number when finite,
one of the three tokens when not. A finite cell's bytes are unchanged, and a
JSON integer still reads as a number rather than as a token.
The tokens are one home — nonfinite_token/parse_nonfinite beside CellValue,
called by the adaptor and by persistence/{format,parse}.rs. Three copies stood
before and a fourth was about to be written. format_f64 is deliberately not
folded in: it is the display layer and these three are a format.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
`improvise cmd` and `improvise script` resolved every command through
`CmdRegistry::parse` — the pure arm, which builds `CursorState::default()`: row
0, col 0, and a grid of zero rows and zero columns. The interactive arm, which
builds `CursorState::from_ctx(ctx)`, was never reached from a script. So every
`Move` and `enter-advance` snapshotted a zeroed cursor at PARSE time, clamped to
(0, 0) and emitted `SetSelected(0, 0)` — worse than a no-op, since it RESETS the
cursor. Nothing could position it, so every cursor-reading command was pinned to
the origin.
Reproduced at a9cad15 on a document with Region on row carrying North, South,
East: `move-selection 2 0 . hide-selected-row-item` and a bare
`hide-selected-row-item` both wrote `hidden: Region/North`. After the fix the
first writes `hidden: Region/East`.
The repair is `parse::segments` + `parse::parse_segment_interactive`, with the
headless loop re-parsing per segment, because command n must see command n-1's
cursor. Line atomicity is preserved by keeping the existing whole-line
`parse_line` as a syntax gate, so a line that parsed before still parses and the
only behavioural change is which cursor the commands see.
Two things the ticket did not predict. The TUI's `:` line had the identical
defect — `ExecuteCommand` also used the pure arm — and is fixed the same way.
And doing that surfaced a second instance of the same shape one level down:
`register_pure` built an interactive arm that refused EMPTY args while its own
pure arm accepted them, so `w` with no path parsed one way and refused the
other, breaking `improvise cmd 'w'` and `:w` the moment either re-parsed
interactively. Both arms are now `parse`.
Evidence: context/test-evidence/cli.md; reasoning: context/code/cli.md section 7.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
extract_date_component returns None for a value the detected format cannot
parse, and the caller's `if let Some(..)` simply did not push the derived
coordinate — so the row silently left the derived Date_Month/Date_Year category
with nothing reported. Same shape as improvise-2cw9/jc95/5hfh: a value the code
cannot interpret became an absence rather than a report.
Narrower than the ticket reads, and the reason is a property of one function.
detect_date_format accepts a format only when EVERY distinct non-empty sample
parses, and extract_date_component makes the identical parse call — so through
the shipped CLI and TUI wizard a mismatch cannot occur. The reachable door is
ImportPipeline's `pub proposals`, where a library caller sets date_format
itself. a_column_of_mixed_date_formats_is_not_proposed_as_a_time_category guards
that reachability claim rather than leaving it as prose.
One warning per derived column, in the house wording csv_parser already uses.
The row still lands with the coordinates it does have: this reports, it does not
relocate.
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
PR #111 made a non-finite affine map unrepresentable where it is DERIVED and
left it constructible where it is READ BACK: TargetModel::Regress carried a raw
{ mean: f32, std: f32 } pair, so a hand-edited or corrupted model reintroduced
the poisoned state on `fit_csv --predict`. The poison is ordinary JSON — `1e39`
is well inside f64, over f32::MAX, and serde's f32 visitor takes it by `as f32`,
which is infinity.
Scaling is now #[serde(try_from = "RawScaling", into = "RawScaling")], so a
saved model reaches Scaling::new like every other caller. NOT a check at the use
site: that is the validated form of an invariant pv3g spent its whole change
making unrepresentable, and it would leave two answers to "is this map usable".
Regress carries #[serde(flatten)] scaling, so the saved shape is unchanged.
Measured rather than reasoned: over a regression CSV at one seed the saved JSON,
the fit report and the --predict output are byte-identical, the new code reads
the old code's model identically, and the old code reads the new one's.
This also gives Scaling::new's `mean` arm a reachable path and a test, which
context/code/gp.md section 7 explicitly recorded as having neither.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
`fit_measure` concatenates three warning tiers with two `splice(0..0, ..)`
calls. The resulting order — the budget's notices, then the feature table's,
then preprocess's — is stated in the field's doc comment and in
context/code/gp.md section 14, joined into a user-facing status line, and was
checked nowhere: swapping the two statements left all 74 improvise-gp tests
green.
No pre-existing fixture produced more than one tier, which is why nothing caught
it. `all_three_warning_tiers_at_once` adds two things to the wide-category
fixture: a `_Measure` item with no cell anywhere (a header column entirely
missing in every row, which preprocess warns about) and a `Cost` cell at an
unused coordinate (one visible record with no `Score`, which feature_table
skips). Neither disturbs the budget arithmetic.
The assertion is on the three positions, not on the concatenation containing
them, and the helper panics on an absent tier so a tier that stops speaking
fails loudly rather than making the ordering trivially true. Two rivals, each
moving a different boundary, each caught only by this test.
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
`assert!(val >= 1.0)` on a fixture whose answer is exactly 1.0 cleared the bar
for every inflated count. The ticket's fault (`len + 999.0`) is not the worst
case: it moves the empty count from 0 to 999 and so fails
eval_agg_over_no_rows_is_zero_only_where_an_identity_exists. Preserve the empty
case and all 1699 workspace tests pass while COUNT over any non-empty set is
wrong by 999.
Two assertions, because one does not discriminate. The exact 1.0 at Region=East
fails the +999 rival; a Count arm returning a constant 1.0 satisfies it, so the
unscoped COUNT over all three regions being 3.0 is what fails that one. Both
rivals injected; both fail, at different assertions.
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
Both faults were injected on purpose to show the extracted functions are on
the live path, and the revert of the second one replaced the WRONG occurrence:
`str.replace(".is_measure()", ".is_index_or_dim()", 1)` hit the first match in
the file, which is `drill_constraint`'s pre-existing predicate at line 78, and
left `records_steps`' injected one at line 137 in place. So the commit carried
two faults where it had shown one:
- `drill_constraint` stopped recognising a formula targeting `_Measure`, so the
derived measure stayed in the constraint and `matching_cells` returned no
source rows -- the defect `drill_into_formula_cell_returns_data_records`
exists to catch.
- `records_steps` put `_Index` and `_Dim` on Row and Column and then overwrote
both with `Axis::None`, while never aggregating `_Measure` away, so entering
records mode lost its spine entirely.
Found by the ChatGPT Codex review on the PR, reading the diff, and both of its
findings were exactly right.
The process failure is the part worth recording: after reverting the rivals I
re-ran clippy, fmt, --check-evidence, the node suites, the eight-crate wasm
gate and Playwright, and every one of them passed over the broken logic. The
one gate that could see it -- `cargo test --workspace` -- was measured BEFORE
the injections and never again, so the PR body's "identical, 1699 passed"
was a figure that was checkable and false. An injected fault is not reverted
until the full suite has been re-run; the targeted test passing again only
shows the targeted fault is gone.
Re-measured after this commit, on the orchestrator's machine: 1699 passed, 0
failed, 7 ignored over 38 binaries -- identical to the base at `a9cad15`, which
is what this PR claims and now actually has.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
`DrillIntoCell::execute` and the records-mode command each spelled out what
their view must be as a run of `effect::SetAxis { view: ViewTarget::Active }`
pushes, interleaving the model question ("what is this view") with the UI
question ("how does a TUI keypress get there"). The two spellings shared the
`_Index`/`_Dim` spine and nothing kept them in agreement.
`view::layout` now answers the first question as data: `records_spine()`,
`records_steps(model)` and `drill_steps(key, none_cats)`, each a `Vec<ViewStep>`
of `Axis`/`PageSelection` assignments naming no effect, no `App` and no front
end. `cmd/grid.rs::steps_into_active_view` is the whole of the second question
for this front end -- a browser composing `ModelCommand`s against `_Drill` will
answer it differently without touching core.
`drill_constraint`/`drill_snapshot` were already model-side and are untouched;
core.md S8 records why their order cannot be derived a second time. `DrillState`
deliberately does not move: it holds an `Rc` and its `pending_edits` are
uncommitted writes belonging to whoever staged them, so it stays on `App` where
gotcha 30's session-local-residue tag puts it.
Behaviour is unchanged and no test expectation moved, which is the strongest
evidence a pure separation can produce. Measured on the orchestrator's machine,
before and after:
cargo test --workspace 1699 passed, 0 failed, 7 ignored over 38 binaries
node web/tests 56 tests over 6 files
node web/tests/bundle 4 tests over 1 file
playwright (harness) 68 passed, 4 skipped
--check-evidence 823 entries, 301 citing tests, 83 prose headings
Two rivals injected against the extracted functions, to show they are on the
live path rather than a parallel copy: `drill_steps` emitting `Axis::Page`
instead of `Axis::Filter` fails `drilling_fixes_its_coordinates_on_filter_not_page`,
and `records_steps` filtering on `is_measure()` instead of `is_index_or_dim()`
fails `save_with_unfinished_record_is_readable`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
`importance_ranking` computed `100.0 * imp[j] / total` in f32, which associates
left: a lone importance above 3.4e36 overflowed on the multiply, before the
divide that would have brought it back into range, and the feature carrying all
of the importance was reported as `inf%`.
Dividing first — the fix the ticket's title proposes — is not enough, and that
is the half worth recording. `total` overflows too: several large importances
sum past f32::MAX, and the reassociated version then divides by `inf` and
reports a serene 0% for every feature, where the original reported NaN%. That is
improvise-pv3g's `cv_regress` shape again: an overflow that corrupts a reported
number with no panic to announce it.
So the sum and the ratio move to f64 — no sum of finite f32s can overflow it —
and only the resulting share, bounded by 100 and therefore always finite, is
narrowed back. The arithmetic stops being able to produce a non-finite share
rather than a call site checking for one afterwards; `fit_csv`'s existing clamp
stays as the bar-length bound it also is. The one remaining guard,
`!total.is_finite()`, answers only an importance that arrives non-finite: an
infinite total apportions nothing, so the ranking is empty rather than NaN.
Reachable end to end: both callers score importance in the target's own units,
so an importance is an MSE difference and grows as the square of the target.
That leaves a live window between pv3g's `standardize` refusal and this
overflow, and `a_large_measures_importance_shares_reach_the_report_finite` sits
inside it — before the fix, `fit_measure` reported `[("Cost", inf)]`.
The scoring bit patterns in tests/scoring_known_answers.rs are unchanged, which
is the evidence the fix is confined to the non-finite path: `importance_ranking`
is not on any path that file pins. gp-tree still prints nothing.
Evidence in context/test-evidence/gp.md; the argument in context/code/gp.md §8.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
`Client::key` folds a locally-resolved batch before sending it, so the page
redraws on the keystroke. A refusal left those folds in place: measured against
a real `Host`, a refused commit left the client one row down with its edit
buffer cleared while the host's mirror of that same client was still at the
origin holding the user's typing.
What rolls back is `ViewState` — the thing the fold produces — never a cache. A
`Command::Model` is a no-op in `reduce_view`, so the optimistic fold never
touched the caches, and the bug report's own evidence (`page_coords`) cannot
diverge this way: it moves only under `Projection::Grid`. What diverges is the
mode, cursor, status and buffers that ride along with the model command.
The rollback needed a positive acknowledgement first, which the protocol did not
have: with only `Rejected` on the wire, a client holding two frames in flight
cannot tell which one an answer addresses. `ServerFrame::Accepted` closes that —
emitted once per accepted mutating frame, last, after everything the frame
produced — the same shape as the basis-publication gap ADR 001 originally left.
Two existing facts make the rule usable rather than a convention: only
`Command::Model` can be refused, so a frame carrying none is answered by neither
and needs no entry; and `Rejection::refused_an_edit` already separates the two
`Save` refusals, so a failed write cannot consume the answer owed to an edit.
A refusal drops one frame and replays the rest, because a single saved snapshot
is unsound under pipelining — it would discard a later frame the host applied.
This does not do improvise-3al1's force, and it does not contradict it: the
2026-09-21 wave recorded the two tickets as contradictory on the ground that a
rollback would wipe the staged typing. It does the opposite — the typing frames
carry no model command, are never refused, and have already settled, so rolling
back the commit restores the buffer to the typing. Asserted, not inferred.
Reasoning: context/code/clients.md, context/code/protocol.md §4.
Evidence: context/test-evidence/clients.md.
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
`ClientFrame::Save` landed with improvise-368h and `Host::handle` has answered
it ever since, but `KeyOutcome` was `Local | Upstream | Ignored`, so no
resolver could say "send this frame". `save` therefore sat on
`the_command_names_that_still_need_the_host_are_these`, resolved to `Upstream`,
and `Host::handle` answers `Upstream` with an empty vec — gotcha 47's black
hole. An implemented server feature that no key could reach.
`KeyOutcome::Request(Request)` closes it, with `Request::Save` the one member
and `Request::frame` the one mapping to an envelope. The variant carries a
narrow enum rather than a whole `ClientFrame` on purpose: a resolver handing
out arbitrary frames could compose a `Viewport` (the page's fact, not the
keymap's) or a `Commands` batch, which would be indistinguishable on the wire
from a `Local` while skipping the local fold `Local`'s contract requires.
`requested` is checked first in `step`, so no name is in two lists, and a
request cannot travel inside a `Binding::Sequence` — a sequence is one frame.
`save` leaves the host-needed list in the same commit, as gotcha 47 requires.
The guard is end to end, in `browser_round_trip.rs`: Ctrl-S on a real `Client`
yields `ClientFrame::Save`, and a real `Host` over a real `App` writes the
document to storage. Two half-tests that never meet is exactly what left this
gap open. Reasoning in `context/code/clients.md`; evidence and the injected
rivals in `context/test-evidence/clients.md`.
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