Commit Graph

1371 Commits

Author SHA1 Message Date
Edward Langley b5a4d227fe docs: rc3 is on gh alone, not "all three remotes"
FOUND BY RUNNING THE COMMAND THE CLAIM IMPLIED. `git ls-remote --tags` against
each of gh, origin and gitea: v0.1.0-rc3 exists on gh ONLY. origin and gitea
carry v0.1.0-rc1 and v0.1.0-rc2 and nothing else.

The claim originates in aa4e901's commit message and was repeated from there
into context/code/tooling.md and, an hour ago, into RELEASING.md by me -- three
copies of a fact nobody had asked a remote about. That commit message is
superseded on this point and cannot be corrected, which is the case
context/design-principles.md section 6 makes for not letting an argument live
only in one; the correction is therefore recorded in tooling.md, naming the
commit.

The irony is local. The false claim sat two paragraphs from the sentence "an
empty log is a statement about RETENTION, not about history", which is the same
failure in the same section: a fact about a remote asserted instead of asked.
Writing that sentence did not stop me repeating the neighbouring instance of it,
which is the more useful half of this commit.

What it changes and what it does not. The rule is untouched -- a published tag
is not moved, and gh is the remote carrying the release machinery, so the
preflight's reason for existing is unaffected. What moves is the SCOPE of any
cleanup, from three remotes to one, and that is precisely the sort of detail a
reader following RELEASING.md at 2am would act on. RELEASING.md now says which
remote and tells the reader to run ls-remote rather than assume the mirrors
agree -- they did not.

No version bumped, no tag cut, no tag deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 22:29:57 -07:00
Edward Langley 50d93eb299 docs: a release procedure, and the overreach the tag history corrects
THE ROOT CAUSE WAS A MISSING DOCUMENT, NOT A MISSING CHECK. Nothing in this repo
said "bump the version before you tag": release.yml is cargo-dist's autogenerated
mechanism, README's Installation section describes the result, and plan.md
Phase 3 carries one-time SETUP instructions from the launch plan. So the
preflight added in the previous commits is the second half of the fix. RELEASING.md
is the first, and it is a checklist rather than an explanation -- the reasoning
stays in context/code/tooling.md section 4, because a checklist followed at 2am
is not read for its argument and one carrying it does not get followed. Its own
file rather than a README section because README Installation addresses someone
INSTALLING and this addresses the one person PUBLISHING.

THE DIAGNOSIS WAS UNDERSTATED AND IS NOW CORRECTED AT THE SOURCE. The earlier
passage rested on a local `dist plan` run, which is the weaker half. v0.1.0-rc1
and v0.1.0-rc2 both carry COMPLETE published GitHub Releases from 2026-04-11 --
all four targets, .sha256 files, sha256.sum, dist-manifest.json, source.tar.gz
and improvise-installer.sh. The workflow is not merely expected to work; it
demonstrably ran end to end the last time a tag and the version agreed.

Nearly the opposite was concluded from an empty `gh run list`: Actions logs age
out at 90 days and those tags are five months old. An empty log is a statement
about RETENTION, not about history -- the artefact a run published outlives the
run, so ask the releases before asking the runs.

AND THE MISTAKE IS RECURRENT, which is the real argument for writing a procedure
down. `gh run list --workflow=release.yml --event=push` keeps the whole tag
history: rc1 FAILED TWICE (14s each) before succeeding on the third push
(3m25s), rc2 succeeded first time (3m9s), rc3 failed (21s). Three occurrences
across two releases, all the same version/tag disagreement. A one-off justifies
a note; a third justifies a document and a check before the irreversible step.

The failure signature is recorded because it is cheap to read and easy to
misread: `plan` fails in ~20s with the four jobs behind it skipped -- verified
on the rc3 run, where plan is `failure` and build-local-artifacts,
build-global-artifacts, host and announce are all `skipped` -- against ~3
minutes for a real build. The workflow ALSO triggers on pull_request, where it
plans without a tag and passes in ~20s, so a green 20s run on a PR and a failed
20s run on a tag look alike at a glance and mean opposite things.

Every claim above was read off `gh`, not inferred. Step 5 of RELEASING.md said
"if plan succeeds and the four jobs skip" in a first draft; the run says plan
FAILS, and it was corrected rather than kept as a plausible-sounding shape.

No version bumped, no tag cut, no tag deleted, the rc1/rc2 releases untouched.
Whether the stale rc tags are ever removed is left as the user's call and
deliberately not made a step: it is not part of cutting a release, and the
principle that covers it -- a published tag is not moved -- is stated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 22:28:15 -07:00
Edward Langley 708d335fa7 ci: the preflight repair relocated its blind spot; close it
REVIEW OF THE PREVIOUS COMMIT, NOT NEW MACHINERY. The diagnosis it states was
re-verified in both directions before anything here was written: `dist plan
--tag=v0.1.0-rc2` gets past the tag/version check (stopping at the pinned
cargo-dist-version, as expected) and `--tag=v0.1.0-rc3` reproduces the CI error
verbatim, naming `--tag=v0.1.0-rc2` as the fix in its own help text. The
workflow was never broken. Both claimed fault injections also reproduce exactly:
the comparison replaced by `if false;`, and `expected` derived from the tag, each
produce four failures and exit 1 -- exit code read unpiped, since a pager's
status would mask it.

WHAT THE TEST COULD NOT SEE. Disabling the dirty-tree guard outright left all
six checks green and the script exiting 0. That guard is not decoration: it is
what stops a tag naming a commit that was never tested, and its failure is
irreversible in the same way the version mismatch is.

The cause is the previous repair. Moving the negative cases to a clean throwaway
checkout is what made the tag comparison reachable at all -- and it thereby
removed the only circumstance in which the test could observe the tree guard.
The guard whose PRECEDENCE caused the original vacuity became the one thing the
suite could not see. The vacuity and its repair are not two events; the repair
relocated the blind spot rather than closing it.

So the new check is aimed the other way: a tag that MATCHES the version, run
against a dirtied checkout, so the comparison passes and a refusal can only be
the tree. Verified discriminating -- with the guard disabled it fails with exit
1, and the two faults are independent in both directions (comparison disabled
leaves the tree check passing, guard disabled leaves the three tag checks
passing), so the seventh check is not a restatement of the other six.

Also recorded, rather than guarded: the `dist plan` second opinion can be
disabled with the suite green, because every input the test feeds it has already
been refused by the textual check. That follows from the arm being optional by
design -- pinning it would mean installing a dist matching cargo-dist-version,
the very mismatch the script tolerates on purpose. Stated so the silence is not
mistaken for coverage.

