Commit Graph

438 Commits

Author SHA1 Message Date
Edward Langley 3d0850c132 Merge main (d7bfcbb, PR #96) into wave/operandless-commands
Two textual conflicts, and one the merge resolved cleanly and wrongly.

effect.rs: #96 extracted the LayoutGeometry list into
layout_geometry_cases() shared by two tests, one of which compares it
against the wire vocabulary; my side had added ViewTarget arguments and
carried the base's assert_eq!(cases.len(), 13). Kept #96's structure with
my arguments and dropped the count -- the list-versus-vocabulary check is
strictly stronger and a count beside it would be a second answer to the
same question. Verified the count came from the merge base, not from my
side, before dropping it.

test-evidence/command.md: both sides appended entries at the same point.
Kept both.

app.rs auto-merged CLEANLY and did not compile: improvise-4dpj's new
a_batch_that_leaves_records_settles_out_of_records_normal constructs
effect::SetAxis, which now carries a view operand. Fixed with
ViewTarget::Active -- the host's own key path, which is what that test is.

Checked rather than assumed, per the brief:

- Test names diffed BOTH ways against the union of the two parents, not
  the count. Union 1510, merged 1509, exactly one name absent and it is
  the intended one: a_hosts_none_cats_leak_into_another_views_formula_columns,
  the ac2z characterization test my own branch replaced with
  a_formulas_columns_survive_a_host_that_aggregates_differently. Nothing
  else lost, nothing unexpected gained.
- #96's the_enumeration_names_every_variant_the_derive_declares is still
  discriminating against the reshaped ModelCommand variants: dropping
  TransposeAxes from every_model_command() fails it by name.
- The test-support feature stays off in shipped builds: cargo tree
  --edges normal --target wasm32-unknown-unknown finds no serde_json.

And the finding the brief asked for, which is the opposite of a
disagreement: #96's switching_views_across_records_ness_settles_against_
the_view_switched_to is the HOST-side twin of the client fix in this
branch. Same batch, same crossing in both directions, and independently
the same phrase in its message. Neither is redundant -- nothing in the
projection stream carries the host's ViewState, so a host-side guard
cannot see a client's mode or the reverse. Cross-referenced in
context/test-evidence/clients.md so neither is deleted as a duplicate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 11:08:17 -07:00
Ed Langley f1d98534f4 Merge pull request #96 from fiddlerwoaroof/wave/gate-and-coverage
Three checks that could not fail: duplicate evidence headings, the records-mode settle, the LayoutGeometry case list
2026-09-21 11:03:45 -07:00
Edward Langley 81fe546594 Settle the mode by observing the grid, not at each composition site
ChatGPT Codex's P1 on #98: switch_view_at composes
[SwitchView(target), ChangeMode(Normal)], and changed_mode settled that
against the grid the client STILL HOLDS -- the outgoing view's. Records ->
pivot emitted RecordsNormal and pivot -> records emitted Normal, which is
the cross-view drift this PR exists to close, by a second door.

The hand-back predicted this as follow-up 3 and called it unreachable
because R is a stranding decline in the browser. That is right about the
TOGGLE route and wrong about this one: records-ness is auto-detected from
the axes (gotcha 1), so a view switch changes it with nothing in the batch
saying "records". One door was enumerated and there were two.

So the remedy is general rather than a patch to switch_view_at.
changed_mode is DELETED; every site emits the plain AppMode::Normal; and
ClientState::fold settles after every fold against the grid it HOLDS.
Route-independent, so no enumeration of composition sites is load-bearing
any more -- the same shape the repo already prefers in cursor_epoch over
matching on CycleAxis, document_generation over matching on LoadModel, and
the dirty flag over a table of command variants.

improvise_protocol::settled_mode is the one rule and all three ends call
it: ClientState::fold, ClientSession::fold (the server's mirror, so the two
cannot diverge in a field nobody currently reads today) and
App::settle_normal_mode. App::normal_mode is gone -- it was that
function's host-side duplicate, and my change orphaned it.

Bidirectional coverage drives a real Host: a session connected to a
records-mode host, the pair folded locally and sent as a
ClientFrame::Commands, every reply delivered in arrival order. The
composed ChangeMode carries the STALE answer deliberately, because
composing the friendly value would let the records -> pivot direction pass
under the fault. Both directions fail with settle_mode no-opped, each
proved separately since the loop stops at the first.

Evidence, the injections and why the first fix's unreachability argument
was too narrow: context/test-evidence/clients.md, context/code/protocol.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 10:44:17 -07:00
Edward Langley 498582deb1 Bound every_model_command() with the derive (improvise-26dm, Codex P1)
The right-hand side of the LayoutGeometry completeness diff was a second
hand-written list. Both this branch's own review and ChatGPT Codex found it, and
the previous commit corrected the PROSE; Codex asked for the FIX. This is it.

the_enumeration_names_every_variant_the_derive_declares diffs
every_model_command() against variants::declared::<ModelCommand>() -- serde's
derive-generated variant list, improvise-5ubs's mechanism. The chain is now
derive -> every_model_command() -> model_effects -> case list, every link
checked, which is what the ticket asked for.

Injection is Codex's own, run end to end: a ModelCommand::ProbeGeometry whose
effect declares LayoutGeometry, with the model_effects and variant_name arms the
compiler forces, omitted from BOTH lists. Before: the whole workspace green.
After: staged, each stage naming the probe --
  every_model_command() never constructs ["ProbeGeometry"]
then, with the enumeration repaired and only the case list short,
  the_layout_geometry_cases_are_every_wire_effect_of_that_kind
  right: [... "ProbeGeometry" ...]
The staging is the point: the two tests answer different questions.

SCOPE OVERRUN, declared. `variants` was `#[cfg(test)] pub(crate)`, not merely
`pub(crate)`, and `declared()` needs serde_json, which improvise-protocol
carries only as a dev-dependency -- so the authorised one-token change could not
work. What it took instead, all plumbing, no behaviour change:
  improvise-protocol/Cargo.toml  serde_json optional + a `test-support` feature
  improvise-protocol/src/lib.rs  #[cfg(any(test, feature = "test-support"))] pub
  improvise-command/Cargo.toml   dev-dep enables that feature
The feature is OFF in every shipped build: `cargo tree --edges normal
--target wasm32-unknown-unknown` shows no serde_json, and all eight wasm-gated
crates still check clean. Drop this commit if the crossing is unwanted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 10:37:46 -07:00
Edward Langley c2bc939608 improvise-x1ns (2nd): the preflight counts a date measure's parts
P1 from ChatGPT Codex on #97, live at f128087 — in the change that fixed the
first instance of this same defect.

`preprocess` infers Numeric -> Date -> Categorical, and `encoded_width` had no
date arm at all. A date string does not parse as f64, so a measure column holding
dates fell through to the categorical count. `build_date` emits
epoch_day/year/month/day/dow, and seven parts once any cell carries a time, so a
column with ONE distinct timestamp promised 1 and materialised 5. Against a
budget of 64 that is an under-count of four on a single column, in the direction
that spends the allocation the preflight exists to prevent.

The fix is structural rather than another remembered branch, because two
instances one branch apart is the tell. `inferred_kind` reproduces `preprocess`'s
own match arm for arm and returns a `Forced`; `encoded_width` matches that with
NO wildcard, so a fourth variant is a compile error here instead of silently
taking the categorical arm — which is exactly how Date was missed. The .missing
flag moves out of the arms, since every branch owes it on the same terms.

The fixture carries a time case because of the RIVAL FIX, not the bug: "a date
column is 5" and "5, or 7 when any cell has a time" both repair the plain-date
failure and differ only on a column containing a time. Injected, `date_parts`
hard-coded to 5, the plain-date case still PASSES and the time case fails at
"promised 8 ... materialised 10". Injecting the bug proves the test sees the bug;
injecting the rival proves it sees the rule. Both arms compare against
`data.feature_names.len()`, never against a width computed a second way.

Also recorded in gp.md: the "must never under-count" line was written as a
proposed kb.pl invariant while the tree already held this counter-example. An
invariant generalised from the defect just fixed generalises the fix, not the
code; the question that would have caught it is "what else does `preprocess` do
that I have not mirrored", which is about the other function and not the bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 10:35:55 -07:00
Edward Langley ab6bb41da3 Review of this branch: two checks that could not fail, one drift left live
changed_mode settled only Normal -> RecordsNormal, but the host's
settle_normal_mode settles BOTH members of the pair, so a RecordsNormal
over an ordinary grid stayed put on the client and became Normal on the
host. Esc out of RecordsEditing resolves through entered() to
`enter-mode records-normal`, so the mirror-image drift was reachable by
the same route the fixed one was. Now total, with the pass-through arm
and both negative rows asserted.

a_compacted_seed_folds_to_the_same_cache_as_the_whole_history left
GridCache::records at its default, so a seed() that OMITTED the arm
compacted to an equal cache and the check could not see the omission.
The history now sets it; deleting the arm from seed() fails the test.

grid_updates' records arm had no test at all: grid_of setting the field
is covered by lockstep, but a client that connects before records mode is
entered learns only through the diff.

Evidence for all three, with the injected faults:
context/test-evidence/{clients,command}.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 10:32:54 -07:00
Edward Langley f6fca7ec98 A formula is evaluated for the view being projected (improvise-ac2z)
rebuild_layout recomputes formulas for the HOST's none_cats, and grid_in's
elsewhere path built another view's GridLayout against that cache. The
cache is keyed by the coordinates the recompute used, so a Total keyed
without a Product coordinate is not found by a view with Product on
Column, and prune_empty drops the formula's columns entirely. Absent
columns, not wrong numbers.

grid_in::rederived_for clones the model and recomputes for the target
view's none_cats. Three conditions gate the clone -- another view,
different none_cats, at least one formula -- so a client on the host's own
view pays nothing and a formula-free document pays nothing ever.

This is an interim and the cost is why: a whole-model clone plus a
fixed-point recompute per projection on exactly the broken path. The
ticket's first candidate, keying formula_cache by none_cats so one Model
holds more than one view's derived values, is the durable answer and is an
improvise-core change outside this wave's ground.

The characterization test is replaced by the positive one, and the
none_cats carve-out is dropped from
a_clients_grid_does_not_depend_on_which_view_its_host_is_on -- which is
the general property this is an instance of, and which fails under the
same injection.

Evidence: context/test-evidence/command.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 10:30:15 -07:00
Edward Langley 618801701e The browser asks the grid what kind of grid it is (improvise-e5pf)
One substitution at three decision points: wherever the host asks
layout.is_records_mode(), resolve.rs asked state.view.mode.is_records().
The two agree only while the mode and the layout agree, and
Command::ChangeMode(AppMode::Normal) is the message that separates them --
the host settles Normal against the layout the batch ends on, reduce_view
folds the variant it was handed.

GridCache::records carries the fact, projected by grid_of off
layout.is_records_mode() and diffed like the extent. Deriving it on the
client from row_frame.cats would have been a second implementation of
is_records_mode (improvise-edp's disease); deriving it from the mode is
the bug. Like Extent it frames nothing, so apply does not re-fit and its
batch position is not load-bearing.

The client's settling has one home, resolve::changed_mode, reached by
every site that can emit AppMode::Normal -- including entered(), which is
how a bound `enter-mode normal` gets there. The three decision points ask
state.grid.records, so a commit at a records cell declines rather than
composing typed_cell_command against a grid whose columns are
[coords.., +cat, measures.., +].

lockstep now DRIVES the keys: in_lockstep presses on both ends and
refreshes the client from a real projection rather than from the host's
slice, which is what let the old records coverage start from agreement.
The reachable path is Tab (category panel) then Esc; c/f/v are Ignored in
RecordsNormal and R is a stranding decline, which a first draft of the
test found the hard way.

Two resolve.rs fixtures put a records mode over a non-records grid -- a
state no host can produce -- and had to be corrected with the fix.

Evidence and both injected faults: context/test-evidence/clients.md.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 10:27:24 -07:00
Edward Langley f128087999 review: correct six prose claims, four of them false
An adversarial review of this branch found that the prose was worse than the
diff — including, twice, in the exact genre the branch was congratulating itself
for catching. Recorded rather than quietly rewritten, because that is the shape.

Must-fix, all verified before correcting:

- `fit.rs`'s comment on the exact check still read "this is the check that
  enforces the bound in both directions of a drift" — verbatim the sentence
  gp.md §14 now spends 25 lines refuting. Gotcha 48's third clause exactly: a
  fact stated twice is changed by grep, not by edit. I refuted the copy in the
  prose and left the copy in the code.
- "false for eight months" was fabricated. `encoded_width` landed 2026-09-19,
  the sentence 2026-09-20; the repository itself is six months old. A paragraph
  whose point is that unchecked cost claims are checkable closed on one.
- "NumericCategory was the one artefact family with no coverage" is false: it
  was one of three, and `ValueOfAMeasure` and `AbsentCoordinate` still have
  none. Corrected in both files that said it, and the remaining gap is named.

The decline's stated reason was wrong, which matters most because a decline is
the artefact nothing checks. It claimed one-hot is the only encoding of a
category whose splits are spellable; `is_expr` is a `primary`, so an ordinal
split IS spellable as a weighted indicator sum. Verified by running
`parse_formula`, not by re-reading the grammar, and now pinned by
`an_ordinal_split_on_a_category_is_spellable_as_an_indicator_sum` so the
paragraph cannot go quietly wrong again. e8no stays declined on grounds that
survive: a category's items carry insertion order rather than magnitude, and the
emitted formula spells one IS per item either way, so the width is not saved.

Also weakened two overclaims — "marking it a Label yields the same fit" (false:
the feature count moves the RNG draw sequence, gotcha 37b) and "asserted rather
than argued" (the dichotomy is argued from spell()'s exhaustive match; the test
asserts one instance) — narrowed "the only coordinate predicate is is_expr" to
what is true of it, corrected an uncheckable test count, named which two of the
three consumers of parse_number_format are actually guarded, and moved an
evidence block that had been inserted under the wrong module heading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 10:26:27 -07:00
Edward Langley 1dd6950ed3 A view-scoped ModelCommand names its view (improvise-lltt)
Nine ModelCommand variants configure a view and named it implicitly: the
effect read App::view_mut(), the HOST's slice. Since improvise-jytm made
ViewState::active_view authoritative per client, a client on another view
moved the host's view instead of its own -- its grid did not change and
every other client's did.

Each of the nine now carries `view: String`; model_effects maps it to
effect::ViewTarget::Named, and the nine effects carry a ViewTarget rather
than reading an ambient one. There is no operandless form left:
App::view_mut() is deleted and replaced by view_mut_of(&ViewTarget), so a
future geometry effect has to say which view it writes. The host's key
path passes ViewTarget::Active, which is right -- the TUI is the host and
has no session to ask. A Named view the document has not got is a no-op
rather than resolve_view's fallback onto the default.

Second half, which the ticket did not predict: naming the view made a
reframe of a view the host is NOT on reachable, and adopt_cursor_epoch
could not see one -- cursor_epoch is bumped only by the host's own
reset_cursor, so those clients were reset by nobody and told nothing
(gotcha 24's strictly-worse-than-nothing state). App::last_reframed_elsewhere
is the second trigger; its opening frames are taken lazily inside
view_mut_of, the one door such a write goes through, so the held-key path
still hashes one view and improvise-mcq4's declined cost is not paid.

Evidence and the three injected faults: context/test-evidence/command.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 10:23:27 -07:00
Edward Langley 019cbc1f72 Check the LayoutGeometry case list for completeness (improvise-26dm)
assert_eq!(cases.len(), 13) catches a case being DELETED from the list and
never one being ABSENT from it, and the two are written identically as an
assert_eq over a number. The adversarial review of PR #78 added a fourteenth
pub struct declaring LayoutGeometry, pointed at by nothing, and the suite
stayed green. doctrine(a_list_checked_for_length_reads_like_a_list_checked_
for_completeness).

improvise-5ubs's mechanism cannot reach this one -- an effect is a struct
implementing a trait, so serde has no derived enumeration to diff against. The
WIRE vocabulary does have one, and model_effects maps each ModelCommand variant
back to the effect it came from (gotcha 38), so the list is now diffed against
the LayoutGeometry effects reachable through ModelCommand. The two sets are
equal today, which is the evidence the substitution lost nothing. Injection:
drop TogglePruneEmpty -- the old count said 12 != 13, this names the effect.

reduce.rs's test module, every_model_command and effect_type_name become
pub(crate) so the enumeration and the name reader have ONE home rather than a
second copy in effect.rs.

Residual, stated in the evidence rather than closed: total over the wire, not
over the effects. An effect no ModelCommand names is still invisible -- exactly
PR #78's probe. A second [...; 13] array survives one function away in
every_layout_geometry_effect_is_visible_to_the_narrow_fingerprint; filed, not
smuggled in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 10:19:20 -07:00
Edward Langley aa06a9db84 improvise-711n: pin that a multibyte format string cannot split a char boundary
`format:` is `rest_of_line` in `improv.pest`, so the format string can hold any
text. `leading_digits` slices at the byte index `str::find` returns, which is a
char boundary by construction — but that is an argument, and this is the test.
It also pins that a non-ASCII digit (U+0662) names no precision rather than one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 10:19:14 -07:00
Edward Langley 78ca25b5e0 Test both arms of the records-mode settle (improvise-4dpj)
settle_normal_mode matches `Normal | RecordsNormal`, and replacing that with a
one-way `Normal` left the ENTIRE workspace suite passing: the existing crossing
test drives the layout both ways, but both branches end in `enter-mode normal`,
so the settle is handed `AppMode::Normal` either way. It discriminates "settles
at all" and "settles against the ending layout", never "settles from either
mode".

Gap 1, a_batch_that_leaves_records_settles_out_of_records_normal: the
production repro -- SetAxis { "_Index", Axis::None } from a records grid.
Injection is the one-way guard; it is the only test in the workspace that fails.

Gap 2, switching_views_across_records_ness_settles_against_the_view_switched_to
(raised by ChatGPT Codex on PR #91, verified correct on main first): the batch
SwitchViewAtCursor emits, both ways. A genuinely different path -- records-ness
is auto-detected from the axes, so nothing in the batch says "records".
Injection is moving the settle back above rebuild_layout; both fail by name.
Scope note in the evidence: this drives the effects, not the view-panel key
path, which needs a CmdContext in another wave's ground this round.

Also carries the command.md half of improvise-hsoe's three duplicate headings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 10:19:10 -07:00
Edward Langley e045f9871b improvise-x1ns: the preflight counts the (missing) one-hot; improvise-e8no declined
improvise-x1ns (found by ChatGPT Codex on PR #90, live on main at ce895ff).
`encoded_width` computed `has_missing` and used it in the numeric arm only, while
`build_categorical` maps every absent cell to MISSING_CAT and includes it in
`distinct(&mapped)`. A forced category with N distinct present values and one
record lacking that coordinate promised N features and materialised N+1.

That under-reports in the direction that costs. The preflight runs before
`preprocess`; the exact check on `feature_names.len()` runs after, so it bounds
the error message and not the allocation the preflight exists to prevent. gp.md
§14 claimed drift was "bounded in both directions by the exact check" — wrong,
and wrong against the argument two paragraphs above it in the same section. That
sentence is corrected rather than softened; the previous wave read this shape and
reasoned it away with it.

The clamp stays outside the addition, so an entirely-missing column — which
`preprocess` skips — is still counted as one and the fix moves only its own case.

improvise-e8no is DECLINED, on evidence rather than on appetite. `formula.pest`'s
only coordinate predicate is `is_expr`, a single-value equality: no IN, no
ordering over items, and a coordinate reaches an `Expr` only through IS. So the
predicates a tree may state about a category are exactly its items, one at a
time, and one-hot is the only encoding whose splits are spellable — the width is
the size of the vocabulary, not an implementation's appetite. Ordinal encoding is
already the classified refusal `Artefact::NumericCategory`, and hashing has no
document name at all. The pre-zv8t "fit that succeeded" therefore either never
split on the column, in which case Label is exactly equivalent and in budget, or
was refused at storage time: no fit is lost that was both storable and used the
column. Asserted by a_split_on_a_numeric_category_read_as_a_number_cannot_be_stored,
which is also the first coverage NumericCategory has had.

Both refusal messages said "a category with a distinct value per record becomes
one column per record", which diagnoses the runaway shape and not e8no's. They
now say a category becomes one column per distinct item, whether its items read
as numbers or not — that clause being the whole of what zv8t changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 10:18:38 -07:00
Edward Langley 3775d4ea1a improvise-711n: a trailing conversion letter no longer erases the precision
`parse_number_format` read the precision as `fmt[i+1..].parse::<u8>()` over
everything after the last `.`, so `,.2f` — the printf spelling the repo-map's
own `.improv` example carries — failed to parse and fell through to zero
decimals, silently, in the TUI, the browser projection and CSV export.

The precision is now the leading digit run after the last `.`, with any
conversion letter ignored. The spellings that are legal are stated once, in
`context/code/core.md`, along with why the trailing `f` is accepted rather than
refused (the grammar takes the format line as an opaque `rest_of_line`, so
refusing it rejects documents this repo already writes) and why the reading is
printf's rather than Excel's (`.00` is the spelling the two disagree on).

`export_csv_quotes_commas_per_rfc4180` asserted the value `starts_with("1,200")`
against a `,.2f` fixture, so it could not tell two decimals from none. It is now
an exact equality, and reverting the fix fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 10:14:27 -07:00
Edward Langley d41c8b8e6f Merge main (1a9f45a) into wave/protocol-render-gaps
#91 and #93 landed while this wave was in flight and appended to the same four
context/ files. That conflict is why CI never ran: a pull_request workflow builds
refs/pull/N/merge, and a CONFLICTING PR has no merge ref, so no run is ever
created. Neither the close/reopen nor the empty commit could have helped.

Resolution: keep both sides. Only context/test-evidence/protocol.md conflicted
textually; the other three auto-merged. Verified with --no-ext-diff that nothing
of this branch was dropped -- the only removals are main's own rewrites, of the
frame-basis paragraph in context/code/command.md and of the minibuffer-exit entry
(renamed _to_plain_normal -> _to_records_normal) in test-evidence/command.md.

NOTE the first three "nothing was dropped" checks were WORTHLESS and read as
clean: an external diff tool is configured, so `git diff | grep '^-'` was grepping
a side-by-side format for unified-diff markers and matched nothing whatever the
content. --no-ext-diff is what actually answers the question. A check that returns
empty for the wrong reason is indistinguishable from one that passes.

Fixed while re-reading, and pre-dating the merge: this branch had TWO entries
headed a_window_narrows_the_cells_and_neither_the_totals_nor_the_widths -- the
original, describing a fixture that no longer exists and repeating the claim the
PR #92 review measured false, and the superseding one. Merged into a single entry
whose subject is what each of the three fixtures was blind to. The evidence
checker cannot see this: it verifies that a heading names a real test, and a
duplicate heading names one just as well.

Merging gh/main (1a9f45a) rather than local main (1ec43a7): local main carries an
unpushed orchestrator kb.pl commit, and the PR's merge ref is computed against
what is on GitHub.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 05:33:07 -07:00
Edward Langley 902a77c174 review: two corrections from the #92 adversarial review, and a P2 they found
1. THE JMJZ WIDTH ASSERTION COULD NOT SEE A WINDOWED WIDTH, and the evidence
said it could. Injecting `if drawn { widen(..) }` -- widths from the window only
-- left 1476 passing, 0 failing. Two reasons, the second structural: the measure
was named `Revenue` (7 chars) against a value of `300021` (6), so the assertion
passed on the header alone; and comparing grid_body's widths against
compute_col_widths is INCAPABLE of testing windowing, because compute_col_widths
IS grid_body with an empty window. Windowing degrades both sides identically.
The same function twice is not a check, and it reads exactly like one.

Fixture is now the reviewer's shape -- a total that cancels and a one-character
header -- reordered once more so BOTH assertions discriminate: with the large
values last, the windowed row and the cancelled total were both 1.0, so the
totals assertion went blind to the row-window fault while the widths assertion
happened to catch it. Large values first:

  for ri in rows.clone()        -> totals [888888888.0] against [1.0]
  if drawn { widen(..) }        -> widths [10] against [11]

10 rather than 5 is a smaller gap and a better witness: the missing character is
the minus sign of a row outside the window.

2. THE T6DB none_cats PARAGRAPH REPORTED A NEGATIVE RESULT FROM A SETUP THAT
LACKED THE CONDITION -- and correcting it found a P2. The fixture was said to put
Product on Axis::None in one view and on Column in another. It did not:
add_category defaults to Column and nothing moved it, so all three views had
none_cats {_Index, _Dim} and the stated input to the hazard was absent.

Setting it explicitly failed the property on the first run, and not on a cell
value: Second loses BOTH Total columns when the host stands on Default.
rebuild_layout fills Model::formula_cache for the HOST's view, and grid_in on the
elsewhere path builds another view's layout against that stale cache -- the Total
cells are keyed without a Product coordinate, the other view asks for one, finds
nothing, and prune_empty drops the columns. Filed improvise-ac2z (P2). Not local:
grid_in takes &App and recompute_formulas needs &mut Model.

Guarded by a_hosts_none_cats_leak_into_another_views_formula_columns, a
characterization test whose message says that failing means the bug is fixed. The
property skips pairs whose none_cats differ and asserts compared > 0, so a future
fixture cannot skip everything and pass.

3. Smaller, all taken. "Nothing else fails" on the t6db injection was wrong: it
fails three tests, including the pre-existing prune-invalidation one -- the #78
fix had more coverage than it was credited with. And the 5ubs should_panic named
one thing and exercised another: pointed at a struct it reached only the
split_once branch, and all three panics shared one literal so `expected` could not
discriminate. The parse is now a `parse_expected` seam fed hand-written mutated
messages, each refusal with its own wording, plus a happy path.

1470 -> 1475 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 04:36:42 -07:00
Edward Langley a10e95eb5f fix(mode): settle Normal/RecordsNormal once, after the rebuild (review of #91)
Two blockers from the adversarial review.

BLOCKER 2, a new wrong state this PR introduced: resolving `AppMode::Normal`
per-effect reads the batch's OPENING layout (gotcha 23), so a batch that CROSSES
— `:toggle-records-mode . enter-mode normal` — settled against the grid it
started on and left `RecordsNormal` over a pivot grid. Base was correct there.

The fix moves the resolution to one place, `App::settle_normal_mode` called
after `rebuild_layout`, where there is no crossing case to get wrong. That is
strictly simpler than what it replaces: `ChangeMode` is an ordinary
`simple_effect!` again, `ReturnToNormalUnlessMoved` and `CloseWizard` write
plain `Normal`, and the per-edit coverage question the review filed as finding 3
is gone because there is one edit. Removing its call site fails exactly three
tests, workspace-wide.

And the prose defended the staleness with an example that does not bear the
weight: `:toggle-records-mode` works, but because `ToggleRecordsMode` and
`ViewBack` overwrite the answer in both directions, so it exercises nothing.
Replaced by a test that drives the crossing both ways.

BLOCKER 1, a false claim in `context/`: "keys.rs emits no ChangeMode" is true
and irrelevant — keys.rs holds only `BrowserKey`. The live browser key resolver
is `improvise_protocol::resolve`, which emits `Command::ChangeMode(Normal)` in
five places, and `reduce.rs` folds it with no resolution. The browser front end
still has the sihr bug; it is LIVE, not latent, and it compounds because the
client's only records-ness fact is the mode it just lost. Named honestly, with
improvise-e5pf cited. Still not closeable here — a client holds a `GridCache`,
not a `GridLayout`.

Also the two corrections: the thpe test's doc comment no longer claims a
discrimination it does not have, and the 2ud deferral notes that `registry.rs`'s
`visible_cols: 8` is production, not a test helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 04:18:36 -07:00
Edward Langley 94b9408425 Merge remote-tracking branch 'gh/main' into wave/command-tui-gaps 2026-09-21 04:15:01 -07:00
Edward Langley 822547a332 perf(project): one walk of the grid instead of three (improvise-jmjz)
grid_of evaluated the whole grid roughly three times per frame:
compute_col_widths walked every cell for its text, then every cell again for the
column totals it must size, and grid_of then evaluated the window a third time.
Every result was discarded at the end of the frame.

ui/viewport.rs::grid_body is now the one walk. It evaluates each cell once and
derives all three answers from that value -- widen the column, add to the
column's sum, and if the position is inside the requested window keep the
CellValue for cell_display to dress. compute_col_widths survives as grid_body
with an empty window, because the keypress path wants widths and no cells, and
two width rules would be the disease improvise-edp names.

MEASURED, orchestrator's machine, cargo bench --bench grid_projection, before
and after:

  typical (400 cells)      1.840ms -> 0.687ms
  large (9,600)           172.6ms  ->  50.0ms
  18k (gotcha 41)         294.4ms  ->  95.2ms
  pathological (90,000)     4.481s ->  1.320s

About 3x, which is the number of passes removed -- the shape the acceptance
criterion asked for rather than a speedup argued from the structure.

Three things the ticket did not predict.

The benchmark's col_widths column now measures something ELSE. It read 45-47%
and now reads 64-82%, which looks like a regression and is the opposite:
compute_col_widths IS the fused walk, so its share rose while the frame fell by
two thirds. A ratio whose denominator moved is not comparable across the change,
and the benchmark now says so.

The windowing ratio did not improve, and that is correct: the pass that remains
is exactly the one a viewport cannot narrow. improvise-i5tk's decline stands and
the two findings are now cleanly separated.

It is not solved, only three times cheaper -- 18k cells is still 95ms, 5.7
frames. The next lever is caching ACROSS frames.

Guard: a_window_narrows_the_cells_and_neither_the_totals_nor_the_widths. Fusing
three consumers into one walk creates a hazard the separate passes did not have,
and the natural "optimisation" (iterate rows rather than 0..row_count) makes a
column total the sum of the visible rows, silently. Watched fail with
left: [1.0] against right: [300021.0]. All 1469 pre-existing tests unchanged,
which is also the evidence the outputs did not move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 04:07:51 -07:00
Edward Langley d42253ef38 review(gp,io): the zv8t forcing defeated its own budget, and two narrower fixes
Adversarial review round on the branch. One real finding, two prose/comment
corrections, one fixture widened.

F2 (the real one, and introduced by zv8t). `as_categories` forces by column
NAME; `encoded_width` counts by column INDEX. A document may legally name a
regular category and a raw measure the same thing — the repo already ships that
fixture in `a_category_and_a_measure_of_the_same_name_are_refused` — and the
measure column was then forced categorical by the name match while the preflight
still counted it numeric by index. Measured at 40 records: preflight 41, actual
80. `no_ambiguous_column_names` ran AFTER `preprocess`, so the expansion the
preflight exists to prevent was materialised and only then refused; at the
default max_records that is a 20k-row one-hot matrix built before the error.
That is `doctrine(guard_the_cost_not_the_result)` defeated by an inconsistency
this ticket introduced — not the bounded approximation that
`doctrine(a_budget_check_is_advisory_or_exact_and_must_say_which)` licenses.

The repair is a data dependency rather than a moved line: `as_categories` runs
the check and returns `Result<Overrides>`, so the Overrides `preprocess` needs
cannot exist until the header has been accepted
(`doctrine(an_invariant_is_made_unrepresentable_not_validated)`). The regression
guard is therefore the SIGNATURE and the injection is a compile error — swapping
the two statements back gives E0425, verified. What it does not cover is stated
in the evidence rather than implied: a deliberate deletion of the check from
`as_categories` is still possible; the accidental reorder is not.

F5. The `encoded_width` comment claimed the preflight "must count it the same
way". It does not, quite: `build_categorical` counts MISSING_CAT as a distinct
value and the preflight does not, so it under-counts by one per category with an
absent coordinate. Bounded by the exact check and pre-existing for genuinely
categorical columns — but the comment asserted an identity that does not hold.

F4. The evidence for the two spelling tests said that on the unfixed tree the
text "could only have been `Expr::Ref(\"Year\")`". There is no text on the
unfixed tree at all — `fitted_formula` refuses before emitting. The argument for
the assertion survives; the stated reason was a guess about a branch that does
not run, and an evidence file is the one place that should be exact.

And the iot6 fixture: `prune_empty` prunes BOTH axes and the test asserted rows
only. `Feb` is now the empty column, the precondition is a pair on each layout,
and the CSV header is asserted to carry `Jan` alone.

1464 passing, up from 1463.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 04:02:02 -07:00
Edward Langley e3fcdc8bc6 test(lockstep): take the fixtures' basis from the server, not from a recipe (improvise-1l9o)
furnished_client and client_ready_to_edit built their BasisCache by walking the
client's own grid for Record cells and calling basis::published(0, 0, records)
with every version hard-coded to zero -- a small re-implementation of
project.rs::basis_update's record selection, in a file whose entire purpose is
to rule out re-implementations. Both now take the Projection::Basis out of a
real Sessions::connect snapshot.

The reason the ticket calls it a nit is the reason it needed a test: a fresh App
really is at generation 0, layout 0 and every record at 0, so the fabrication
and the publication are indistinguishable on every existing fixture -- all 23
lockstep tests pass under either. So the change alone is structural and
untested by construction. the_fixtures_basis_tracks_a_host_whose_layout_has_moved
moves a tier first and then asserts the fixture's claim names the host's current
tiers; re-fabricating the two document tiers fails it (0,0) against (0,1) and
fails nothing else.

Worth recording: SetNumberFormat was the first attempt and does not move
layout_version, although it declares Model(LayoutGeometry). layout_version is
OBSERVED from view_shapes rather than read off the declaration (improvise-pdvm),
and a number format is not part of a view's shape hash. Classification is not a
promise about the counter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 04:01:08 -07:00
Edward Langley d8df9196f5 decide(protocol): keep ColWidths diffed against the mirror, for a different reason (improvise-44ad)
The move -- diff col_widths against the label-folded cache, as hkt6 already
obliged for totals -- is DECLINED, and the two things learned are worth more
than the change would have been.

THE TICKET'S STATED COST DOES NOT EXIST. It says the move breaks
a_client_on_a_socket_is_seeded_and_gets_its_edit_back, and that that assertion
is gotcha 33(c)'s standing evidence, so rewriting it inside a change 33(c)
guards is the improvise-q0vb / PR #12 shape. Measured by deleting the re-fit
from fit_to_labels: three existing unit tests fail and the socket test is not
one of them. What it actually asserts is that a reframe CARRIED widths -- a
statement about the producer's diff, not about the consumer's re-fit. 33(c) is
triply guarded elsewhere.

THE REAL REASON IS COUPLING. The widths a label-fold invents are each column's
own label width: an approximation the consumer applies until the authority
catches up. Diffing the producer's output against it makes what the server sends
depend on a fallback rule defined in the client -- invisible today, silent the
day a second client implements the fold. The redundant ColWidths per reframe
buys out of that, and over-sending is the direction Dirty::of is deliberately
sloppy in.

Landed anyway: the half the ticket calls "the better test either way".
a_client_never_holds_widths_of_a_different_length_from_its_labels asserts the
invariant 33(c) is really about, at EVERY step of a fold that widens before its
widths arrive and narrows before they shrink -- not merely at the end, which is
where a message-count assertion looks. Watched fail at step 0 with the re-fit
deleted.

The totals/col_widths asymmetry is now stated rather than discovered: totals is
fold-relative because hkt6 found a real hole; col_widths has none, the re-fit
being a safe fallback rather than a stale value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 03:59:03 -07:00
Edward Langley 877d3e193b refactor(help): a chord is recognised by its modifiers, not its spelling (improvise-thpe)
`compact` tested whether a label it had just formatted began with "Ctrl-" or
"Alt-". No false positive today — the reviewer enumerated every
`format_key_label` output — so this is the shape, not the behaviour.

`Hint::keys` is now `(KeyCode, KeyModifiers)` and `compact` takes the pairs, so
the string is not in scope to parse; `Hint::labels` and the help pages format
last through the one spelling in `keymap::key_label`. The regression guard is
the signature, as it is for `Checked`.

`Keymap::labels_for` — the "throw the modifiers away" step the ticket names —
has no caller left and is deleted: a consolidation that keeps its old path keeps
the defect available.

The rule also got wider and total over the bitset: a chord is any modifier
beyond shift. Injected a temporary `KeyModifiers::SUPER` spelled `Super-x` and
ran it — the old filter keeps it, `compact` drops it. Binding dump byte-identical
across the change (4406 lines).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 03:57:32 -07:00
Edward Langley c4bf74608f fix(protocol): a group run is opened by a header, not by a name (improvise-h0iv)
group_runs coalesced adjacent positions by group NAME, so two runs under two
different headers sharing a name read as one and both front ends drew a single
span across the boundary.

THE TICKET'S PROPOSED FIX DOES NOT WORK, and that is worth more than the fix.
h0iv suggested comparing the (cat, name) pair, FrameGroup carrying cat for
exactly that reason. But its own reachability case is ONE category -- Month
grouped into Q1 nested under Region, giving North/Q1 and South/Q1 adjacent --
so both headers are (Month, Q1), equal as values, and the pair separates
nothing. A test pins that so the next reader does not re-propose it. What does
separate them is that they are two distinct FrameEntry::Group entries, which
groups() already borrows from self.entries: std::ptr::eq on the header is the
literal statement of "the same header opened both".

THE PRODUCT QUESTION, decided here and reversible in one comparison. Whether
two adjacent same-named runs are one span a user wants spanned is a matter of
taste, which is why #77 filed rather than fixed. What settled it is not the
span but the indicator: the existing wasm-client test merged Month/Q1
(expanded) with Fiscal/Q1 (COLLAPSED) and its own message said the merged span
is drawn with the first one's indicator -- a collapsed group rendered as
expanded, with no affordance saying otherwise. A span boundary is taste; a
wrong collapse glyph is a wrong fact.

That test's justification was also spent: it said the rule must be name-only
"because that is what src/ui/grid.rs uses", and since improvise-8qlr grid.rs
calls group_run_heads. There is no second copy left to agree with, which is the
general point -- a consolidation's frozen behaviour reads as decided afterwards,
and nothing says it was frozen only for the duration of the move.

The agreement fixture gains the case it could not see: two_level_rows minus the
ungrouped Sep, so the two Q1 runs abut, end to end through a real GridLayout and
both renderers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 03:57:07 -07:00
Edward Langley e6e39a03ff review(gp): the zv8t prose claimed a cost it has, and the cost is measurable
Self-review of 99cfb7f, of the prose rather than the diff. §11 said the forcing
cost nothing — "no document that fitted before stops fitting" — reasoning that a
tree splitting on a numeric category was refused as an artefact anyway.

That is false. The refusal is conditioned on `used_features`, so a fit that
simply never split on the numeric category SUCCEEDED, and the wide categories
are exactly the ones a fit has least reason to split on. Measured with a
throwaway #[ignore] probe on both sides of the fix — 100 records, a 100-item
numeric `Year` beside a `Cost` that predicts `Score` on its own:

  pre-fix:  FIT LANDS -> SUM(IF(Cost < 48.0150032043457, 970.3.., 3684.7..))
  post-fix: REFUSED   -> would encode 101 features from 100 records

So the sentence one paragraph below improvise-wd6n's corrected "permanent
refusal" was the same failure mode: a claim nothing in the suite could check,
green either way. Noting it because the pattern, not the sentence, is the
finding — `doctrine(prose_that_calls_something_permanent_forecloses_the_ticket)`
has a sibling in prose that calls something free.

The trade is still right, for a reason that is NOT "both refuse": the old
success was accidental, since `Year` was encoded as a continuous number over
1900–1999 that the fit could only have used by producing a formula it would then
be refused for. What is genuinely lost is the case where the user wanted
neither — a wide numeric category left alone while the fit uses the measures.
Filed as improvise-e8no (P3), with the acceptance criterion that the fix is a
budget that can decline A COLUMN rather than the whole table, and an explicit
"do not close this by making the forcing conditional on cardinality", which
would be inference by another name.

`a_wide_numeric_category_now_refuses_a_fit_that_used_to_ignore_it` pins both
halves. Its second half is what keeps it from being a bare characterization: it
marks `Year` a Label and asserts the fit then lands on `Cost`, because advice in
an error message is a promise and this is the half worth asserting.

Note for the record: 99cfb7f's own message carries the false claim and cannot be
corrected. Per design-principles §6, the correction lives in context/ and this
says so — the message is superseded by §11 and by the test-evidence entry.

1463 passing, up from 1462.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 03:56:47 -07:00
Edward Langley 8e9842b89f fix(cursor): the frame basis follows active_view, not the next geometry effect (improvise-9c9o)
PR #78 round 2's `was == self.view_name()` guard fixed a spurious invalidation
and introduced its mirror: the first geometry effect after a mid-batch
`SwitchView` skipped the check entirely, because there was no basis for the new
view yet, so the code re-based and skipped. Under-reporting, which gotcha 35
says is the expensive side — a stale cursor has nothing in the stream to correct
it.

The re-base now happens at the switch, in the loop, as soon as the name moves,
so by the time the geometry arm runs the name and the basis agree by
construction and the guard is gone as unreachable. `SwitchView` stays
`ViewOnly`: the basis is `App`'s own bookkeeping and none of it is on the wire,
so this is not the protocol change the ticket feared.

Also deletes a fixture line in the sibling test that was a complete no-op —
`views["Second"].clone_into(&mut views["Default"].clone())` clones into a
temporary that is dropped — and replaces it with the assertion it was pretending
to be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 03:53:37 -07:00
Edward Langley 9b81324d6a fix(protocol): a frame that lost its origin must not claim to lead the axis (improvise-1wl0)
AxisFrame::first carried #[serde(default)], so a frame whose first was dropped
in transit deserialized as 0 -- not an absence but the claim "this frame leads
the axis", which is precisely what body_from acts on. A scrolled browser would
draw the carried group header above its first row, silently.

Dropping the attribute rather than reaching for Option<usize> or a two-variant
enum, and the reason is about the wire rather than the type: there is exactly
one production producer, reading window.start, so a frame is never partially
constructed and "unknown" is not a state an honest peer can be in. Making it
representable would only oblige body_from to answer the same question one level
down. A missing field is now a loud parse failure at the transport.

Same argument that gave Basis no Default (gotcha 43). The Default derive still
yields first: 0 and that is not the same hole -- a default AxisFrame has no
entries, so the claim is vacuous either way.

Test written first and watched fail, printing the forged first: 0. It asserts
the key was present before removal, that the mutated value is still a
well-formed AxisFrame minus one field, and that the error kind is "missing
field" -- the three ways this check could otherwise pass for the wrong reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 03:53:15 -07:00
Edward Langley 99cfb7fb41 fix(gp): a numeric- or date-valued category is spellable (improvise-zv8t)
`fit_measure` handed `preprocess` an `Overrides::default()`, so it never said
which header columns are document categories — although the very next line told
`Features::of`. `preprocess` then inferred Numeric -> Date -> Categorical from
the values, so a `Year` category with items 2024/2025 became a numeric column
and a category of ISO dates became a date column, and both were refused as
encoding artefacts with `Year IS 2024` spellable all along.

`fit_measure` now builds its `Overrides` from the same
`header[..category_columns]` slice it already hands `Features::of`, forcing each
to `Forced::Categorical`, so the one place the document's own answer was known
is now the only place either caller reads it from. `Forced::Categorical` already
existed; no formula-language change was needed.

Three things follow, and the last is a cost.

The date decline in gp.md §11 now means what it says. A `DatePart` could arrive
over a *category* column by exactly this accident, so that decline was narrower
than it read; it now covers a genuine date measure and nothing else.

The `Artefact` arms stay and stay correct. `crate::dataset` and `fitted_formula`
take the caller's `Overrides`, so a caller that does not force can still produce
a `Numeric` role over a category column. The arm is no longer reachable from
`fit_measure`, which is the only production entry point — it is not dead code,
and deleting it would derive the classification from one caller's habits.

Forcing widens the encoding, deliberately: a two-item `Year` is now two one-hots
rather than one numeric feature, and a two-hundred-item one is refused by
`MAX_FEATURES` where before it was refused one stage later as an artefact. Both
refuse, so nothing that fitted before stops fitting; what changes is which guard
speaks, and the width guard's message names the repair. `encoded_width` had to
learn the same slice for that to hold, or it counted such a column as one
feature and handed the refusal to the exact check *after* materialising the
matrix — the side of the cost the preflight exists to stay on (§14).

Evidence. The two spelling tests needed no fault injected: on the unfixed tree
`fit_measure` refuses with the two real messages the ticket named. Each fixture
gives the model one regular category, no other feature column, and a target that
category alone determines — because the refusal is conditioned on
`used_features`, so a fit whose champion happened to be a bare leaf would be
accepted and prove nothing. Asserting `formula.raw` contains `Year IS` rather
than merely `is_ok()` closes that in both directions. The preflight test did
need one: dropping the `col >= category_columns` clause makes it fail by name,
with the *exact* check speaking where the preflight should.

The prose this corrects was itself the ticket's subject. improvise-wd6n's §11
called `Artefact::NumericCategory` a permanent refusal that "no vocabulary
closes", which is false and is what filed improvise-zv8t. §11 and the gp
test-evidence entry are both updated to say it is closed rather than open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 03:52:42 -07:00
Edward Langley 6ee8ff389d fix(io): CSV export writes the rows the view prunes (improvise-iot6)
`render_csv` built its layout with `GridLayout::new`, which does not apply
`prune_empty` — only `with_frozen_records` does. So `:export` in the TUI and
the CLI export path both wrote rows the grid does not draw. User-visible, in
production, and shipping for as long as the two constructors have differed.

The fourth instance of this trap; the third was `project::grid_in` in PR #78,
and this is that fix line for line.

The fixture is the difficulty, and the ticket says so. The sibling property
written for improvise-t6db passed on the buggy tree in its first version,
because it made its two views differ by turning pruning *off* — vacuous, since
with nothing to prune both code paths agree. So this one contains a row that is
actually empty, leaves `prune_empty` ON, and asserts the precondition in both
directions before exporting: `with_frozen_records` draws 2 rows where `new`
holds 3. If pruning ever becomes a no-op the precondition fails and says so,
rather than the assertion passing vacuously.

No fault needed injecting — the bug was live. On the unfixed tree the test
fails with `["Food", "Gas", "Rent"]` against `["Food", "Gas"]`. None of
`export.rs`'s three pre-existing tests mentions pruning at all, which is
exactly why this shipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 03:52:32 -07:00
Edward Langley 4e5f9f3ab8 test(protocol): make the cyclic all() prove it reaches every variant (improvise-5ubs)
AppMode::all() and Rejection::all() walk a total cyclic after(), and both doc
comments claimed a new variant could not be omitted. Half true, wrong half:
answering after() FOR a new variant is compiler-forced, pointing an existing
arm AT it is not, so a variant nothing names is never walked -- measured by
the PR #78 reviewer as all().len() == 17 against 18 variants, suite green.

Closing it needs an enumeration independent of after(). Stable Rust has none
for an enum with data -- variant_count is nightly, a derive macro is a new
proc-macro crate, and strum's EnumIter cannot construct AppMode::ItemAdd or
Editing at all. serde can: the Deserialize derive already builds the complete
variant-name list from the enum definition and publishes it in its
"unknown variant ... expected one of ..." message. variants.rs reads that back
and diffs it against the names the cycle reached, each taken off the variant's
own serialization rather than off Debug.

The helper is defensive because an unparsed message would return an empty list,
and an empty list passes every cycle; a should_panic test points it at a struct
and asserts the message, not merely that it panics.

Both overclaiming doc comments replaced. The fourth instance of this shape --
assert_eq!(cases.len(), 13) in ui/effect.rs -- is over a hand-built list of
effects rather than enum variants, so serde has nothing to say about it; left
filed rather than bodged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 03:52:04 -07:00
Edward Langley 6782e8555c fix(mode): a minibuffer opened over a records grid returns to it (improvise-sihr)
`AppMode::Normal` is now resolved through `App::normal_mode()` at the moment the
mode is set, so no keymap binding and no commit command carries an exit mode.
Two readers, one function.

The ticket proposed carrying the entry mode on `MinibufferConfig`; declined, with
the reason in context/test-evidence/command.md — carried generically it also
returns `CategoryAdd` to `CategoryPanel`, and carried for three modes it is the
one-arm shape the ticket itself argues against.

The binding table is untouched, so `doctrine(binding_dump_diff)` has nothing to
diff: what was lost was the runtime mode, not a binding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 03:51:37 -07:00
Edward Langley 20e9bd4106 test(project): pin host-view independence for grid_in (improvise-t6db)
PR #78 fixed grid_in to build a non-active view with with_frozen_records
rather than GridLayout::new, which skips prune_empty, and landed with no
test of its own. This is the general property: which view the host stands
on is not an input to any client's grid.

The fixture is the load-bearing part. The reviewer's first formulation made
the two views differ by turning prune_empty OFF in the second, which passed
on the buggy tree -- with nothing to prune, both constructors agree. So the
fixture gives one region no cell at all, leaves all three views pruning, and
asserts every view really is two rows before comparing anything.

Widened while writing it: an aggregate formula, so the views also differ in
none_cats, which grid_in likewise does not rebuild for the elsewhere path.
That asymmetry is argued and NOT observed -- see context/test-evidence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 03:48:46 -07:00
Ed Langley 1cafdad2fc Merge pull request #78 from fiddlerwoaroof/wave3/protocol-session
Wave 3: protocol and session — five consolidations, the cursor observed, and lltt found live
2026-09-20 22:14:33 -07:00
Edward Langley 630b84066c fix(gp): NumericCategory is a filed gap, not a permanent refusal
Review findings on this branch, all three in the gp commit.

The prose was the worst of them: §11 called Artefact::NumericCategory a
permanent refusal that "no vocabulary closes". It is not. fit_measure hands
preprocess an Overrides::default(), so it never says which header columns are
categories — although the next line tells Features::of — and preprocess then
infers Numeric -> Date -> Categorical from the values. A `Year` category with
items 2024/2025 becomes a numeric column and is refused, with `Year IS 2024`
spellable all along. Forced::Categorical is the existing knob. Filed as
improvise-zv8t. Prose that calls something permanent is what stops the ticket
being filed, which makes this the branch's own defect class one level up.

MissingFlag's words said "that measure", but its arm is not conditioned on
is_category and dataset.rs writes "" for an absent coordinate, so a .missing
flag over a category is reachable — the same "declined in words naming
something they are not" the commit was written to fix. Now "that column".
ValueOfAMeasure's tail likewise covered only the present case.

The test module claimed "each artefact family" while its fixture reaches three
of five; named for what it reaches. Re-injected the collapsed-reason fault
after the reword: still fails, so the negative clauses still bite.

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

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

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

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

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

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

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

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

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

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

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

Two things beyond the merge.

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:52:58 -07:00
Edward Langley 173d9820a6 feat(gp): decline the artefact families separately, in their own words
improvise-wd6n. Spelling::Artefact was one variant standing for five distinct
families, the last five reached through a `_ =>` wildcard in spell(). The one
refusal they fed said the tree "splits on a col.missing flag or a date part",
so three of the five were declined in words naming something they are not.

Artefact now names which family, spell() is exhaustive (a sixth FeatureRole is
a compile error rather than a sixth thing that message misdescribes), and the
refusal gives one clause per family.

The two families the ticket asks about are declined in writing in
context/code/gp.md §11, separately, because each needs different vocabulary:
col.missing needs a word for absence that reaches apply_agg and eval_agg, and
the date parts need a date type improvise does not have anywhere. Nothing new
is emitted, so improvise-4zl7's route-then-aggregate rule is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:51:17 -07:00
Edward Langley 97177112a3 perf(io): the grammar generator re-parsed improv.pest per case, not per run
improvise-8shu. improvise-io's lib binary was the run's outlier at 1.42s
against a 0.01s median. The ticket's diagnosis was an unbounded grammar walk
generating deep documents; measuring the two halves separately falsified it.
load_grammar() x1000 was 2.50s and the walk x1000 was 15.7ms — improv_file()
called load_grammar() inside its prop_map, so pest_meta re-parsed the grammar
once per generated case.

The strategy's input space is unchanged. The grammar is read once through a
OnceLock, and a guard counts parses rather than calls so it is independent of
which test in the binary runs first. Case counts go 500 -> 256, the default
design-principles §7 prescribes and never below it.

1.42s -> 0.21s; improvise-io is no longer the run's longest binary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:48:34 -07:00
Ed Langley fb405a6301 Merge pull request #84 from fiddlerwoaroof/fix/ws-origin-check
fix: check Origin at the websocket handshake (improvise-kc7n)
2026-09-20 21:32:32 -07:00
Edward Langley ec236da7c5 Merge remote-tracking branch 'gh/main' into wave3/protocol-session 2026-09-20 21:32:02 -07:00
Edward Langley 313e02f522 Merge #79, and fix improvise-mcq4: the frame observation costs one build, not four
Merge of `gh/main` at 23f6def (#79, #80, #82) together with the fix for the
regression this branch measured and filed. One conflict, in
`context/test-evidence/clients.md`, where both sides appended a section: both
kept.

improvise-mcq4. The observation was building every view's pruned layout, before
and after every model effect, and `with_frozen_records` applies `prune_empty`,
which walks cells. Two changes, and the first one is the one that mattered — not
the one that looked obvious:

- **Narrowing to the active view bought nothing.** It was the coordinator's
  suggestion and mine, and measuring it is what showed why: the bench's second
  view is a fresh one with everything on `Axis::None`, so it costs nothing to
  build. The per-view multiplier was never the cost; two *expensive* builds were.
  Kept anyway — `active_view_frame` is the honest shape, and the audience half is
  now the config diff `observe_reshape` already computes for free, which
  over-reports in the safe direction and is read only behind the epoch gate.
- **The opening basis is free.** At batch start `self.layout` already IS the
  active view's pruned layout, so `opening_view_frame` hashes it instead of
  building a second one. `frame_of` is the shared hash; `view_frame` builds then
  calls it. Guarded on `drill_state.is_none()`, because a drill freezes the record
  list and the held layout is then not what `view_frame` would build. Opening use
  only — `self.layout` is stale mid-batch (gotcha 23).

MEASURED, one `CycleAxis` batch, `prune_empty` ON, against the same fixtures:

                     before fix   after fix   baseline
      400 cells         283.2us     167.0us      93.6us
    9,600 cells          7.34ms     3.879ms     1.967ms
   18,000 cells         12.41ms     5.752ms     3.043ms
   90,000 cells         91.69ms     56.54ms     28.52ms

4.1x down to 1.9x at the canonical size, and back inside the 16.7ms frame budget
where it had been outside it. What remains is one pruned build per model effect,
which is inherent to observing a pruned frame; the last one duplicates
`rebuild_layout`'s work and could be elided only by moving the check after it,
which is the position that clobbers a deliberate cursor placement in the same
batch. Not done, and improvise-mcq4 carries the residual.

BOTH CONSTRAINTS RE-RUN AFTER THE CHANGE, because narrowing is exactly what could
quietly stop covering things:
- Subsumption: #76's own `a_page_flip_under_prune_empty_invalidates_the_cursor`
  and its negative twin both still pass with no per-effect reset anywhere.
- Breadth: `AddFormula`/`RemoveFormula` and `SortData` still covered.
- Disabling the narrowed observation fails ten tests, so it still does the work.
- `a_reshape_of_one_view_leaves_a_client_on_another_view_alone` still passes, so
  the cheap audience set has not widened into nynk's negative arm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:31:58 -07:00
Ed Langley 0947fd0c6c Merge pull request #81 from fiddlerwoaroof/wave/core-formula-io
core/formula/io: a duplicate-section round trip, the WHERE word boundary, and a parameter that promised a filter
2026-09-20 21:31:04 -07:00
Edward Langley 664b4a7780 fix: the --allow-origin help was a three-line comment run
CI's `check-comment-runs.py` runs with no flags and the local gate in my brief
runs it with `--check-evidence`, which reports the evidence audit and not the
run cap — so a three-line clap doc comment passed locally and failed in CI.
Run both spellings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:27:25 -07:00
Edward Langley 01d48f2ce9 Merge remote-tracking branch 'gh/main' into fix/ws-origin-check
# Conflicts:
#	context/test-evidence/clients.md
2026-09-20 21:26:39 -07:00
Edward Langley d5995814d1 review: the round-trip test could not see a merge that dropped the section
Adversarial review injected the other fault -- Some(_existing) => {}, which
drops the duplicate section outright -- and the whole improvise-io suite stayed
green at 2000 proptest cases while the fixture's item and its group line
vanished from the document. A test that compares two printed forms cannot tell
unstable-but-lossless from stable-and-lossy, because both sides of the equality
lose the same thing. Three contains assertions now run before the equality, and
they fail under that injection.

Two more from the same review. push_category destructures PCategory, so a fourth
field has to be named rather than silently dropped on the duplicate path only.
And design-principles.md's formula-tokenizer section described a tokenizer that
no longer exists -- a split_where pre-parse step and Token::Ident/Token::Str,
none of which are in the tree -- and claimed bare identifiers allow spaces,
which bare_ident forbids. This branch changes that production, so correcting the
sibling copy is the grep gotcha 48 asks for.

improvise-349m improvise-r08u

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:26:34 -07:00
Ed Langley 23f6defb19 Merge pull request #79 from fiddlerwoaroof/wave/browser-page
The browser page: a title bar, a live pointer, a scrolling selection, and casts with no shell in them
2026-09-20 21:23:36 -07:00
Edward Langley cead3210e5 fix: two Origin headers state none a policy can answer for
Review of the previous commit. `HeaderMap::get` is first-wins, and the
deployment README recommends puts a reverse proxy in front of the socket — one
that APPENDS `Origin` rather than setting it leaves two, and answering from the
first lets the second be anything at all. No browser sends two, so two is not a
state any policy can answer for and the handshake is refused, exactly as an
`Origin` that is not text already was.

Guarded by two_origins_state_none_this_policy_can_answer_for, proved by
restoring the first-wins read, which fails that test alone across the
workspace. The test also asserts the allowed origin BY ITSELF is still allowed,
so the refusal is demonstrably the second header rather than the first.

Second review finding: a_handshake_from_a_foreign_origin_is_refused matched
"403" in a Display string, which cannot tell a refused upgrade from a dropped
connection. It now matches Error::Http and asserts the status. Proved by
changing forbidden() to UNAUTHORIZED, which fails it and
a_refusal_is_a_forbidden_response.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:21:49 -07:00