E2E and integration tests are rare and pin boundary contracts; unit tests
assume those contracts and test between them (user, 2026-09-20). The browser
half has four boundaries — RenderModel→DOM, JS→wasm, page→peer, worker→OPFS —
and everything between them needed no browser at all.
`web/shell.js` and `web/serial.js` are the seams: pure modules that RETURN A
DESCRIPTION rather than take a DOM double. `gridPlan(model)` answers every
question `gridTable` used to answer while creating elements — the data-row
index across group headers, what a group header spans, a null group label
against an empty one, an empty edit buffer against no editor — and `app.js` is
now a loop that builds elements from it. Painting stays untested by Node; what
to paint stopped being untestable.
26 tests in `node --test web/tests` (0.12s, no Rust toolchain, no wasm bundle,
no npm install), plus three in `web/tests/bundle/` that read the generated
.d.ts and so run in the browser job. Playwright goes from 29 tests/36.9s to
16/15.5s on one machine: `widgets.spec.js` and `pointer.spec.js` are gone,
replaced by `contract.spec.js`, which asserts that EVERY field the render model
projects reaches the screen. The four widgets those specs were written for were
one failure at one boundary, and one assertion catches the fifth.
It caught it: `doc_name`/`doc_dirty`/`doc_stored_as` reach no element
(improvise-5jna, already open). They are named in `UNPAINTED` with the ticket,
so the list is the debt written down rather than an exemption.
Thirteen faults injected into the Node suite and eight into the served page,
each watched to fail the test it names; the last of those was strengthened
because the first version passed. `web/assemble.sh` grew an import-completeness
guard whose own first version was blind to the multi-line import it exists for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The review re-ran fold_bench across two shapes at the SAME 18,050 cells: the
old code cost 51 ms at 25x361 and 244 ms at 95x95, and after the fix the two
are indistinguishable. A 4.8x spread from group count alone is what a
per-member scan predicts and a constant factor does not, so the spread
vanishing is better evidence for the diagnosis than the absolute number.
Also records the depth asymmetry between the two Agg arms, which bounds a
chain of folded formulas and must not be tidied into one rule.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third-round review finding, and I verified it against the code before accepting
it. Commit 7d25762's message declined that test on the grounds that a formula
targeting a regular category should project Derived anyway, so the injected
behaviour "is not clearly a fault". That is a different key entirely.
RecordCol::Category(name) carries a CATEGORY name, and RecordRow::cell_key(arg)
puts its argument under _Measure (layout.rs:68-70). So answering Some there
yields `coords + _Measure=<category name>`, and formula_for matches it whenever
a _Measure item happens to share a name with a regular category — projecting
Derived for a COORDINATE column, which is never a formula target and which the
pivot path does not have at all. The two front ends would disagree, which is the
§5 defect this ticket exists to close. RecordCol's own doc names the collision:
the enum resolves a column from its index "so a category and a measure sharing a
name stay distinguishable".
The decision not to write the test is unchanged, and the reviewer's
recommendation too. What changes is the argument: the REACHABLE version of that
fault is a new RecordCol variant answered carelessly, and the exhaustive match
makes that unrepresentable rather than unlikely. A test would pin what the type
system already pins.
Recorded in context/code/core.md because a wrong argument in context/ outlives
the code it describes, and that one would have told a future reader a coordinate
column projecting Derived is defensible. The commit message cannot be corrected
without rewriting history while a review is in flight, so the durable record
says so explicitly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review of #54 found the fold reintroducing exactly the divergence the ruling
exists to remove, and the same line turned out to be the 244 ms stall. One
defect, two symptoms.
`eval_expr_cached`'s `Expr::Ref` looks the value up in `formula_cache` at
`context.with(cat, name)`. Inside the fold `context` is a MEMBER-RECORD key
carrying the none_cat coordinates, while `formula_cache` is keyed by stems with
none_cats STRIPPED — so the lookup could never hit, fell through to
`aggregate_raw`, found no raw cell behind a formula target, and answered
Err("ref:…"). A routing formula over a derived measure gave Number(11.0)
uncached and Error("ref:Margin") cached, and the cached path is the one a pivot
grid runs whenever any category sits on Axis::None.
`eval_expr`/`eval_bool` are hoisted to module level and BOTH evaluators now hand
the fold the same record-level `eval_expr`. The two agree by construction rather
than by a test. Note the repair not taken: teaching the fold to hit the cache by
stripping none_cats off the member key returns the GROUP's value to every
member — aggregate-then-route through a cache lookup, and silent.
That same per-member `aggregate_raw` was one poorly-narrowed scan per member, an
O(n^2) shape. Measured with the new `examples/fold_bench.rs`, five runs a point,
at 18,050 cells: 51.1-53.3 ms before, 18.1-19.2 ms after, against 7.1-9.0 ms for
a linear formula. Scaling went from superlinear (45x cells, 85x time) to linear
(45x cells, 45x time). Debug 2.75 s -> 132.9-135.1 ms. The fixed point runs
exactly 2 passes, measured by counting, so hoisting the member set has a 2x
ceiling and is declined on improvise-9iyv — a memo keyed by less than the whole
`partial` is a second answer to what a cell depends on.
`Expr::Is` has an arm in each evaluator and the cached one was untested: the
earlier fault was injected into the shared `eval_is`, which both arms call.
Replacing the cached arm alone left the suite green. The first replacement test
ALSO stayed green, which is the interesting part — once the fold routes through
`eval_expr`, an `Is` inside a SUM never reaches the cached arm, so that arm is
reachable only by a bare `Is` outside an aggregate. That is what the new test
uses, and injecting into that arm alone now fails it.
`IS` gained a word boundary: `Region ISLAND` parsed as Is(Region, "LAND"),
silently wrong where it was a parse error. `is_expr` is compound-atomic with
`sp+` written out, because a negative lookahead in an ordinary rule tests the
character AFTER pest's auto-inserted whitespace and fails backwards. The
lookahead was then redundant and removed — deleting it left the suite green,
while relaxing `sp+` to `sp*` fails the test, so `sp+` is the mechanism. `WHERE`
has the same hole, is far less exposed, and is filed as improvise-r08u.
Also: `ambiguous.sort_unstable()` before `dedup`, which only removes consecutive
duplicates, so the refusal could name a column twice.
Every fault re-injected after the refactor: no-fold 10 failures, shared eval_is
5, record-level Is arm 4, cached Is arm 1, feature_roles order 2, context-vs-
partial 1, the new cached-fold divergence 1, `sp+` relaxed 1. Baseline 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second review round. Six accepted-but-no-op commands still reset the cursor and
bumped the epoch; five are fixed here and the sixth is filed.
The one to remember is AddItem { _Measure, <a formula target> }. The guard read
category.items, but _Measure's effective items are explicit UNION formula targets
(gotcha 7), so it called "new" a column the grid was already drawing. The three
item effects now compare effective_item_names ACROSS the mutation rather than
predicting from one side of it, which is correct in both directions at once and
needs no separate rule for dedupe, for formula targets, or for an unknown
category. Prediction is what was wrong; observation is the fix.
Four smaller ones: SetAxis on a category the view does not register (set_axis
no-ops, but try_axis_of returns None, which compares unequal to Some(axis));
HideItem/ShowItem naming something the grid never drew (hide_item records any
string); and ToggleGroup on a group that does not exist, which never got a guard
at all because it merged CLEANLY through the #48 merge -- the clean-merge hazard
arriving in the one place nobody re-read.
TogglePruneEmpty on a grid with nothing empty is NOT fixed. Guarding it means
computing the pruned layout, which is building the frame improvise-ni6k proposes
fingerprinting. It is one keystroke in ordinary use, so it is filed loudly rather
than patched quietly.
The fixture could not reach any of these by construction -- no formula, no
category on Axis::None, no unknown-name case: the one-category-fixture shape the
doctrine names. It now carries a Profit formula, a Channel category on Axis::None
and a Ghost entry hidden under a name the model never had.
Two of the five injections initially failed to reproduce, and BOTH TIMES THE
INJECTION WAS WRONG RATHER THAN THE GUARD: ShowItem needed a hidden ghost name,
since is_hidden is false for a name nobody hid; and AddItem had to compute its
predicate BEFORE the add, because afterwards the item really is in category.items.
An injection that does not reproduce the original ordering tests nothing.
ToggleGroup's first guard was also wrong -- add_item_in_group sets item.group
without pushing to Category::groups -- and the existing reshape test caught it.
All nine earlier injections re-run and still caught.
Filed: improvise-nynk (adopt_cursor_epoch resets clients on views nothing
reshaped -- bisected to main/#48, but this branch takes it from 2 effects to 12),
and TogglePruneEmpty plus the Axis::None class onto improvise-ni6k.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eight conflicts, all resolved keeping both sides. The three in context/ were
append-vs-append. The five in code were the restructure #50 declared in advance:
- render.rs: render_model became a free fn over &ClientState and the mode label
moved to improvise_protocol::mode_label. Took that shape and re-added the
three doc_* fields.
- state.rs -> improvise-protocol/src/client.rs: only the import path conflicted.
The file now lives IN improvise-protocol, so `crate::` is right, plus
DocumentCache.
- keys.rs went 1387 -> 187 lines because resolve/step moved to
improvise-protocol/src/resolve.rs. Took gh/main's slim file and ported every
addition into resolve.rs: the Derived and Create arms of edit_or_drill, the
or-patterns in record_at_cursor and record_cell_at_cursor, page_cycle with its
Odometer, the two bindings, and `[`/`]` leaving the still-needs-the-host list.
- project.rs: kept the value-based numeric/error from improvise-tom1 with
gh/main's cell_id(app, layout, ..) signature; kept BOTH document_update and
view_in; kept our title-bar projection inside their per-view projection set.
- lockstep.rs: both sides appended tests and the hunks interleaved mid-function,
so took gh/main's file and re-appended our page-turn block whole.
THE CLEAN MERGE WAS THE DANGEROUS ONE, exactly as warned. `cargo check
--workspace --all-targets` passed with three of our tests SILENTLY GONE — they
lived in keys.rs's test module, which moved wholesale. i_on_a_derived_cell_names_
the_formula, the_page_keys_turn_the_odometer_as_the_command_does and
a_filter_axis_is_projected_but_not_cycled are ported into resolve.rs, with
client_on now returning a ClientState rather than a Client.
One assertion changed meaning under us and is corrected: lockstep's
page_selections read workbook.active_view(), which since improvise-jytm is the
document's DEFAULT. SetPageSelection writes App::view(), the client's. They
coincide in a one-view fixture, so it was passing by fixture luck; it now reads
App::view() and still fails under the injection.
All four injections re-run on the merged code and all four still discriminate:
reversed odometer, dropped records formula_for lookup, title bar gated on Dirty,
and filtering_cats leaking into the protocol. 1348 passing, clippy clean, fmt
clean, all eight wasm crates clean, both comment-run checks clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two conflicts, both in ui/effect.rs and ui/reduce.rs, both resolved by keeping
BOTH sides rather than either.
#48 introduced App::view_mut() as shorthand for
model_state.workbook.active_view_mut(). SetAxis, HideItem and ShowItem conflicted
because this branch had just given each a no-op guard (improvise-9mo9 review, F1).
Resolution keeps the guards, expressed through the new accessor. ToggleGroup
merged cleanly and kept its reset, which is the case worth noting: git had no
reason to flag it.
#50 moved records_named into improvise-protocol and re-exports it from
ui/reduce.rs, and re-added reduce_view to the import list. reduce_view's last use
in this file was deleted by improvise-s17o on this branch -- apply_command's
fallback arm no longer folds -- so the resolution keeps the re-export and drops
the now-dead import.
A CLEAN MERGE IS THE DANGEROUS ONE, so all nine fault injections were re-run
against the merged tree rather than trusting a green suite: SetAxis's moved
check, AddItem's fresh check, AddCategory's Err arm, SetAxis's reset removed,
SetNumberFormat gaining a reset (the negative arm), the ShowItem non-vacuity
guard, reset_cursor clearing the whole cursor map, the CommitAndAdvance advance
ordering, and apply_command folding again. All nine still caught, and the tree
restores byte-identical. That matters most for the cursor ones, since #48 changed
which view a session is projected against.
1333 passing (1328 from main plus this branch's 5), clippy and fmt clean, both
comment-run checker modes clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
loop-of-one test
F1 (HIGH). Gating the reset on Ok is not the same as gating it on "something
changed". Six accepted commands changed nothing and reset every cursor anyway:
SetAxis to the axis already held, HideItem on an already-hidden item, ShowItem on
a never-hidden one, AddItem of an existing item (Category::add_item dedupes and
returns Ok), and RemoveItem/RemoveCategory of something absent. Each now asks the
state before mutating. The case that made it urgent needs no second client:
TileSelect binds `r` to set-axis-for-tile row, which emits SetAxis with no
comparison against the current axis, so `r` on a Row category sent the user's own
cursor to the origin.
F2 (REGRESSION this PR introduced). Naming a measure in the trailing `+` column
at the bottom-right of a records grid sent the cursor home: AddItem now resets,
inserts_a_column matches only NewCategory, and EnterAdvance at the bottom-right
returns AbortChain with no SetSelected. The restore moves to BEFORE the advance,
where an aborting advance cannot skip it -- gotcha 21's rule applied to the
cursor rather than the mode -- which also subsumes the +cat arm's copy.
F3. a_refused_model_command_invalidates_no_cursor named a class and tested one
member; injecting reset_cursor into AddCategory's Err arm left it green. Now a
table over all three refusable effects, and that injection fails it by name.
F5. The benchmark could not reproduce its own figures: it took the mean over a
few iterations, and review measured the same share at 8% and at 131% -- a part
slower than the whole containing it, printed flat. It also passed (false, 2)
where grid_of passes the view's (true, 0), so it timed a call that never happens.
Now min-of-N with the view's own format, and a share above 100% prints UNSTABLE.
Figures are stable across runs at ratio 1.2-1.5x, col_widths 45-49%.
F6. The drg counts omitted the 8 simple_effect! invocations and misclassified
AddItem at base through a regex that cannot see `app` newline `.model_state` --
doctrine(derive_then_verify) catching the person citing it. Correct: 57 effects,
narrow set 32 -> 25, not 25 -> 17 of 49. The direction holds and is still the
argument; the headline was overstated by about half again. Corrected in
context/code/command.md and on the bead, not quietly restated.
F9. a_model_command_that_leaves_the_active_view_alone_broadcasts_nothing asserts
a ResetCursor broadcast, so the name read as a lie: ..._broadcasts_no_switch.
F4 filed as improvise-pdvm: AddItem/RemoveItem declare CellData so layout_version
does not bump while cursor_epoch does, and this PR's own reshape assertion is the
first evidence for it. Related to improvise-75ld, same question other direction.
Faults injected for every new guard: SetAxis's moved check, AddItem's fresh
check, AddCategory's Err arm, and the pre-fix advance ordering ((0,0) vs (0,3)).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two findings from the adversarial review of #51, both demonstrated before being
fixed and both re-proved by injection afterwards.
FINDING 1 — a formula-owned cell was still a writable Record in records mode,
and my own guard test's written premise was false. "A formula target lives only
in measure_item_names" does not hold: an item can be BOTH an explicit _Measure
item and a formula target, and improvise-poia in this same PR made the `+`
column that builds one a single keystroke away. Type `Profit` into `+` on a
document that already has `Profit = Revenue - Cost`, and the column projects
CellId::Record; `999` commits, is stored, and is then shadowed forever by
formula_cache — the exact defect ir7l exists to close, in both front ends.
The question is now asked of the record's REAL coordinate.
GridLayout::record_cell_key is the one mapping from a records position to a
model cell; cell_id and the new CmdContext::owning_formula both go through it.
The synthetic (_Index, _Dim) key names no measure, which is why EditOrDrill's
guard could not fire there.
typing_into_a_records_formula_column_is_refused_on_the_key_path drives the
reviewer's repro through App::handle_key with no test API. It exists because the
projection half and the EditOrDrill half are separately breakable — injecting
each alone fails a different assertion.
FINDING 2 — DocumentCache::dirty never cleared on a client. Both save paths
cleared model_state.dirty OUTSIDE a projection: Host::save returned only a
possible Rejected, and Worker::on_frame built its frames and persisted after. So
after the user's last keystroke a browser showed [+] on a saved document,
indefinitely — which is the failure mode protocol.md's own z754 section names as
the thing the design avoids.
The z754 test that should have caught it called sessions.project_all by hand,
which is exactly the call both production paths omit. Not a test comparing a
thing with itself; a test exercising a call the deployment never makes. The new
ones go through Host::handle and through the worker's real message loop.
Also filed improvise-8ec3: the reviewer measured improvise-io's test binary at
3.7-5.2s where this machine reads 1.4-1.7s, so the 2s per-binary rule is
breached on some machines — on main too — and the figure design-principles §7
pins is not reproducible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
improvise-4zl7. A tree is fitted on individual records; an improvise formula is
evaluated at a CellKey, and an aggregating cell used to aggregate each Expr::Ref
first and evaluate the IF once on the totals. That is the same answer for a
linear formula and a different one for anything that routes, so every fitted
tree meant one thing on a record row and something else one row up. The user
ruled: aggregate the rule over the members.
The rule needed no new aggregate vocabulary. SUM(e) already means "fold e over
the members" and simply did not: eval_agg specialised Expr::Ref and, for every
other inner expression, summed the raw matching cells and ignored the expression
entirely. It now folds — one entry per member record, the expression evaluated
at that record's own coordinates. eval_agg is shared by eval_expr and
eval_expr_cached, so the fold has exactly one home; each evaluator passes its
own recursion in.
Expr::Is(Filter) is the new arm in both evaluators: `Cat IS Item` is 1.0 when
the cell's own Cat coordinate is Item, keeping the evaluator f64-only and
reusing Formula::filter's own context.get/unwrap_or(false). A grammar production
in primary, before ref_expr, with both operands through
improvise_formula::quote's one home; IS joins the keyword guard.
The emitter reads feature index -> role off preprocess's plan rather than
reconstructing it: gp_tree::data::feature_roles is the one statement of the
expansion order and encode_row now drives off it too. improvise_gp::Features
carries one Spelling per feature — Measure, Coordinate or Artefact — decided by
role and column, never by the name's shape. A one-hot over a real category is
emitted as `Region IS North` and accepted; col.missing and the date parts stay
Artefact and stay refused (improvise-wd6n).
fit-measure on a document a category explains now lands instead of refusing, and
the refusal no longer depends on which features the search happened to pick.
Evidence: eight hand-computed tests in improvise-core over a four-record fixture
whose Cost straddles the threshold inside each region, so route-then-aggregate
and aggregate-then-route differ at every level. Two faults injected and watched
to fail — reverting the fold fails 7 of 8, and a constant-false eval_is fails
exactly the 3 coordinate tests. Reordering feature_roles fails both it and
encode_row_replays_the_training_expansion.
The fold is O(members) per evaluation per fixed-point pass and unbenchmarked:
improvise-9iyv.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Findings 1 and 2 of the adversarial review of PR #50, both demonstrated by
injection before being fixed.
**F1.** `resolve.rs`'s `structural`/`record_write` were a second derivation of
`reduce.rs::records_named`, and nothing tested them: `lockstep.rs`'s
`apply_on` restates the basis against its twin with `caught_up`, which
re-derives it with the SERVER's answer and throws the client's claim away
before anything is compared. Emptying `record_write`'s claim left the
workspace green.
Fixed by collapsing rather than testing, which is what this unit argues for
everywhere else. `records_named` is pure and `RecordHash` is already
re-exported by the protocol, so it moved DOWN into `improvise-protocol`
beside the enum it reads; `improvise-command` re-exports it; `resolve.rs`
has one `model()` that claims `records_named(&cmd)`. The claim is now right
by construction and the guard is the exhaustive match that was always there.
Verified by emptying the shared function's `SetCell`/`ClearCell` arm: five
server-side tests fail, where the client's copy had none.
What the old shape cost, which was not a compile error: `structural` accepted
any variant and claimed `[]`, so a new record-writing command compiled clean
on the client with the wrong answer and the symptom was a dead browser key,
refused as `RecordUnstated`, with nothing failing.
**F2.** Three resolver divergences the suite could not see, all now failing:
`delete_view_at_cursor`'s cursor step-back (its only decision — the test
pressed `n` and `Enter` while its doc comment said "create, switch and
delete"), and both Formula arms of `CommitKind`, Formula being the only kind
whose `on_commit()` is `Some`. `both_ends` now compares every cursor, not
only the document, mode and status. A fourth injection — `cycle_panel_focus`'s
`start` index — still survived, because `PanelKeymap` binds Tab to
`enter-mode normal` inside a panel so no key reaches those arms; they are now
driven directly by `panel_focus_cycles_from_the_focused_panel_not_the_first`.
Widening the commit test found a real divergence, pinned rather than fixed as
improvise-cl7y: the client folds `CommitBuffer`'s optimistic "Formula added"
and cannot parse, so a browser reports a formula it did not add.
`a_formula_the_client_cannot_parse_diverges_on_the_status_line` asserts both
sides, so a fix has to come there and change it.
F3 filed as improvise-thpe and deliberately not fixed here.
`apply_on`'s doc comment pointed at an evidence entry that did not exist and
now names `context/code/clients.md`, which has the argument.
1324 tests pass, up from 1322.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
improvise-9mo9 made SetAxis reset the cursor, and DrillIntoCell issues eight or
so SetAxis effects. The PR claimed that was safe and did not test it: the claim
was that SwitchView precedes the batch, so reset_cursor writes the ACTIVE view's
cursor -- by then the freshly created _Drill one -- while the view drilled from
keeps its entry in ViewState::cursors. That is an ordering argument about effects
inside a batch, which is the class of claim gotcha 23 exists because people get
wrong, and I had flagged it to the reviewer as unverified.
It holds. Two faults injected: removing SwitchView from the batch fails the
non-vacuity guard ("the drill did not switch views, so this proves nothing"), and
making reset_cursor clear the whole cursors map rather than the active entry
fails on the origin view's cursor (left: (0,0), right: (1,1)).
Both sides are asserted on purpose. That _Drill opens at its origin is half the
property; that the other view keeps (1,1) and row_offset 1 is the half that would
have broken, and a test asserting only the first passes against an emptied map.
The cursor_epoch half stays argued and now says so in context/code/command.md: a
drill bumps it once per SetAxis, adopt_cursor_epoch compares before and after per
command so it relays once rather than eight times, and DrillIntoCell is key-path
only today, so no client sees it. That changes when drill becomes addressable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fact stated twice is changed by a grep, and this copy was missed. The
"four decisions that are silent when wrong" list in context/code/clients.md
enumerated CellId::Record and CellId::Aggregate and then said None is "not
aggregate" — written when those were the only two kinds. There are now four,
and a reader of that list would have concluded a Derived or a Create cell must
be a None.
Found by grepping for the claim after PR #47 put web/demo.improv on the site's
front door, which is also how the remaining stale copies were located: two in
prolog-sessions/kb.pl and two in context/repo-map.md, both orchestrator-only
files, reported as text in the hand-back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All three were demonstrated by injection against a green suite — this repo's
characteristic defect, and the PR's own evidence table asserted coverage it did
not have.
- `ClientSession::view_in` had no guard: pointing it at the host's view left
1313 tests green. Only `grid_in` was checked; `category_tree_in` and
`tile_bar_in` were not, and the tile bar is the control a user reassigns axes
with. `a_session_on_another_view_is_projected_that_views_axes` closes it.
- `adopt_deleted_views` and `apply_switch` had vacuous audience assertions:
both bystanders sat on the document default, where a wrongly-addressed relay
folds to a no-op. Both now sit on a view they can be moved off, and both read
the audience off the `Broadcast` rather than inferring it from folded state.
- `ViewBack`/`ViewForward`'s `EffectKind` had no guard — reclassifying either
was silent. `walking_the_view_history_moves_no_document_state` asserts the
consequences (`dirty`, `layout_version`) rather than the declaration.
Four follow-ups filed: improvise-w3bf, improvise-pubt, improvise-h074, and the
reachability trigger appended to improvise-lltt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
improvise-tom1. The user has ruled that a remote client must NOT see another
client's staged drill edits, so the invisibility is correct and was never the
defect. What remained was that three places answered "what text is in this
cell" — and only two of them still do.
GridLayout::records_value is now the resolution: it returns the CellValue, and
records_display is one to_string over it, so the text can never disagree with
the value it spells. project::cell_display used to call records_display and
then GUESS numeric-ness with text.parse::<f64>() — the one place the producer
guessed rather than knew, and it was wrong: a records coordinate column holds
an item NAME, so a Year category with items 2024/2025 projected a coordinate
the browser right-aligns as a number. Nothing in the suite could see it,
because every records fixture in the tree names its items Region/East/North.
A correction to the ticket worth more than the code: src/ui/grid.rs is NOT a
third answer. Since improvise-anr9 the TUI grid takes its text off GridCache
and then overlays drill_state.pending_edits — one derivation plus a
session-local overlay, which is exactly what the ruling wants, and
App::display_value_of does the same for the editor. The ticket was written
before anr9 landed.
Both halves of the ruling are now asserted, with the leak injected as the
fault: teaching the producer to prefer pending_edits, which is the plausible
"fix" the ticket exists to forbid.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A second run of benches/grid_projection put the 'large' row's col_widths share at
45% where the first said 64%. 45-46% is what reproduces; 64% was noise, and a
single-run number written down as a fact is the same defect as an argument
written down as a measurement -- which is what the benchmark existed to avoid.
What does not move between runs is what the decision rests on: ratio stays
1.2-1.5x and col_widths stays at roughly half the frame. improvise-jmjz's title
and notes corrected to match.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three tickets asked one question — where do UI strings live relative to a
serde wire enum — and answering it three times is the disease this unit exists
to cure. One answer, in `improvise-protocol/src/help.rs`:
**A UI string is keyed by a command name, never by a key, and never hangs off
the enum that crosses the wire.** `AppMode::label`/`hint_text` are gone;
`help::mode_label`/`help::hint_line` replace them, so the enum a client
deserializes carries no English at all (improvise-k099). **And wherever a
string names a key, the key is looked up rather than spelled**:
`Hint { keys, command, caption }` takes `keys` off `Keymaps::default_keymaps()`,
`command` is the semantic value a localized front end keys off, and `caption`
is the default English — the `AxisColor`/`axis_fg` shape.
- **improvise-x7sw**: `help::which_key` is the which-key popup's rows, keys and
captions together. The client already holds the keymaps, which is the answer
the ticket preferred, so `ViewState::transient_prefix` stays an address.
- **improvise-ktv1**: the help pages are data — `help::pages()` — with every
key row derived. `src/ui/help.rs` keeps its ratatui styles and loses ~310
lines of content; `web/app.js` paints rows it does not author, and
`harness/tests/shell.spec.js` asserts that `?` really draws them.
**What this deletes is the measure of it.**
`every_hint_key_resolves_in_its_mode` and its 60-line hint tokenizer are gone:
they existed to hold a hand-written string honest against the keymap, and the
string is derived now, so the property is true by construction
(`doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)`).
`Keymap::binding_hints` goes with them — `which_key` is the one walker, and the
binding dump reads it. What replaced the test checks the *other* direction, a
mode advertising a command nothing binds, which was never checkable before.
Two defects the derivation exposed, both fixed here: `key_label` dropped
modifiers, so Ctrl-arrows read as bare arrows, and `Char(' ')` printed a literal
space instead of `Space`.
**What is deliberately not derived**, and says so: the import wizard's keys
(its whole keymap is one `Any` binding into `HandleWizardKey`, which decides
per step inside the command — a real hole, left visible), `gg`/`yy`/`ZZ` and
the ex-commands (`HelpLine::Literal`), and the captions, which no test can
judge. `context/code/protocol.md` §8 has the argument.
Cost, stated: the hints changed shape. They are longer, they name arrow keys
beside `hjkl`, and two sub-lists on the Panels page lost their visual indent.
That is the price of the keys being true.
Evidence: 1321 tests pass, and the 7 Chromium shell tests pass against the
assembled site. Two faults were injected into the help overlay — dropping the
`paintHelp` call, and removing the `mode != Help` guard so it is never hidden —
and each fails the new spec.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
improvise-n89z. Three Projection mutations had no producer. They get two
different answers, and the distinction is the finding.
GridUpdate::Invalidated is DELETED. It was not merely unused, it was
REDUNDANT: `grid_updates` diffs, so it always knows which cells moved and sends
Cells, and `GridCache::seed` compacts — there is no state in which a server
knows the contents are stale and not which ones, and if there were, the answer
is a fresh Snapshot through `rebase`. Meanwhile its stated meaning had migrated
into running code, because PR #23 gave `invalidate_relabelled` the job of
blanking exactly what a reframing invalidates, totals included. Three documents
still described it as load-bearing, which is worse than silence: a reader
reasoning about the fold's guarantees assumed something produced it.
CategoryTreeUpdate::Items and ::Axes STAY, with the argument written down.
They are derived rather than invented — the effects that move a category tree
come in three shapes, by the same recipe that built ModelCommand — and the
producer takes the coarse arm on purpose, because Dirty::of cannot say WHICH
category moved (an EffectKind never can), so emitting them needs the tree
observed and diffed for a widget that costs a few hundred bytes.
The two tests that reached the blanking through the retired arm now reach it
through a reframing, which is what really produces it; the row one is strictly
stronger, since blanking everything could not tell "blanks what it reframes"
from "blanks the lot". `the_retired_invalidated_spelling_does_not_parse` keeps
it from returning as a serde alias, with the injected-fault discipline
`the_retired_spellings_do_not_parse` earned: well-formed input first, error
KIND asserted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Self-review caught three references the deletion commit missed, which is gotcha
48's rule arriving on schedule: when a fact is stated in more than one place,
changing it is a grep, not an edit.
- context/code/command.md still had a `### reduce_full / reduce_key` section
calling it "the signature the three hosts share" -- the exact claim the ticket
was filed against, two hundred lines below the paragraph that corrects it.
- ADR 001 stated in the present tense that apply_command/reduce_full do not
check, and recorded improvise-ondj giving both a Checked signature without
saying that one of them has since gone. The ADR now says why deleting beat
typing: having no caller was the condition that let the defect hide, and a
Checked on a function nobody calls fixes the type and not that.
- The test-evidence entry's "no longer compiles" is now "no longer exists".
Also records the AddItem/AddItemInGroup success gating and the four effects that
reset unconditionally because they cannot report a miss.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
improvise-poia. `+` and `+cat` projected `None`, which the protocol defines as
"no projected cell", so a client could not tell a column that creates something
from a padded slot — gotcha 42's conflation, a third time.
The ticket offered two designs and both assumed a cell write. Neither happens:
add_measure emits exactly one effect, AddItem { _Measure, name }, and
add_category emits AddCategory(name) and deliberately writes no coordinate on
the current row (improvise-2zq). The record under the cursor is not part of the
operation and neither is any value — what the user types is a NAME. So there is
no distinct command to invent and no two-command flow to sequence.
What was missing was never an address but a recognition. CellId::Create carries
the kind and nothing else, because the kind plus the typed name is the whole
operand. It is not a SetCell target, so editable stays Record-only and
record_at_cursor still answers None — both by matching, which the compiler now
forces.
No client composes it yet: keys.rs::commit returns early for the whole of
records mode, and that guard is broader than its stated reason. Filed as
improvise-gcvb, with the two things that make it more than a one-liner — an
inserted +cat column means the TUI does not advance, and AddCategory can abort.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Self-review of improvise-9mo9's own commit. AddCategory was gated on success and
AddItem/AddItemInGroup were not: the reset sat after the `if let Err`, so a typo'd
category name bumped cursor_epoch and broadcast Command::ResetCursor to every
client for a change the document never made. The epoch is document-wide, so the
cost was paid by everyone.
SetNumberFormat's negative arm cannot see this -- it tests a command that succeeds
and reshapes nothing, where this is one that reshapes nothing because it was
refused. a_refused_model_command_invalidates_no_cursor is the second arm, proved
by moving the reset back outside the match and watching it fail on the bystander's
cursor.
Also drops apply_command's now-dead `Command::Project(_)` arm: since the fallback
stopped folding, both returned Dirty::NOTHING and the first was unreachable
duplication.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
improvise-drg proposed narrowing each Effect::apply to the slice it touches. It
was filed with three unpicked mechanisms and a note to decide before claiming.
Declined, and the counts are the argument.
Over the 49 impl Effect blocks: 8 touch only view_state, 9 only model_state, 32
cross-cut. At this wave's base commit the split was 9 / 16 / 24, so the narrow
set fell from 25 to 17 in one wave -- and the mover was improvise-9mo9, earlier
on this branch, which made nine reshaping effects call app.reset_cursor(). That
is architectural, not incidental: a reshape lives in model_state.workbook and the
cursor it invalidates lives in ViewState, so the effects that matter most are the
ones that reconcile the two slices.
Three more grounds. The type-level documentation already exists and is checked --
Effect::kind() has no default and apply_effects reads that one answer three times
-- so a narrowed signature would be a second statement of the same fact, free to
disagree. Every offered mechanism weakens a guard: three traits need three queues
(destroying batch order, which gotchas 21/23/43 lean on) or an enum wrapper (a
fourth classification beside EffectKind), and default methods give `apply` a
default whose wrong answer is silent, which is exactly what kind()'s no-default
rule exists to prevent. And finer-grained effect tests are not blocked: the 64
tests in effect.rs already drive apply against a one-line App fixture.
Approach (B) -- rebinding the slice at the top of a body -- stays available as a
per-function style choice and is not mandated.
The bead carries the `human` label, so this is recorded as a recommendation with
its evidence rather than settled unilaterally.
Closes improvise-drg
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
improvise-z754. draw_title read workbook.model.name, model_state.dirty and
model_state.file_path; none was projected, so a browser could not draw a title
bar at all.
A projection, not a ViewState field: these are facts about the DOCUMENT, shared
by every client and writable only by the server. On ViewState they would be
document state wearing a session slice's clothes, and reduce_view would need
commands nothing composes. The per-widget cache is the shape for "a local copy
of what a widget draws, replaced by a Projection".
It is the one widget no EffectKind can gate, and that is the whole design.
`dirty` is raised by any EffectKind::Model(_) and cleared by effect::Save,
which is ViewOnly and correctly so; SaveAs moves file_path and is also
ViewOnly. So Dirty::of reports NOTHING for exactly the effects that change this
widget most visibly. ClientSession::document_update therefore OBSERVES — build
the desired cache, diff it against the mirror — the same answer record_versions
got for the same reason.
`stored_as` is a name, never a path. The TUI already drew only file_name(), and
a socket server projecting file_path would hand a remote client the host's
directory layout. `None` carries the fact ClientFrame::Save fails on.
draw_title now draws from project::document_of, so the two front ends share
one derivation. web/app.js deliberately does NOT paint it yet — every harness
fixture mocks a FAILING peer, so a title element added now would be unrun
JavaScript, which gotcha 48 forbids. Filed as improvise-5jna, fixture first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two findings from running the code-review skill on this branch.
`Workbook::delete_view` used to move the document's active view and
`adopt_active_view` pushed the result into every mirror, so deleting the view a
client was on moved that client. The split removed both, leaving that client
naming a view the document has not got — projected the default's grid while its
own slice says otherwise. `Sessions::adopt_deleted_views` answers it, observed
rather than predicted: the check is whether a mirror still names a view the
document has, not whether the command was a `DeleteView`.
That needs a one-session audience that is not the asker, so `Broadcast::ToAsker`
becomes `Broadcast::To(SessionId, Command)` and `Host::relay` reads the address
off the message rather than taking `from`.
And "what does this name name in this document" was answered twice — in
`App::view_name` and again inside `project::grid_in`. `App::resolve_view` is now
the one home both go through.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
improvise-i5tk's acceptance criterion 1 was a benchmark, on the ground that "this
can dominate TUI rendering" is exactly as unevidenced as "it is fine".
benches/grid_projection.rs is that benchmark, and it says no.
One grid_of call, dense pivot grids, this machine:
typical 100 x 4 = 400 cells whole-grid 1.902ms ratio 1.2x col_widths 45%
large 1200 x 8 = 9600 cells whole-grid 185.644ms ratio 1.3x col_widths 64%
18k cells (gotcha 41) 3000 x 6 = 18000 cells whole-grid 402.435ms ratio 1.5x col_widths 42%
pathological 9000 x 10 = 90000 cells whole-grid 5.235s ratio 1.5x col_widths 44%
ratio is whole-grid over a 45x12 terminal window: 1.2-1.5x, against a fix whose
stated cost is changing what a cache index MEANS in the TUI and putting every
scroll site in scope. Wrong trade, so i5tk is closed on the measurement.
Why the ratio is so low is the bigger finding. Two of the three whole-grid passes
a frame performs are viewport-independent by construction:
ui/viewport.rs::compute_col_widths takes no viewport and walks every (row, col)
twice -- once for cell text, once for the column totals it must also size -- and a
column total must sum every row including scrolled-past ones (gotcha 44).
grid_of's own cell_display loop is the third pass and the only one windowing would
touch. So the whole grid is evaluated about three times per frame, every result
discarded, and at 18,000 cells a keypress costs four tenths of a second. Filed as
improvise-jmjz; its fix is caching or one fused pass, which this benchmark has now
distinguished from the windowing it rules out.
No criterion crate: plain Instant, and test = false so cargo test does not run a
target that takes seconds by design.
Closes improvise-i5tk
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`context/code/command.md` gains the derivation for improvise-jytm: what moved,
what projects per client, why `effect::SwitchView` is `ViewOnly`, and why
`adopt_active_view`/`Broadcast::ToOthers` are deleted rather than left.
`context/test-evidence/command.md` and `clients.md` follow the tests that were
replaced, each entry naming the fault injected to prove the new check can fail.
Two gaps filed rather than smuggled in: improvise-lltt (a view-scoped
`ModelCommand` names no view) and improvise-azhb (`Initial View` no longer
follows the TUI user).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
improvise-p1ym. GridCache::page_coords carried (category, selected item), so
PageNext/PagePrev resolved to a command name the browser held but could not
compose an argument for. They reached Host::handle, which answers an empty vec,
so a smoke test found them inert on the live page.
The item list is not a second field beside the selection: page_coords becomes
Vec<PageAxis>, and `items` is Some exactly when the axis is Axis::Page. That
puts gotcha 27's split in the type — a Filter axis constrains without being
navigable, so it carries no list and no caller can compose a cycle for one.
GridLayout::page_coords stays filtering_cats(), which is right for the bar,
and navigation.rs::page_cat_data stops spelling the Page-vs-Filter rule a
second time: it reads the layout's list.
The browser's page_cycle mirrors PageNext/PagePrev line for line, odometer
carry included, and `[`/`]` leave the still-needs-the-host list.
Two decisions stated in context/code/core.md. The list is
effective_item_names — the same call that already chose the default selection,
so selection and list come from one place; page_cat_data used
ordered_item_names before, which for a _Measure page axis let the default
selection be a formula target `[`/`]` could not reach. And the list is NOT
filtered to visible items: the TUI cycles hidden ones, and making the browser
differ would be the divergence rather than the fix.
The lockstep test's first version was vacuous — it rebuilt the client's cache
from the host and compared it against that same host. It now drives two
independent documents and compares View::page_selection; the injection that
reverses the odometer's significance now fails it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cycle-panel-focus`; the category panel's `cycle-axis-at-cursor`,
`open-item-add-at-cursor`, `filter-to-item`, `toggle-cat-expand` and
`delete-category-at-cursor`; the view panel's `switch-view-at-cursor`,
`create-and-switch-view` and `delete-view-at-cursor`; tile select's
`set-axis-for-tile` and `cycle-axis-for-tile`; the help overlay's
`help-page-next`/`help-page-prev`; `commit-buffer` in all three kinds; and the
two cell writes ADR 002 lets a thin client name, `clear-cell` and `paste`.
Each mirrors its `Cmd` line for line, status sentence included — a client that
emitted the mutation without the sentence beside it would diverge on the status
line and nowhere else. Two orderings are copied rather than rationalised:
`CommitBuffer` writes its optimistic status first (improvise-6t3) and
`TileAxisOp` writes its axis first.
**A divergence found and fixed on the way.** `effect::HelpPageNext` clamps at
the last page and `HelpPagePrev` saturates at zero, while `reduce_view`'s
`Command::HelpPage` arm *wrapped*. Two derivations of one fact, disagreeing,
invisible because `h`/`l` in the help overlay had always been upstream keys and
nothing had ever sent the command. `walking_off_the_end_of_the_help_pages_
agrees_with_the_host` was written first and failed — client 0, host 4 — and the
wire was then changed to match the effect, since the effect is what ships in
the TUI. Whether both should wrap instead is improvise-trd3.
**Three names the ticket listed do need enrichment**, filed as improvise-4bwa:
`toggle-prune-empty` words its status off `View::prune_empty`, which is in no
cache; `delete-formula-at-cursor` needs `(target, target_category)` and
`FormulaCache` carries only the raw text; and `view-back` emits
`ApplyAndClearDrill`, which is deliberately off the wire (gotcha 38) and is
blocked on ADR 001 layer 2 rather than on a projection.
The lockstep harness is new and is the evidence: it applies what the client
composed to a **second authoritative host** and compares the documents as
`.improv` text (`Debug` prints the symbol table's `HashMap`s, so it differs per
process — gotcha 18). Four injected faults — a wrong axis, a wrong status
sentence, a constant view index, and `AddCategory` for `AddItem` — each fail a
named test; the second one did *not* until the fixture was made to sit on a
category header as well as an item row.
`the_command_names_that_still_need_the_host_are_these` shrinks by exactly what
closed. 1318 tests pass, up from 1311.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
improvise-jytm, improvise-6c6q, improvise-k69y.
`Workbook::active_view` becomes the document's *default* — what a freshly
connected client opens and what `.improv` writes as `Initial View` — and
`ViewState::active_view` becomes authoritative per client. Two clients may sit
on different views of one document: the model is shared, the UI is not.
- `App::switch_to_view` writes only the client's slice; `App::view()`/
`view_mut()`/`view_name()` resolve it against the workbook.
- `effect::SwitchView`/`ViewBack`/`ViewForward` are `ViewOnly`, which takes
`ViewBack`/`ViewForward` off `ModelCommand` — their operand is one client's
history stack (the `ApplyAndClearDrill` argument).
- `grid_in`/`category_tree_in`/`tile_bar_in` project against the session's view;
`ClientSession` observes its own move through `projected_view`.
- `apply_switch` takes a `SessionId`, moves only the asker's mirror, and relays
`Broadcast::ToAsker` only when the document refused. `adopt_active_view` and
`Broadcast::ToOthers` are deleted: nothing produces them any more.
- `draw_status` reads the client's copy (improvise-k69y).
`the_two_active_view_names_never_diverge` is replaced, not deleted, by
`the_documents_default_view_outlives_this_clients_switches` and
`a_fresh_session_opens_the_documents_default_view`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ui/reduce.rs claimed every deployment mode reached the model through it. That was
aspirational, and it is what let improvise-ondj's missing basis check sit
unnoticed: reduce_full had no production caller anywhere in the workspace, so
nothing exercised its Command::Model arm and nothing could notice it skipped the
check.
Deleted rather than wired up, on the argument in its own doc comment: "Per-client
grid updates need a viewport and come from Sessions::project_all." It returned
half of what a host needs. The browser worker was the natural candidate caller and
reaches the model through Host::handle -> Sessions::apply_checked, which is the
half that has the viewport. Its only caller was a test helper, which now spells
the two lines it was.
apply_command's fallback arm no longer folds. It routed a wire view command into
app.view_state -- the authoritative slice -- which nothing in production did and
nothing should: Sessions::apply_checked folds it into the asking client's mirror,
because the model is shared and the UI is not. A client's SetStatus moving the
host's status line was behaviour that was safe only because the arm was dead.
view_slice_commands_land_in_the_authoritative_view_state pinned exactly that, so
it is inverted rather than deleted, and its fixture writes a value into the status
line first so the assertion can tell "the fold was removed" from "the field was
empty anyway". Restoring the fold fails it.
Sessions::apply is now #[cfg(test)]. Host::commands answers the whole frame with
CheckedBatch::against, so a door that answers one command and then dispatches is
the lost update improvise-955n fixed; cfg(test) makes "no production caller" a
fact the compiler keeps rather than a note verified at one commit.
Closes improvise-s17o
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
improvise-ir7l. project::cell_id decided Record vs Aggregate purely on whether
a regular category sat on Axis::None; it never asked whether the coordinate
named a formula target. So a formula cell was a Record, render.rs::editable
said true, and a write to it landed, was stored, and was then shadowed forever
by formula_cache — not a revert, a permanent silent shadow, on the demo's
front page.
Rather than a second "but not really" flag beside CellId (the two-answers
shape ADR 002 exists to forbid), a formula-owned cell now projects a different
kind of identity. Derived carries the formula's target and no surrogate, so
the write is unconstructible rather than refused.
The predicate is Model::formula_for(&CellKey), one home, which already existed
three times open-coded inside model/types.rs. The TUI lands in the same change:
EditOrDrill calls the same predicate and refuses, or the two front ends
diverge.
Two orderings are load-bearing and tested: Derived is asked before the record
branch (the coordinate is stored), and after the aggregate branch (gotcha 10 —
an aggregating formula cell is still drillable).
Two tests were passing while their names were false for the Profit column and
are renamed and two-sided now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only TransposeAxes and CycleAxis reset the cursor, while eight siblings that
renumber just as thoroughly did not. SetAxis is the sharp case: CycleAxis *is*
SetAxis to Axis::next(), so a pair differing only by an argument disagreed.
reset_cursor is also the only writer of cursor_epoch, which is the only thing
Sessions::adopt_cursor_epoch watches, so each gap was invisible in both
directions at once: the host's own cursor stayed stale and no client was told.
Gotcha 24 records that the second half is strictly worse than doing neither.
SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory,
RemoveCategory, AddItem, AddItemInGroup and RemoveItem now reset. The item
effects are in because "an append cannot move an existing position" holds only
of the outermost category on an axis: expand_category runs inside the cross
product, so an item appended to an inner category repeats once per outer block.
SetNumberFormat stays out and keeps the negative arm.
CommitAndAdvance's +cat branch depended on the missing reset — it stayed put so
the cursor would land on the column it had just created (improvise-2zq). It now
says so with an explicit SetSelected, placed after AddCategory's abort so a
refused category still leaves the batch alone.
The new test asserts of every case that row_items/col_items really changed
before asserting anything about a cursor; two faults were injected to show both
arms discriminate. Reasoning in context/code/command.md, evidence in
context/test-evidence/command.md. SetPageSelection under prune_empty is the one
residual and is filed as improvise-7kvw.
Closes improvise-9mo9
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`resolve`/`step` and `ClientState` lived in `improvise-wasm-client`, and
nothing about either was a browser fact: both depend only on `ViewState`, the
protocol widget caches and the announced window, all already protocol types.
The move costs no new dependency and makes design-principles §5 true rather
than aspirational — a new front end now needs exactly two things, its own
event → `KeyEvent` mapping and painting.
- `state.rs` → `improvise-protocol/src/client.rs`
- `keys.rs::{resolve, step, KeyOutcome}` → `improvise-protocol/src/resolve.rs`
- `socket.rs::{typed, typed_cell_command}` → `resolve.rs`, since `step` calls it
- `BrowserKey` stays in `improvise-wasm-client/src/keys.rs`: it *is* the
browser half of the two things a front end still owns
- `render_model` becomes a free function, `ClientState` being foreign now
`tests/lockstep.rs` is kept deliberately. It drives `reduce_key` — the host's
`Cmd`/`Effect` path — against `resolve`; those are still two derivations in two
crates, one unreachable from a browser, so the move changed which crate
`resolve` is spelled in and not that it is a mirror.
`doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)`
does not fire.
1311 tests pass, unchanged from the baseline: this commit moves code and
changes no behaviour.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
keymap.rs: the dispatch/registry-vs-table split (this file stays the authority
on it) and the improvise-0hb Sequence-flattening decline, with its three
findings.
cmd/**: the two CommitAndAdvance orderings, CommitSave's mode-change-first,
add_category's deliberate no-coordinate-write, EditOrDrill's mode-agnostic
message choice, the SaveAndQuit and CreateAndSwitchView declines, and fit.rs's
whole module doc including the measured wasm bundle cost.
crates/improvise-command now reports no comment run over two lines.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
host.rs: the one-App-per-document argument, why ClientFrame::Key answers
nothing, why Host::save bypasses Sessions, and the broadcast-before-projections
ordering. The 955n evidence -- including arm 3's check-before-apply trade that
clause 4 must pay back -- goes to the evidence file.
viewport.rs/cat_tree.rs: the residue of two derivations that moved to
improvise-protocol, and why each residue is the shape it is.
ui/ now reports no comment run over two lines.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The diff-not-declare rule, Dirty::of's derivation (now recorded in
context/code/command.md, which gotcha 35 should point at rather than the doc
comment), axis_frame's three group-header edge rules, the Broadcast audience
argument and Applied's three cases all move. Test narratives, including the
injected faults for hkt6 and the renamed-axis case, go to the evidence file.
One orphaned doc paragraph dropped: a struct doc describing a type that no
longer exists at that site had fused with fn diff's. Noted in the evidence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The residue-tag taxonomy, the record-as-unit-of-atomicity argument, the
process-local XOR fingerprint, and apply_effects reading dirtiness,
layout_version and the record fingerprints off one EffectKind, go to
context/code/command.md. Test narratives go to context/test-evidence/command.md.
One comment was wrong rather than long: entering_records_mode_sorts_existing_data
carried a stray unfinished first line from a neighbouring test. Dropped, and
noted in the evidence entry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EffectKind's no-default rule and the simple_effect! mandatory-kind argument,
the abort ordering Save/SaveAs/AddCategory rely on, and
ReturnToNormalUnlessMoved's observe-don't-predict, go to
context/code/command.md. Effect test bug-narratives go to
context/test-evidence/command.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Checked/CheckedBatch argument -- one basis per frame, checked once, two
tiers hoist and the record tier cannot, and the create-exemption stated over a
property -- goes to context/code/command.md. Test bug-narratives go to
context/test-evidence/command.md, keyed by test name.
The ```text fence illustrating the E0502 that killed the lifetime-tied Checked
moves with its argument rather than being deleted; it was never a runnable
doctest, only a block the checker exempts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CC-gp: move the gp-tree and improvise-gp reasoning into context/ prose (improvise-9c8c). 184 runs capped to none.
AND THE FINDING OF THE WAVE: cargo test --doc was ZERO across the whole workspace. All six 'doctests' were text or ignore, so none ran — the checker's doctest exemption was protecting nothing while letting 74 lines of CLI documentation claim it.
Both ignore blocks had drifted, which is exactly what ignore cannot catch: the fit sketch called fit::fit(&ds.rows, &y, &spec, ..) — the parallel x/y signature improvise-dkqn REMOVED, against a Dataset with no rows field, so the crate's headline example had not compiled for some time; and the breed sketch called a rank() that has never existed in this crate.
Two are promoted to running doctests (1309 -> 1311, and the 2 ignored become 2 passing). That is the one permitted non-comment change and it earned it.
CC-tui: move the TUI and root-crate reasoning into context/ prose (improvise-9c8c).
src/ui/grid.rs's 33-line module doc — which last week's documentation review called one of the four best-written explanations in the repository — moved whole rather than summarised. The agreement test's six injected faults and the THE_MEMBERSHIP_CORRECT_SPANS derivation are preserved in context/test-evidence/tui.md; that assert_ne! is a live tripwire for improvise-gd83.
mode_style's doc called its wildcard arm 'the intended default chrome'. It is a defect, not an intent: improvise-zx8t records that AppMode exhaustiveness holds for five enum methods and fails here, so SavePrompt took ExportPrompt's DarkGray by accident. Comment now names the ticket; flagged, not fixed.