Two corrections of fact. The CI step lives in the `kb` job beside
pr-watch-test.sh, where this repo keeps its check_the_checkers -- not in `test`;
tooling.md now says so. And the awk version parser was probed rather than
hardened: `version.workspace = true` yields empty, which the root path refuses
loudly and the per-crate loop skips; a non-first [package] block and build
metadata both parse correctly; a trailing comment on the version line mis-parses
but fails CLOSED and prints the mis-parsed value in the refusal. No manifest in
this workspace has one, so nothing was changed for it.

No version bumped, no tag cut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 22:25:37 -07:00
Edward Langley aa4e901bd2 ci: a release preflight, because the tag is already public when CI refuses
THE WORKFLOW WAS NEVER BROKEN. v0.1.0-rc3 was pushed to all three remotes
while every crate still said 0.1.0-rc2; dist matches a tag against the root
package version, refused with "This workspace doesn't have anything for dist
to Release!", and the four jobs behind `plan` skipped. Proved in both
directions locally: `dist plan --tag=v0.1.0-rc2` gets past the check and
`--tag=v0.1.0-rc3` reproduces the CI error exactly.

So this changes no trigger and no job graph. What it adds is a check that runs
BEFORE the irreversible step. dist named the right answer in its own error
text; the problem is when that answer arrives -- a tag is public and immutable
by the time CI reads it, so the repair is a new version rather than an edit.

scripts/release-preflight.sh refuses a dirty tree, compares the intended tag
against the root package version, runs `dist plan` as a second opinion when
dist is on PATH (and says so when it is not, since an absent dist must not
read as a pass), and prints the tag-and-push commands only once it passes.

THE TEST'S FIRST VERSION WAS VACUOUS AND THE INJECTION IS WHAT SHOWED IT.
release-preflight-test.sh originally asserted only "did it refuse". Every
negative case still passed with the tag comparison replaced by `if false;`,
because the script refuses a DIRTY WORKING TREE first and this repo's tree is
almost always dirty -- bd auto-exports .beads/issues.jsonl on nearly every
command. Four of five checks were answering "is the tree dirty" while claiming
to answer "does it catch a bad tag".

It now runs the negative cases against a clean throwaway checkout AND asserts
the refusal names the version mismatch, so a refusal for the wrong reason
fails. Two rival faults were injected against the repaired version -- the
comparison disabled, and `expected` derived from the tag itself -- and each
produces four failures and exit 1.

That is design-principles section 7's "a check that looks like the answer,
answering a neighbouring question", found inside a checker written to prevent
that class, within an hour of writing it. Recorded in context/code/tooling.md.

No version is bumped and no tag is cut here, per the user's instruction to
make the workflow work without releasing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 22:20:34 -07:00
Ed Langley f34334cfca Merge pull request #116 from fiddlerwoaroof/docs/wave-corrections
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
docs: gotcha 38's stated reason was false, and four waves' worth of kb facts
2026-09-21 22:12:10 -07:00
Edward Langley 06f847bb66 docs: four more stale facts in repo-map, found by the h3y7 audit
An audit that had to read the code to answer a completeness question kept
tripping over the map. Each of these is a fact the map states that the tree
does not support, verified before applying:

- gotcha 3 said model_effects sends 28 ModelCommand variants. A brace-matched
  count of the enum's top-level variants returns 24. Same staleness class as
  the gotcha 38 arithmetic corrected in the previous commit.
- gotcha 43 named `reduce_full` twice in the present tense. It has been DELETED;
  `reduce_key` is the survivor. Note the paragraph's own argument PREDICTED
  this ("no production caller anywhere in the workspace, so pub(crate) would
  have made it dead code") -- so the deletion vindicates the reasoning and
  falsifies only the tense. Recorded that way rather than quietly renamed.
- the File Inventory's counts for ui/reduce.rs (1411 -> 1080), ui/effect.rs
  (2731 -> 3034) and resolve.rs (2477 -> 2765) had drifted, two of them by
  more than the 10% the inventory's own preamble warns about.
- "50+ effect types" is now 58, with the number that matters recorded beside
  it: a brace-match over `impl Effect for` in effect.rs finds only 49, missing
  8 simple_effect! expansions and SetTransientPrefix in command/keymap.rs. All
  9 it misses are ViewOnly -- the half the ViewBack bug lived in. gotcha 38
  warns that the naive awk OVER-reports; the brace-matcher it recommends
  instead UNDER-reports, and nothing said so until now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 21:48:33 -07:00
Edward Langley 5a3d1d1109 docs: gotcha 38's own arithmetic was stale in the same way (Codex P2)
The paragraph's head says 26 model effects and 24 ModelCommand variants; its
tail still said "29 - SwitchView - these two = 26", which subtracts SwitchView
and forgets ViewBack/ViewForward. So the corrected gotcha contradicted itself
and the enum's own variant count.

Verified independently before applying: a brace-matched count of ModelCommand's
top-level variants in improvise-protocol/src/reduce.rs returns 24.

Worth noting what this is: the correction landed in the same commit range as the
sentence it contradicted, which is exactly the "one copy of an argument updated
and not its siblings" failure gotcha 48 names. A paragraph about a derivation
going unchecked for completeness had its own sum go unchecked.

Found by ChatGPT Codex reviewing PR #116.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 21:47:32 -07:00
Edward Langley d517b03dbf Merge branch 'main' into docs/wave-corrections 2026-09-21 21:36:40 -07:00
Ed Langley 44b8819083 Merge pull request #115 from fiddlerwoaroof/wave/t-drill-wire
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
The view stack is the client's own (improvise-gd28)
2026-09-21 21:36:01 -07:00
Edward Langley 286c09960a kb: what the integration role learned that no branch could
Four doctrines from the wave q/r/s/t integration, each measured rather than
reasoned:

a_merge_prints_pop_whether_or_not_it_stashed -- git invites a stash pop on a
conflicted merge even with an empty autostash. Three occurrences, twice with
stash@{0} being another session's WIP, on a stack shared across nine worktrees.

a_missing_loose_ref_is_not_a_deleted_branch -- a watcher reading refs/heads
directly cannot tell a deleted branch from a packed one. Both happened in one
event.

rebuild_the_integration_on_the_new_base_and_diff_the_trees -- when three PRs
merged upstream mid-integration, rebuilding on the new base gave a BYTE-IDENTICAL
tree, which proves the upstream merges lost nothing rather than inferring it.

a_reported_sha_is_correct_and_short_lived -- three stale SHA tables in one
session; the remedy is a watcher, not diligence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 21:28:15 -07:00
Edward Langley df2c1fb37c Merge branch 'main' into docs/wave-corrections 2026-09-21 21:28:04 -07:00
Edward Langley fa1f572335 Merge remote-tracking branch 'gh/main' into wave/t-drill-wire
# Conflicts:
#	context/test-evidence/command.md
2026-09-21 21:28:00 -07:00
Ed Langley 9eb13119ae Merge pull request #114 from fiddlerwoaroof/wave/s-resolve-keys
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
fix: five category-panel and axis defects, four of them P1 (koh6, g3no, uej8, z336, 25bf)
2026-09-21 21:26:26 -07:00
Ed Langley e3eef3c0a1 Merge pull request #113 from fiddlerwoaroof/wave/r-browser-page
browser: paint the page bar and the totals strip, and delegate the pointer
2026-09-21 21:26:08 -07:00
Ed Langley 96969c7bdc Merge pull request #112 from fiddlerwoaroof/wave/q-keymap
One global keymap every mode inherits from (improvise-636d, improvise-piyp)
2026-09-21 21:25:56 -07:00
Edward Langley 67c390687d fix(protocol): a refused switch is undone, not overwritten
ChatGPT Codex, PR #115, and it is this PR's own regression. Once `reduce_view`
records history, the CORRECTION for a refused switch records history too: the
asker folds `Missing` (back gains the view it left), then folds the correction
(back gains `Missing`), while the guarded mirror holds neither. `<` afterwards
lands on a view the document never had, and `forget_view` cannot help --
nothing vanished, the name was never real.

Reachable rather than theoretical: no shipped client can invent a view name,
but one can pick a name from its projected list that another client deletes
before the frame arrives.

Two changes. The mirror now folds what the client folded, refused or not --
a correction can only undo what BOTH ends hold, and guarding one end is what
let them drift. And the correction is `[ViewBack, ForgetView(asked)]` rather
than a `SwitchView` back, because `ViewBack` is the exact inverse of the push
while a `SwitchView` pushes another frame and leaves the back stack one entry
longer than it started.

The residue is asserted, not hidden: the optimistic fold clears the forward
stack and no correction can refill it, so a refused switch costs the redo
history exactly as an accepted one does.

The neighbouring test compared `active_view` alone and stayed green through
all of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 21:21:19 -07:00
Edward Langley aeff017626 fix(panel): a singleton category's item reached the escalation anyway (Codex P1)
A ChatGPT Codex review of #114 found the cursor fix wrong for a category with
exactly ONE item, and it is this PR's own argument turning against it: that is
the configuration where the conditional retreat degenerates into the
unconditional step-back the PR argued was worse than the bug. No sibling below,
so it stepped back onto the category's own header -- one d from RemoveCategory.

Reproduced on both paths first, asserting the CONSEQUENCE rather than a landing
index: rebuild the post-deletion tree, seat the cursor where the command put it,
and ask whether a second d composes a RemoveCategory. It did, on both.

The enumeration is what makes the second fix total. CatTreeEntry has two
variants and flatten_cat_tree emits a header then its items, so after deleting
the item at i: below is a sibling -> the list closes up, stay; above is a
sibling -> step back; NEITHER -> the category is now empty and both neighbours
are Category rows, so there is no safe ADJACENT landing at all. That third case
is structural, which is why the rule could not be patched in place.

The landing is therefore the nearest Item row, preferring above then below --
never a header. Cost, stated rather than hidden: the cursor may cross into
another category, and a held d then deletes one item per press, each with its
own status line, walking upward. It never reaches RemoveCategory, which is the
criterion the review asked for. Landing on the emptied category's own header
was rejected even though RemoveCategory there is cheap -- an empty category has
no cells, and a non-measure formula target IS an item, so no formulas. That is
a harm-magnitude argument where the repo prefers the bad state unreachable.

Residual, stated: if the deleted item was the only item in the whole tree there
is no Item row anywhere and the cursor does not move. Every category is empty
then, so no landing exists that would avoid it.

Both fixtures are fenced so they cannot pass for the wrong reason. The browser
one expands BOTH categories, because a first version expanded only the
singleton's and the cursor fell off the shortened tree, where the delete arm
answers that there is no category to delete -- green for a reason unrelated to
the fix. The TUI one seats the two-item category FIRST, so the backward search
(the preferred direction) finds a real landing.

Injected the pre-review rule on both paths: each new test fails, and the two
adjacent-case tests still pass. The singleton is the discriminating input and
nothing else in the suite sees it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 21:21:18 -07:00
Edward Langley 8eb93628b2 kb: the view/server split exists so clients can differ, and that is a test
User: 'The whole point of the view / server split is that multiple clients can
have different views of the model.'

Recorded as the RATIONALE rather than a fact, because it is the single premise
the day's four rulings follow from -- client-local view stack, ephemeral drill,
client-only view switching, and identical staging. gotcha 29 already said 'the
model is shared and the UI is not'; this says why, and converts the split from a
fact into a test: does the proposal force two clients into the same view?

Plus the two integration doctrines: an autostash instruction is not permission to
pop (the stash stack is shared across six other sessions), and a capability and
its key can land in different tickets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 21:18:01 -07:00
Edward Langley 0cfbc6f373 kb: browser drill stages identically, because staging and the basis check answer different questions
User ruling. An agent proposed that a browser write through with ordinary
SetCell commands rather than staging, since those are already basis-checked.
The substitution does not hold: the basis check is anti-staleness coordination
between clients and answers whether a picture is CURRENT; staging is an
editing-session abstraction for one user and answers whether they want to KEEP
what they typed. Write-through would silently remove the ability to abandon a
drill -- invisible to any test that asks only whether an edit lands.

The convergence that makes it cheap: clause 4 is delivered, so a staged commit
is a batch of SetCells taking its atomicity from the transaction that already
exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 19:50:37 -07:00
Edward Langley e3fc2210b8 kb: the doctrine this wave's four agents earned
Twelve facts across four PRs. The ones that generalise furthest:

a_completeness_check_closed_over_the_omitting_artefact_is_blind -- the parity
guard asked whether every RenderModel field is painted, and its domain was
RenderModel, the very thing that can omit a field. It reported a clean bill for
a whole wave while totals and page_coords were painted by nothing.

an_escape_hatch_is_where_a_new_check_goes_vacuous -- both bugs in a new guard
were in its exemptions, never its check. '&state.grid' is a substring of
'&state.grid.row_frame'.

a_cursor_rule_copies_between_panels_only_if_the_list_is_homogeneous -- the
obvious repair for uej8 is worse than the bug.

a_ticket_can_be_one_level_too_high_and_its_fix_unimplementable -- g3no's stated
fix could not work on the browser side twice over.

deliberate_slack_can_become_load_bearing_with_nobody_editing_it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 19:32:56 -07:00
Edward Langley 05a3e3f490 docs: gotcha 38 said ViewBack left for a reason that was false
It claimed ViewBack/ViewForward were excluded from the wire as session-local
residue, like ApplyAndClearDrill and AcceptWizardModel. But view_back_stack IS
in ViewState -- the portable slice -- so by the view half's own derivation rule
they were always Commands and were simply MISSING.

The cost was a live divergence: effect::SwitchView pushes the leaving frame
while reduce_view's arm set active_view and nothing else, so no client ever had
a back history. That, not the drill commit the declined() comment blames, is why
view-back was dead in a browser.

Found by the agent on improvise-gd28 (PR #115) while checking the derivation for
COMPLETENESS -- it had only ever been checked for correctness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 19:32:38 -07:00
Edward Langley 507447c48a docs: the arguments the two commits' doc comments point at
Every `context/code/...` pointer added by this branch now names prose that
exists. protocol.md §2 carries why `ViewBack`/`ViewForward` CLOSE a gap in the
view half's derivation rather than widening it, why the fold and the effect had
diverged, and why a guard was written and then deleted. command.md carries why
`forget_vanished_views` and `adopt_deleted_views` do not subsume each other.

A pointer to prose that does not exist reads as a promise the explanation does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 19:30:42 -07:00
Edward Langley 2a3a69d803 fix(protocol): a deleted view leaves every client's history
Found reviewing the commit before this one, and the defect is that commit's
own: giving clients a back stack gave them dead frames.

`effect::DeleteView` moved `active_view` off the doomed view and left both
stacks alone. That was already wrong for the TUI -- `<` could reach a frame
naming a view the document had lost -- and invisible only because no client
had a stack at all.

Worse, the guard the previous commit added made it a loop. `apply_step`
corrected a dead landing by folding `SwitchView(default)`, which pushes the
dead view straight back; `<` then popped it, was corrected, and pushed it
again forever.

`ViewState::forget_view` is the one home, called by `effect::DeleteView` for
the host and reached by `Command::ForgetView` for every client. Which views
vanished is OBSERVED by diffing the name set across `apply_command`, for the
reason `adopt_cursor_epoch` watches a counter rather than a command variant.

`Sessions::apply_step` is DELETED. The pruning makes a dead frame
unrepresentable, so the guard had no reachable input, and a guard whose fault
cannot be injected is indistinguishable from one that always passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 19:29:15 -07:00
Edward Langley 614d818e75 test: three self-review findings on the above
The code-review skill run against this branch, and the findings are about my
own tests rather than the code.

1. The formula sweep's predicate is (target_category, target), and nothing
   discriminated the first half: a sweep on `target` alone passed every test
   I had written. Two categories may legally hold a target of the same name,
   so removing Metric/Profit must not take _Measure/Profit. Proved by
   injecting `retain(|f| f.target != item_name)` and watching it fail.

2. Both `cycling_an_axis_...` tests read the status off `last()`, which a
   resolver emitting the status and NOT the mutation also satisfies. Both now
   assert the mutation is first.

3. Two things the fix leaves that nothing guards, both filed and both written
   down rather than left in a PR body. improvise-nwgk:
   doctrine(a_status_line_composed_beside_a_mutation_lies_when_the_mutation_no_ops)
   asks a fallible mutation to RETURN whether it acted, and remove_item still
   returns unit -- the reachable lie is gone, a wire RemoveItem naming nothing
   is not. improvise-en4s: Dirty::of(CellData) reporting formulas: true was
   slack over-reporting and is now load-bearing, because a delete can take a
   formula out of the document; narrowing that arm would strand every client's
   formula panel forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 19:28:40 -07:00
Edward Langley 56b3058136 docs(browser): the arguments for the page bar, the totals and the delegation
Self-review findings, applied.

The word `Total` is now spelled twice — `src/ui/grid.rs` and
`web/shell.js::gridPlan` — which fails design-principles §5's own test: would a
third front end write this again? Yes. It is kept local on the `indicator()`
precedent (the collapse glyph is spelled in both ends while the FACT is shared)
rather than built into a protocol string table for one word, and it is now
NAMED as debt at both the call site and in `context/code/clients.md` instead of
being left for the next reader to find.

`frameCostOf` now counts the totals strip, and that is a change to a rule
`context/code/clients.md` already states — so the section says why the strip is
reserved where the inline group rows deliberately are not: it is present
exactly once, never interleaved with data, so counting it is neither the
feedback loop nor the ratchet that argument rules out. The page bar needs no
rule at all, being outside the box `extentOf` measures.

The evidence file claimed three injections into `dispatch.test.js` that had not
been run. They have been now, and the claim is rewritten to match what actually
failed rather than what was planned — the third was `navigable: true`
unconditionally, not the `position !== ""` reading the text described.
`doctrine(an_injected_fault_must_be_the_fault_you_named)` applies to the
description as much as to the fault.

Injections run and read, all against the served copy or the source:
  gridPlan takes the table row index    -> both round-trip tests fail
  whole() drops its empty-string guard  -> both "means nothing" tests fail
  navigable: true                       -> the Filter test alone fails
  grid bound to `click`                 -> 4 Playwright tests fail, including
                                           the pre-existing improvise-0x9u guard
  paintPages never called               -> the page bar assertion fails
  the tfoot block never runs            -> the totals assertion fails
  page_coords deleted from render_model -> the Rust page-bar test fails
  totals_strip returns Vec::new()       -> the Rust totals test fails, and the
                                           new cache-parity guard reports it
  PageAxis::cycle replaced by items.is_some() -> the cycle index test fails

Two residuals named rather than discovered later: the `Total` spelling above,
and the page bar being display-only — clicking an entry does nothing, because
composing a page change from a click needs a `click_page` on the wasm surface
that does not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 19:26:57 -07:00
Edward Langley b4bd6bcdc4 test(command): the global guard names the command, not merely one
Self-review under the code-review skill, two findings against this branch.

**doctrine(binding_dump_diff) had not been run**, and it is the doctrine
aimed at exactly this change: a green suite is not evidence for a keymap
refactor. Diffed: 84 added lookups, 0 removed, 0 CHANGED -- no binding
anywhere answers differently. 40 of the 84 are not a binding change at all
but the widened probe space hitting ImportWizard's catch-all on F(3)..=F(12).
`:` produced zero rows, because every mode that now inherits it already bound
it locally: the duplication was real and consistent, which is also why nothing
in the suite could have caught the two spines drifting apart.

**The fallback arm was vacuous.** `shadowing_answer` returned `None` for the
six non-shadowing modes, so the test asserted only that a global key resolved
to SOME plain command -- "is this key bound", where it means "does this key
reach the global". It now answers with the global's own command name, read off
the global rather than restated, leaving Help's `?` as the only hand-written
expectation because it is the only decision.

Proved by injecting the rival fix rather than only the bug: giving TileSelect
`Arc::new(global_keymap())` -- its own equal-but-separate copy -- leaves this
test GREEN, since every key resolves to exactly the right command. Only
`every_modes_keymap_chain_ends_at_the_same_global` fails, which is the
clearest statement of why that one compares by pointer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 19:25:48 -07:00
Edward Langley e2417414ad docs: the arguments the new comments point at, and the test evidence
context/code/core.md gets the two model rules (why removing a name removes
the formulas targeting it, and why a required virtual category refuses) and
the axis rule (why leaving the constraining axes forgets the page selection,
and why the predicate is filters_data rather than 'is it Page').

context/code/clients.md gets improvise-koh6's decision in full -- including
why the state-dependence rule is about Upstream and not about outcomes --
plus the cursor rule and the shared axis sentence.

Evidence entries are module-qualified where they must be: three leaf names
are answered by a test in both resolve.rs and panel.rs, and one in both
model/types.rs and workbook.rs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 19:24:47 -07:00
Edward Langley 9d70f4fcf6 feat(protocol): the view stack is the client's own (improvise-gd28)
Two user rulings, 2026-09-21: the state drill manipulates (the view stack)
is local to the client, and a drill is an ephemeral view. The first one is
what this lands; the second is scoped in the hand-back.

`effect::SwitchView` pushes the view it leaves onto `view_back_stack` and
clears the forward stack. `reduce_view`'s arm set `active_view` and nothing
else -- so a connected client's own history was empty however many views it
visited, and `view-back` had no stack to pop. Both write the same portable
field, and the fold is meant to be the mirror's copy of the effect.

`reduce_view` now mirrors all three effects line for line, and `ViewBack` /
`ViewForward` join the view half of `Command`. They carry no basis and dirty
nothing: the stack is in `ViewState`, so this is UI, not document.

The case a client-local stack creates is a frame naming a view somebody else
deleted. `Sessions::apply_step` guards the landing exactly as `apply_switch`
guards a switch, correcting the asker alone.

Also: `drill_constraint` and `drill_snapshot` get one home in
`improvise-core::view`, where the addressed-drill work will share them with
`DrillIntoCell` rather than freezing a second record order (gotcha 18).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 19:22:14 -07:00
Edward Langley a93d668e5e feat(browser): paint the page bar and the totals strip, and delegate the pointer
improvise-p7lh: `GridCache::page_coords` and `GridCache::totals` were projected
to every session and folded correctly by every client, and NO front end read
either. The shipped front page — `web/demo.improv` is `Quarter: page, Q1` —
drew a grid filtered to Q1 with nothing saying so, and `[`/`]` resolve locally
off that same `page_coords`, so a user could page through quarters watching
every number change with no way to tell which one they were looking at.

`RenderModel` gains `page_coords` and `totals`, fed straight off `state.grid`.
The Page/Filter split is asked of `PageAxis::cycle` rather than re-derived from
`items.is_some()`, which disagrees with it on an empty item list (gotcha 27).
The totals are RELAYED, never summed in the client: the sum runs over every row
including ones a viewport scrolled past (gotcha 44).

`web/tests/render_surface.test.js` gains the direction that would have caught
this when improvise-n10 added the fields. The existing guard is closed under
what `RenderModel` chose to expose, so a cache field that never reaches it is
invisible to it; the new one asks that every `pub` field of every widget cache
is read somewhere the browser runs, or is named as debt with a reason.
`grid.col_widths` is the one entry, pointing at improvise-pcje.

improvise-b8ot: the five clickable surfaces moved from one listener per node,
reallocated inside the render loops on every repaint, to one handler per stable
delegation root — the user's ruling. The operands are carried on the node as
data attributes and read back by a pure `cellAction`/`indexAction` in
`web/shell.js`, so the handler is a LOOKUP rather than a second derivation of
which data row was clicked: `gridPlan` counts data rows because a group header
is not one, and walking the table back up would disagree the moment one appears.
The editor's own keydown/blur stay direct — one transient node, one at a time,
and `blur` does not bubble.

All four documented behaviours a naive delegation loses are preserved and each
is now asserted in `harness/tests/contract.spec.js`: mousedown rather than click
(asserted by dispatching mousedown ALONE), `preventDefault` and only over a cell,
the conditionally skipped repaint (asserted by the editor's survival), and
closing a live editor before composing the click.

Gates: 1598 passed / 0 failed / 7 ignored over 37 binaries (baseline 1595);
node --test 47 over 6 files (baseline 36 over 5); Playwright 47 passed /
4 skipped (baseline 43 / 4); clippy, fmt and --check-evidence clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 19:21:57 -07:00
Edward Langley 544cbd79f3 test(command): the registration guard reaches prefixes and F-keys (improvise-piyp)
`every_bound_command_name_is_registered` had two blind spots, both found by
injection rather than by reading, and both inside the region improvise-636d
works in.

Its walk matched `Some(Binding::Prefix(_)) | None => {}`, so it never
descended into a prefix sub-keymap: everything under `g`, `y` and `Z` was
unchecked in every mode, and twice over for RecordsNormal, which reaches
Normal's three through `Keymaps::prefix_keymap`. And its key space was a
second copy of `tests_support::probe_keys_pub`, identical but for the two
F-keys the public one carries, so a name bound on any function key was
invisible here while `dump_every_binding` probed it.

Two unregistered names -- one under `g`, one on `F(5)` -- left the workspace
byte-identical at 1595/0/7. The positive control separating "untested path"
from "broken test" is the same bogus name on `Char('~')`, which fails.

The walk is now `reachable_names`, recursing into `Binding::Prefix` and
terminating because `prefix_keymaps_are_flat` says the nesting is one deep.
`probe_keys` DELEGATES to `probe_keys_pub` rather than restating it, which is
the part that matters: two lists of "the keys a test probes" is the same
disease as two keymap spines, and the F-key gap was the drift between them.
`probe_keys_pub` now covers F(1)..=F(12) rather than the two that happen to be
bound, since the point is to see a binding nobody expected.

Acceptance is the two injections failing SEPARATELY -- a fix for one need not
close the other -- and both do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 19:21:26 -07:00
Edward Langley 7a4461667e fix(panel): five category-panel and axis defects, four of them P1
improvise-koh6 (P1, data loss): `N` is advertised on the browser's own help
overlay and resolved `Upstream`, which is a black hole -- the DOM was
byte-identical and the user's next keystrokes landed in NORMAL, where `i`
opens an editor pre-filled with the cell under the cursor. Measured: 18,400
became the text 18400on. `search-or-category-add` is now answered in
`composed` in BOTH arms -- the composable one builds the two commands the
browser already has, the other declines out loud. So the host-needed list
stays a property of the NAME, which is what
`enter_in_an_empty_view_panel_resolves_locally_rather_than_upstream` forbids
making state-dependent; the OUTCOME varying with state is what
`cycle-axis-at-cursor` already does two arms above.

improvise-g3no (P1): `Model::remove_item` did not drop the formulas targeting
the item, so a `_Measure` row synthesised from a formula (gotcha 7) was
re-synthesised the moment it was deleted and both front ends reported
success. `remove_category` has always swept its formulas; the item level now
does too, which is the missing half of that symmetry rather than a new rule.
The optimistic status both front ends compose is now true, so no ordering
change is needed.

improvise-uej8 (P1): deleting a category's last item left the cursor on the
NEXT category's header, one `d` from a `LayoutGeometry` change that rewrites
every view's axis map. Both front ends now retreat one row when the row
below is not a sibling item -- never unconditionally, or holding `d` would
walk backwards. The formula and view panels do not have this shape: their
lists are homogeneous, so a cursor that does not move cannot escalate.
Also guards the three required virtual categories at the model boundary,
which is where `d` at the panel's default cursor 0 was deleting `_Dim`.

improvise-z336 (P2): `View::set_axis` never touched `page_selections`, so
cycling a category off Page merely HID its filter and cycling back silently
reinstated it. It now forgets the selection unless the new axis still
constrains -- `Axis::filters_data()`, Page OR Filter, because gotcha 27's
trap is answering this with `categories_on(Page)` and dropping the
selection a drill put on `Filter`.

improvise-25bf (P3): `cycle-axis-at-cursor` told the user nothing where the
tile route said "Product -> None". Both routes now go through one home,
`viewmodel::axis_change_status`. This also answers z336's secondary defect:
the stale "Filter: X = Y" persisted precisely because the key that moved the
axis wrote no status over it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 19:21:17 -07:00
Edward Langley 11320f771a feat(protocol): one global keymap every mode inherits from (improvise-636d)
`?` resolved in 2 of 17 modes: bound once on Normal and reached by
RecordsNormal through the single inheritance edge. It was missing from all
three panels, TileSelect, SearchMode, ImportWizard and Help itself.
Meanwhile `bind_panel_toggles` had two call sites and `:` was written into
two spines while `?` was in one — nothing kept the spines in agreement.

`global_keymap()` binds `?`, `:` and the three panel toggles, and all
seventeen modes are built `Keymap::with_parent(global)`. The answer to "which
modes must not inherit" is **none**: `lookup_local` is exhausted before the
parent, so the eight text-entry modes and SearchMode shadow every global key
with their own `AnyChar`, and ImportWizard with `Any`. Only Help writes an
override, and it is a decision — `?` closes the overlay.

The global binds Char keys only, because `AnyChar` is what does the
shadowing; F1 therefore stays on Normal although it runs the same command.
`TextEntry::with_parent` is folded into `new`, which now takes the parent
every text-entry mode has.

Four guards: the chain ends at one global (Arc::ptr_eq, not by value), the
global is Char-only and prefix-free, no mode binds a toggle of its own, and
every global key resolves in every mode and types where it must. Each was
proved by injecting its fault; evidence in context/test-evidence/protocol.md
records the two cases where a neighbouring test stayed green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 17:48:36 -07:00
Ed Langley 523d57197c Merge pull request #111 from fiddlerwoaroof/wave/o-gp-fit
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
fit: a poisoned affine map is unrepresentable (pv3g, P1); a budget declines a column (e8no)
2026-09-21 17:16:26 -07:00
Edward Langley eebf25c262 fix(gp): the CLI refuses an overflowing target instead of aborting (Codex P1)
ChatGPT Codex on PR #111, correct. improvise-pv3g's claim is that a finite
input no longer aborts the process, and the two LIBRARY paths honoured the
refusal while examples/fit_csv.rs turned it straight back into a panic with
`expect`. A user with a 1e20 column still got a panic -- at a different site
than before, but still a panic, from the entrypoint a person actually runs.

Demonstrated first: a temporary test drove `run_fit` over an 8-row CSV at
1e20/4e20 and aborted at fit_csv.rs:399. `run_fit` now returns
`Result<(), String>` and `main` prints the refusal and exits 1. Gotcha 37(a)
read the right way round: the library returns data, the example owns every
message, and that includes the refusals. `fit_and_report` is the split that
makes it testable without a temp file, and the example carries `test = true`
so its tests run under `cargo test --workspace`.

A SECOND abort on the same path, found because the first "just under the
overflow" fixture used 1e18 and blew up somewhere else: at that scale the map
is finite, the fit lands, and `report_importance` dies with a capacity overflow
in `"#".repeat((share / 5.0).round() as usize)`. The share is `inf` because
`importance_ranking` computes `100.0 * imp[j] / total` -- multiplication first,
so an importance over f32::MAX/100 overflows before the division that would
have returned 100.0 (measured: imp=[3.7499995e36], total the same, share=inf).

The bar width is clamped and the arithmetic is filed as improvise-k844 rather
than fixed here: reordering it moves the bit patterns
`misclass_rate_and_importance_are_bit_for_bit_what_they_were` pins, and this
PR's headline evidence is that none moved. AGENTS.md's own rule -- a finding
can be correct and still be wrong to fix in that PR.

Three guards, each failing under its own injection: restoring the panic,
refusing every map, and removing the clamp.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 16:50:09 -07:00
Ed Langley 05162fa231 Merge pull request #110 from fiddlerwoaroof/wave/n-browser-boot
browser: a boot failure is a state with a reason, not silence (improvise-1wft + 10)
2026-09-21 16:32:29 -07:00
Edward Langley 5d51287511 Merge remote-tracking branch 'gh/main' into wave/o-gp-fit 2026-09-21 16:31:02 -07:00
Edward Langley 509e44485b Merge remote-tracking branch 'gh/main' into wave/n-browser-boot 2026-09-21 16:24:58 -07:00
Ed Langley 1e911622d1 Merge pull request #109 from fiddlerwoaroof/wave/m-adr001
ADR-001 clause 4: a frame of mutations applies whole or not at all
2026-09-21 16:24:17 -07:00
Ed Langley 7b8d7375af Merge pull request #108 from fiddlerwoaroof/wave/j-persistence
wave/j: .improv persistence — eight beads, and the format changed
2026-09-21 16:23:57 -07:00
Ed Langley c91aee154e Merge pull request #107 from fiddlerwoaroof/wave/i-import
Wave I: five silent import defects (jc95, 5hfh, l1f5, jcfs, 2cw9)
2026-09-21 16:23:46 -07:00
Edward Langley d9b93d5c32 Merge remote-tracking branch 'gh/main' into wave/n-browser-boot 2026-09-21 16:05:14 -07:00
Edward Langley 20f04c831c docs(gp): FitOutcome::warnings now leads with the budget's notices
The field doc named two sources and an order that the improvise-e8no splice
changed. Third review finding: a doc comment stating an order is a claim, and
this one was one commit stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 16:04:30 -07:00
Edward Langley dc2fe21911 review(gp): correct an unchecked cost claim and close a vacuity hole
Self-review round on wave/o-gp-fit, three findings.

1. The e8no evidence claimed the dropped-column fit "lands on the measured
   pre-fix champion again". Printed it: the threshold and the high leaf are the
   probe's, the LOW LEAF IS NOT (1090.28 against 970.3), because pre-zv8t the
   column was present as one inferred numeric feature and the draw sequence
   differed. Exactly the unchecked-cost-claim shape the same file was written
   to record. The claim that survives is the internal one, which the test
   asserts: ignoring the column and marking it a Label give the same champion.

2. `a_target_just_under_the_overflow_still_fits` looped over `leaf_numbers`
   without asserting the walk found any, so an emitter change that stopped
   reaching the leaves would have made it pass vacuously.

3. `unaffordable_categories` drops by NAME while budgeting by INDEX, which is
   `doctrine(forcing_by_name_while_budgeting_by_index_defeats_the_budget)`. It
   is safe by the same DATA DEPENDENCY that doctrine's own repair used --
   `overrides` does not exist until `as_categories` returned Ok -- and gp.md now
   says that rather than "a `?` puts it there". Also recorded: an over-count in
   the advisory preflight used to cost a needless refusal and now costs a
   needlessly dropped column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 16:04:09 -07:00
Ed Langley 8d682bec1c Merge pull request #106 from fiddlerwoaroof/wave/l-prune-layout
Wave L: prune_empty drops collapsed headers and GroupEnds; column width overflows before clamping
2026-09-21 16:03:46 -07:00
Ed Langley 294c223c36 Merge pull request #105 from fiddlerwoaroof/wave/k-core-formula
fix(core,formula): seven bug-hunt beads in the model, the evaluators and the formula walker
2026-09-21 16:03:35 -07:00
Edward Langley f616487af4 fix(gp): a poisoned affine map is unrepresentable (pv3g); a budget declines a column (e8no)
improvise-pv3g (P1). `fit-measure` panicked in `number_text` with a NaN leaf on
an ordinary document whose target values were large but finite: 8 records at
1e20/4e20. Every input was finite and `fit_measure` checked exactly that; the
overflow was derived. `standardize` sums (t - mean)^2 in f32, 8 * (1.5e20)^2
exceeds f32::MAX, `std` came back inf, every normalized target became +/-0, and
`denormalize` computed 0.0 * inf + mean = NaN two crates away from the
arithmetic that caused it.

`Scaling` has private fields and one constructor that refuses a non-finite part,
so `standardize` returns `Option<(rows, Scaling)>` and `denormalize` cannot be
handed a poisoned map at all. `cv_regress` pools nothing for a fold it cannot
standardize -- the silent half, which produced R2 numbers with no panic to
announce them. The arithmetic is untouched: every bit pattern in
`scoring_known_answers` is unchanged.

The ticket's sibling path is corrected in `context/code/gp.md` SS7: a finite
target with an infinite mean gives +inf variance, never NaN, so the
`NaN.max(1e-6)` laundering it describes needs an infinite input the existing
guard already refuses. Measured by injection.

improvise-e8no (P3). The width preflight refused the whole table when one
category was too wide, so a document that fitted before zv8t stopped fitting.
`encoded_width` became `encoded_widths` and `unaffordable_categories` drops
category columns widest-first into `Overrides::ignore` until the rest fits,
reporting each as a `Dataset::warnings` notice -- data, never printed. The
encoding decision is untouched: a category is still never inferred numeric and
the forcing is still unconditional. `context/code/gp.md` SS11's "declined"
verdict is superseded there, with the reasoning under it kept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 16:01:41 -07:00
Edward Langley d4d1cd43f2 fix(adr-001): clause 4 reaches the host's session slice and the batch flags
Integration review of 455e8614 found clause 4 delivered for the document
and the mirrors and NOT for `App::view_state`.

F1. `restore_document` said `view_state` was "deliberately untouched",
citing improvise-yqpd's note that rolling back `status_msg` would undo
an AddCategory refusal's explanation along with the damage. **That note
is about an apply_effects-level rollback, which is not what landed.** At
the FRAME level the asker receives ServerFrame::Rejected(reason) — the
explanation is delivered on the wire, addressed to the client that earned
it — so the host's status line has no explanatory duty for a client's
frame. The host's own key path is untouched and still aborts without
undoing, exactly as gotcha 21 describes.

Measured consequences, all now closed: the TUI host's cursor still moved
and cursor_epoch was still bumped; a rolled-back LoadModel left
`view_state.active_view` naming a view of the DISCARDED document; and
`status_msg` kept the rejected frame's sentence, so a frame that did
nothing claimed it had loaded a file.

The fixture had to be renamed before any of that was visible.
`host_over_a_loadable_document` built its incoming workbook with
`Workbook::new`, whose default view is `Default` — the same name the
outgoing document uses — so a ghost active_view was indistinguishable
from a correct one. Gotcha 39's trap one level up. Checked first that
neither user of the fixture depends on the names matching, and that
gotcha 39's own same-name fixture lives in project.rs and is untouched.

Restoring the whole slice cannot clobber a TUI user's in-progress typing:
`Host::commands` is synchronous, so snapshot and restore bracket only the
frame's own writes.

F3. `batch_aborted` was left true after a rolled-back frame — inert, but
the same shape as the stale-flag bug the previous commit fixed. `App`'s
doc comment names four more "last batch" fields and calls
last_reshaped_views "the shape batch_aborted takes"; restore cleared
three of the four and snapshotted none. All six now round-trip, which is
why DocumentSnapshot is renamed AppSnapshot: it is no longer only the
document.

F2 is NOT closed and is stated as scope in the evidence and the PR body:
broken_chain asks records_named, so a frame can still apply partially and
silently through a command that names no record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 15:47:38 -07:00
Edward Langley 4e08a9b485 fix(formula): every Rule dispatch in the walker is exhaustive
Integration review, cross-branch against #108. My asymmetry argument was right
-- a &str dispatch can only have GRAMMAR_INVARIANT, a Rule dispatch can have a
compile error -- and I then applied it to the two &str sites and NOT to the
Rule site where the stronger technique was available. build_primary kept its
unlabelled `else`.

Demonstrated, not argued. With `pct_literal = { "#" ~ identifier }` first in
`primary`: the tree built clean, all 85 crate tests passed including my three
new 492x guards, and parse_formula("X = #Revenue") returned Ref("Revenue") --
the `#` discarded, the formula silently wrong. The injection must be
NON-ATOMIC: an atomic rule is caught by accident in first_inner's own
GRAMMAR_INVARIANT and would have reported the code safe.

Sweeping for the shape found three more in the same file, two genuinely silent:
build_formula's if-chain-in-a-loop dropped an unhandled part (its comment said
"Rule::EOI and any silent rules are ignored"; EOI is now an ARM, so ignoring is
a decision), filter_value_to_string returned unprocessed text, and build_unary
was caught only incidentally downstream. All four now match over Rule with an
or-pattern over the rules that cannot appear, so ANY new rule in formula.pest
is a build failure at all four until someone decides what it means. Verified by
injecting three more probe rules: every site names all three.

The greppable invariant: `== Rule::` now appears nowhere in parser.rs.

each_primary_alternative_builds_its_own_expr is the anti-clamp guard -- a
mechanical if/else-to-match conversion can transpose two arms and still
compile, since the compiler checks that every rule is NAMED, never that it is
named against the right body. Proved by swapping the string and pipe_quoted
arms: it fails and nothing else in the suite does.

Also corrects context/code/formula-and-io.md, which still called the final
`else` arms "unlabelled-but-safe" on the grounds that COUNT was the only
remaining agg_func and >= the only remaining cmp_op. Two of those three clauses
were made false by my own earlier commit. Each was true OF THE GRAMMAR AS IT
STOOD -- a snapshot silently re-read as an invariant, which is the whole defect
in one sentence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 15:47:09 -07:00
Edward Langley 40b0574b4c test(protocol,core): assert gd83's property where the type lives, and make the collapsed fixture prune
Integration review of #106. Three findings; two applied, one does not reproduce.

1. AxisFrame::group_of had NO coverage of its GroupEnd arm. Replacing it with
   `FrameEntry::GroupEnd => None` left all 133 improvise-protocol lib tests
   passing — only tests/grid_frame_agreement.rs in the root crate caught it,
   and that file does no pruning. So improvise-gd83's property reached the
   protocol by construction through project.rs and never by assertion: a
   derivation living in two crates with one of them unchecked, which is what
   gotcha 44 and design-principles §5 exist to prevent.

   Two tests in render.rs over the exact frames prune_axis_items now produces
   — the re-opened run closed by a GroupEnd, and the collapsed header with no
   item under it. The second also asserts collapsed: true travels, because a
   header arriving without the flag is there and inert, which is lhi4 again
   one layer out. The injection now fails both where it failed nothing.

   This is the landed half of u6gc's AC3. The other half — that
   prune_axis_items -> axis_frame -> group_of really composes to these frames
   — still needs project.rs and stays filed rather than landed.

2. a_collapsed_groups_header_survives_pruning passed with prune_empty disabled
   entirely: its fixture funded every item, so nothing was ever pruned and the
   name claimed more than the test could see. It now carries an unfunded Mar,
   so turning pruning off leaves DataItem(["Mar"]) in the asserted sequence and
   the test fails. Same vacuity class as the any() -> whole-sequence fix in
   4075d66.

3. NOT APPLIED — "no fault separates the collapsed test from the GroupEnd
   test" does not reproduce. Re-measured all three faults over all four tests:
   inverting close_pending_run's condition to `had_item` fails the collapsed
   test while the GroupEnd test PASSES, because a re-opened run is closed by
   the GroupEnd arm whatever the header condition says. The evidence now
   carries the full 3x4 matrix rather than prose, so the claim is checkable
   instead of assertable, and every column is distinct.

Also moves wide_cell_clamps_without_overflow's evidence from core.md to
protocol.md. improvise-yae4 touches viewport.rs and render.rs and does not
touch improvise-core at all, so core.md was the wrong home; it was put there
by hand and the checker cannot see a misfiled entry — it asks whether a
heading names a real test, not whether it sits in the right file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 15:45:12 -07:00
Edward Langley 836323cafa style: cargo fmt the harness crate, which --all does not reach
`harness/opfs-contract` carries its own [workspace] table, so it is not a
member of the root workspace -- deliberately, since it names
improvise_io::opfs and a member would break `cargo test --workspace`. The
consequence is that `cargo fmt --all -- --check` passes over it without
looking, so every gate I ran was honest and green while the file I had
edited was never visited.

It needs its own gate, as CI runs it:

    cd harness/opfs-contract
    cargo fmt -p opfs-contract -- --check
    cargo clippy --target wasm32-unknown-unknown --all-targets -- -D warnings

The gap is bounded: grepping for crates carrying their own [workspace]
table finds exactly this one and the root.

And a fmt failure is the FIRST step of that job, so the clippy behind it
never ran and nobody knew whether it was clean. It is -- verified locally
at this head, no warnings.

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