PR #65's review found this PR had planted a fresh instance of the defect it
exists to repair. Both blocking findings fixed, each demonstrated.
FINDING 1. "a peer that cannot be reached is named, and the watchdog stays out
of it" could not see the property its comment claimed. `shell.arm` runs only
after `await transport.ready`, which never resolves on a socket that never
opens — so the watchdog is never armed there and denying "sent no document"
denies an unreachable event. Reverting PR #21's routing left that clause
passing.
The aggravating half: `context/test-evidence/clients.md` already said so, and
this PR EDITED that paragraph — inserting "runFor(10s) advances the watchdog"
immediately above the sentence explaining it is never armed. Not text the PR
left alone; text the PR walked past mid-sentence.
The clause is gone and the test keeps the naming property it really guards: a
`close` on a socket that never opened must not read as "disconnected". And the
shape cannot return — `outlastTheWatchdog` now asks the page `timesArmed() > 0`
before jumping, so a watchdog denial re-added to that path FAILS BY NAME. It
asks *ever armed* rather than *armed now* on purpose: by the time the disconnect
test jumps, a correct page has already disarmed, which is the property, so the
obvious spelling would have denied the passing case. Building the guard is what
surfaced that distinction; reading had not.
FINDING 2. The spec kept its own `HANDSHAKE_TIMEOUT_MS` beside a comment saying
a silent disagreement "would pass for the wrong reason". It did: the spec at
1000 against the page's 8000 made the suite green over the reverted routing,
because the jump landed inside the window. The constant is now exported from
`web/shell.js` and imported by both, so the disagreement is unconstructible.
Shrinking it in its one home still catches the routing revert — that is the
demonstration the guard did not go quiet.
Filed, not fixed: improvise-78v6 (#61 also deleted the help-overlay spec and
nothing replaced it — a PR about a dropped guard that did not diff the dropping
PR's test names) and improvise-5ray (--check-evidence collects only `### ` and
resolves only Rust fns, so every browser entry's `####` is invisible; appending
one that names nothing exits 0, which is how the dangling heading cleared the
gate for a release).
Verified on the merged tree: node 26/26, bundle 3/3, Playwright 17 passed + 3
skipped, cargo test --workspace 1378 passed, comment-runs clean,
--check-evidence 501 resolving, all cited ids resolve, no .beads diff.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
The eleven deleted per-widget tests and the three contract tests replacing
them were compared at 3.3s against 3.4s, which reads as a near-tie. It is not
one: those were two separate `npx playwright test` invocations, each paying its
own cold start (~0.6s and ~1.9s), so the wall clocks measured startup as much
as tests and flattered the replacements. The per-test sums are 2.7s against
1.55s.
The conclusion is unchanged and has more margin: eleven tests at 2.7s cannot
buy a twenty-second saving, so the deletion of the eleven bought shape and the
two real-timer watchdog waits bought the speed. It also explains, without
needing anything else, why the contaminated baseline's clock barely moved —
1.55s of contract tests, warm inside a full suite, lands under a second against
a 36s total.
Same discipline as the row above it in the table, applied to the other side of
the comparison: two numbers are only a comparison when they were measured the
same way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third correction to one number, and this time by measurement rather than
arithmetic. `1af51c9` reached by a FULL checkout — which removes a tracked
file the target does not have, where `checkout -- <paths>` leaves it — then
built and run twice on a private port, and the same for this branch in the
same session:
before 23 chromium, 26 passed + 3 skipped, 36.2s / 36.3s
after 14 chromium, 17 passed + 3 skipped, 15.1s / 14.9s
Three things that changes.
The `before` row was internally inconsistent: it paired a corrected chromium
count (23) with the contaminated run count beside it (29), and 23 + 3 firefox
is 26. A row whose columns do not add up is a row built from two trees. The
note now says the columns must agree, because that identity is what caught
this every time it was caught at all.
`32.8s` is gone. It was not measured by anyone who could hand me its
provenance — I attributed it to the reviewer, who had only ever counted
`1af51c9` statically and never ran a full pre-PR suite — and it does not
reproduce here. Quoting an unattributable number inside the paragraph that
exists to explain an unattributable number was the wrong repair.
And the contamination's cost was the COUNT, not the clock: the contaminated
run read 29 against the clean tree's 26, but 36.9s against 36.2s, because the
three extra tests cost under a second between them. A wall clock absorbs a
contaminated tree without a trace; an arithmetic identity does not. That is
now the stated lesson, and the same error surviving three corrections — into
PR #61's body as "26 chromium", then through a fix that corrected one column
and left its neighbour — is the worked example of `clients.md`'s own rule that
a fact stated in more than one place is changed by a grep, not an edit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review of the fix found the speed table quotes one number where there are two:
every spec but `opfs.spec.js` is Chromium-only, so a count read off a run is
three larger than the count of tests in the files, with three more skipped. The
table now has a chromium column and an all-projects column, and says so — the
same "two numbers, not one" the Rust suite learned in design-principles §7.
Chasing that ambiguity turned up a worse error in the number itself. The
`before` figure was measured with `git checkout 1af51c9 -- harness web` inside
the working branch, which restores TRACKED files and leaves untracked ones
where they are — so `contract.spec.js` stayed behind and the "baseline" ran
main's 23 tests plus three of the branch's own. It read 29/36.9s, which looked
entirely plausible. The honest figure, measured on a clean tree, is 23/32.8s.
A baseline taken by checking paths out over your own work measures a tree that
never existed, and that is now written down where the number is.
Also records the probe for the open question the faked clock left. A peer that
opens, stays silent past the watchdog window and is closed afterwards produces
a byte-identical `#status` transcript under `page.clock` and under a real
nine-second wait — four states, same order, same text. So `runFor` does not
flatten that ordering. What stays uncovered is an event landing strictly INSIDE
the collapsed window, which a synchronous `runFor` cannot deliver and which is
a race the page promises nothing about. Stated rather than left to be
re-derived; the probe itself is not kept, being a characterization of
Playwright's clock rather than of this repo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adversarial review of PR #61 found one real coverage loss, and it is the
defect class that PR's own body names: an assertion that survives the deletion
of the thing it was about. `shell.spec.js` lost "a socket that opened and then
dropped reads as disconnected, and stays that way" and both MutationObserver
negative assertions. Reverting PR #21's routing half in `web/app.js` — the
socket `error` and `close` arms calling `setShell` instead of `fail` — left the
node suite 26/26 and Playwright 13/13 green.
`shell.test.js` cannot cover it, and why is the whole boundary argument:
"a loud failure DISARMS the watchdog" tests `createShell` in isolation and
cannot see whether `app.js` ROUTES the close event through `fail()`. That
routing is the half gotcha 48 says keeps getting lost; this was the third time,
after two earlier mis-confirmations that simulated the `opened` flag and not
the routing.
Both tests are back under their original names, so the two `####` headings in
context/test-evidence/clients.md name live tests again. They cost ~75ms each
rather than ~9s, because `page.clock` fakes the PAGE's timers: the page, the
socket, the listeners and the `setTimeout` call are all real and only the
passage of time is simulated. That is not a weakening, and the injections are
the proof in both directions — if `runFor` did not advance the page's timers, a
broken routing could not surface the watchdog's message and they would pass:
- both arms reverted to `setShell` -> "…and stays that way" fails
- close arm only -> "…and stays that way" fails
- close arm drops the `opened` flag -> "…the watchdog stays out of it" fails
Three injections, three outcomes, none of them the unit test.
The helper guards its own premise, because it turned out not to: `runFor`
without `clock.install()` does nothing AND throws nothing, so a test that
forgot the install asserted "the watchdog never spoke" against a watchdog that
never ran — 6/6 green. `outlastTheWatchdog` now requires the page's own
`Date.now()` to have moved, and that omission fails by name.
Also: `render_surface.test.js` matched `model.<field>` over source INCLUDING
comments, so un-painting a field and mentioning it in a comment passed —
demonstrated against the pre-fix version, then fixed and re-demonstrated.
And the PR's speed claim is corrected where it is documented. The eleven
deleted per-widget tests cost 3.3s between them against 3.4s for the three
contract tests replacing them: they were free. Essentially all of 36.9s -> 15.5s
was two real-timer waits, one of them this guard. The wait was the cost, not
the test.
Filed, not fixed: improvise-igxp (the help overlay's rows are asserted as
present, never as content — inherited verbatim from the test contract.spec.js
replaced) and improvise-4ktt (the js job's bundle-freedom is a shell-glob
convention, not a gate). The `####`-vs-`###` blind spot in --check-evidence,
which is why the dangling heading survived the gate, is a comment on
improvise-r0qa.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two conflicts. .beads/issues.jsonl restored to main's export so the branch
carries no beads diff. context/code/command.md: main rewrote the
Sessions::apply paragraph (now #[cfg(test)], improvise-s17o), which
supersedes this branch's 'verified at this commit' note; main's text is
kept, minus its closing pointer telling the reader to read
design-principles.md §5's 'Sessions::apply' as 'apply_checked', because
this branch corrects §5 itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only .beads/issues.jsonl conflicted; restored to main's export so the
branch carries no beads diff. kb.pl auto-merged cleanly: doctrine/decision/
invariant counts 56/48/51 before and after.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only .beads/issues.jsonl conflicted; restored to main's export so the
branch carries no beads diff.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pre-commit hook that re-exported and staged .beads/issues.jsonl was removed
(adfc765 turned off git-add; the hook itself is gone), which was the right call:
the file used to ride along on every branch that did any bd work and conflicted
with every other branch that did the same — it left the index unpickable during
the PR #50 merge.
The consequence is that the export is now refreshed deliberately rather than
continuously, exactly like context/repo-map.md, and it had drifted: 314 records
committed against 338 in Dolt. Twenty-four beads filed since the hook went are
absent, including every one from today's web-UI and review rounds.
That staleness is load-bearing rather than cosmetic. The documentation site in
PR #62 links a ticket id only when it appears in this export, and renders it as
plain text otherwise — so every missing id is an unlinked citation on the public
site. The site is built to degrade rather than to assume, which is right, but
the degradation should reflect reality and not a stopped clock.
Dolt remains the source of truth; this file is a passive export of it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured on run 35535225637 (push to main, caches warm). The critical path
was the browser job at 263s plus an 11s deploy; everything else finished
inside it. Its decomposition: harness lint 37s, Playwright browser download
50s, `./harness/build.sh` 110s, `npx playwright test` 50s.
Three wasm-pack builds ran in that 110s, and two of them — the client and
the worker bundle — were the same two `pages-build` ran in its own 68s step,
in a parallel job with a separate rust-cache key (`add-job-id-key` defaults
on). Same work, twice per run.
- One `build` job now runs `./harness/build.sh` (all three bundles plus the
`harness/serve` tree) and then `./web/assemble.sh site $GITHUB_SHA` from
the pkg dirs it just produced. It uploads `harness/serve`, the two raw
bundles, and the Pages artifact. `pages-build` is folded into it.
- `browser` downloads `harness/serve` whole and needs no Rust toolchain and
no wasm-pack. Downloading the tree rather than reassembling it keeps one
answer to "what does the harness serve" (improvise-vvl3): `harness/build.sh`.
Verified locally that the two assemblies differ in exactly the four
cache-bust-stamped files and nothing else — the .wasm bytes are identical.
- `harness-lint` is its own job. It gated nothing the harness needs and cost
37s of the critical path, and `cargo fmt --all` run from inside
`harness/opfs-contract` formats the WHOLE outer workspace (114 files,
measured) despite that crate's empty `[workspace]` table, so a rustfmt
violation anywhere in the repo failed the *Browser harness* job.
`-p opfs-contract` scopes it to the one file it is about.
- Playwright's browser download is cached on the resolved package version.
- rust-cache in `build` and `harness-lint` covers `harness/opfs-contract`'s
own target dir, which the default single-workspace config never did.
No gate is weakened. `pages-deploy` still needs every check that could
previously fail it; `harness-lint` is named explicitly because it used to be
a step inside `browser`. `kb`, `wasm` and `test` gain no dependency on
`build` — none of them consumes an artifact.
On improvise-864a: the gap between the Pages artifact upload and the deploy
narrows from ~182s to ~115s on this run's shape, so the window in which a
superseding run can expire it is smaller. The race is not closed, and not
widened.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
E2E and integration tests are rare and pin boundary contracts; unit tests
assume those contracts and test between them (user, 2026-09-20). The browser
half has four boundaries — RenderModel→DOM, JS→wasm, page→peer, worker→OPFS —
and everything between them needed no browser at all.
`web/shell.js` and `web/serial.js` are the seams: pure modules that RETURN A
DESCRIPTION rather than take a DOM double. `gridPlan(model)` answers every
question `gridTable` used to answer while creating elements — the data-row
index across group headers, what a group header spans, a null group label
against an empty one, an empty edit buffer against no editor — and `app.js` is
now a loop that builds elements from it. Painting stays untested by Node; what
to paint stopped being untestable.
26 tests in `node --test web/tests` (0.12s, no Rust toolchain, no wasm bundle,
no npm install), plus three in `web/tests/bundle/` that read the generated
.d.ts and so run in the browser job. Playwright goes from 29 tests/36.9s to
16/15.5s on one machine: `widgets.spec.js` and `pointer.spec.js` are gone,
replaced by `contract.spec.js`, which asserts that EVERY field the render model
projects reaches the screen. The four widgets those specs were written for were
one failure at one boundary, and one assertion catches the fifth.
It caught it: `doc_name`/`doc_dirty`/`doc_stored_as` reach no element
(improvise-5jna, already open). They are named in `UNPAINTED` with the ticket,
so the list is the debt written down rather than an exemption.
Thirteen faults injected into the Node suite and eight into the served page,
each watched to fail the test it names; the last of those was strengthened
because the first version passed. `web/assemble.sh` grew an import-completeness
guard whose own first version was blind to the multi-line import it exists for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
User doctrine, 2026-09-20: 'E2E and integration tests should be rare and focus
on boundary contracts. Unit tests should assume the boundary contracts tested
by E2E and integration tests and test between the boundaries.'
Recorded with the worked example that makes it actionable rather than
aspirational. Four browser widgets had never worked and all four were ONE
boundary failure — a RenderModel field with no element painting it. Per-widget
specs cost four slow tests and would still miss the fifth; one contract
assertion that every exposed field is painted catches all four and the next.
opfs.spec.js is the shape already in the tree: it runs the storage contract
rather than a feature.
The corollary that is easy to miss: a unit test is cheap precisely BECAUSE it
assumes the contract, which means deleting a contract test silently invalidates
every unit test leaning on it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fourth widget reported, and the one with a different answer: there is no
wire vocabulary for drill. reduce.rs contains no Drill variant at all, neither
Command nor ModelCommand. That is improvise-gd28, and it is a decision rather
than an oversight — drilling writes drill_state, which gotchas 30 and 1 rule
session-local, and a Command::Model is routed with no SessionId. Adding
ModelCommand::Drill would reintroduce exactly what improvise-3x3u removed.
Everything below the command works, verified in the browser: CellId::Aggregate
is emitted and received, editable answers false for it, and edit_or_drill
reaches the aggregate arm. The TUI is the control and passes 12 drill tests, so
this is isolated to the client/wire.
The finding gd28 did not record is that the two entry points fail DIFFERENTLY.
`i`/`a` declines out loud with a status. `>` — the key the hint line actually
advertises as ">:drill" — is a name resolve::step does not handle, so it goes
KeyOutcome::Upstream -> ClientFrame::Key -> Host::handle's empty vec: a black
hole, not a slow path (gotcha 47). Silent. That asymmetry is why this reads as
broken rather than refused.
These tests document what is true rather than leaving folklore. They MAKE an
aggregate through the tile bar (web/demo.improv has none by construction —
gotcha 46) and assert the precondition, so they cannot pass against a grid of
Records. Both proved by injection: silencing the aggregate arm fails both, the
second on its check_the_checker control; making `>` resolve fails only the
second.
Evidence added to improvise-gd28 rather than filing a duplicate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review finding on this PR, and it is the commit's own lesson applied to the
commit. The replacement invariant ended with "what is genuinely unrun:
Safari/WebKit and any non-Chromium engine for the shell spec" — a CLOSED list,
and an incomplete one.
Verified: `WorkerServer::documents()` and `WorkerServer::refresh()` are
`#[wasm_bindgen]` exports with no caller in `web/*.js` or `harness/`, so they
are run by nothing. (The ten `.refresh(` hits under `harness/opfs-contract` are
`OpfsFs::refresh`, a different method — I checked rather than counting greps.)
So the old invariant understated coverage and my replacement overstated it. The
direction flipped; the defect did not. The claim is now scoped BY ENGINE and
says why: a coverage claim in prose is unfalsifiable by anything in this repo —
the KB-consistency job checks the KB is consistent, not that it is true — so
state what IS covered and scope what is not, rather than enumerating an absence.
kb_consistent exits 0; 55 doctrines / 47 decisions / 51 invariants, unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Repair-forward for the accidental push at 959f249, which put the PRE-review
version of the browser-widget work onto main. The three defects review found
are all in the version main currently carries; this is the delta that
supersedes it.
1. `categoryList` did not call `indicator()`. It inlined U+25B8/U+25BE, the
SMALL triangles, where `indicator()` — the page's declared ONE spelling —
the page's own grid group headers, and src/ui/category_panel.rs all draw
U+25B6/U+25BC. The browser's category panel disagreed with the TUI's and
with its own grid, inside the file whose comment says "only this spelling
is the page's".
2. Two specs pinned the divergent pair, so the obvious cure would have failed
tests that shipped with the feature. widgets.spec.js AND pointer.spec.js —
the latter written on a different branch, which had grown the same wrong
assertions independently. Three files converging on a spelling nothing
compares is design-principles §5's predicted failure, observed. Proved
discriminating by running the corrected specs against the pre-fix bundle,
where they fail with `Received string: "▸ Product (4) R"`.
The pointer.spec.js line is in this PR and not a later one because main
already carries that file: without it this change leaves CI red. It is one
assertion corrected by the same rule as its sibling, not feature work.
3. `el.tiles.hidden` was a no-op, removed with its false comment. `#tiles`
declares `display: flex`, an author-origin rule that beats the UA sheet's
`[hidden] { display: none }`, so the guard never hid anything. Worth
knowing before restoring it: `#tiles` being ALWAYS laid out is why its
height never changes and it stays out of the ResizeObserver feedback path.
Also `playwright.config.js` takes IMPROVISE_HARNESS_PORT (default 8099, so CI
is unchanged). The hard-coded port plus `reuseExistingServer` let Playwright
silently ADOPT a server left by another worktree and test THAT tree. It fired
in both directions in one session: a false negative (a fix reported absent)
and — the dangerous half — a false POSITIVE, 5 passed against a tree verified
to contain no #tiles at all. A failure sends you looking; a false pass prompts
nobody.
The evidence prose records all of it, plus the ResizeObserver acyclicity proof
that retires "argued, not tested" there: frameCost.cols is row header LEVELS
(independent of projected rows) and frameCost.rows derives from the column
window, so cols -> rows and nothing depends on rows. No cycle to oscillate.
Gates: 1359 Rust tests, rustfmt, comment-run and evidence linters, 21/21
Chromium harness (on a private port, so not another checkout's tree).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The user's rulings on the two beads PR #55 filed.
improvise-7ybz: the browser was keyboard-only by omission. Nothing outside an
editable grid cell had a click listener, so clicking the collapse triangle on a
category did nothing — which is what "I can't expand categories" meant, since
the keyboard path worked all along.
`improvise_protocol::resolve::click` now answers all three widgets under one
rule: **go there, then do what Enter does there.** A category row focuses the
panel, moves its cursor and activates (expand on a header, filter on an item);
a tile selects and cycles its axis; a cell moves the selection, with the page's
inline editor as the pointer's `i`. One rule rather than three inventions, and
it is the keyboard's own definition rather than a second one — `cat_tree_entry`,
`toggle_cat_expand`, `filter_to_item` and `tile_axis` each gained an `_at` twin
and the cursor-reading form is now a one-line call of it.
It lives in the protocol crate, not the wasm client, for the `flatten_cat_tree`
reason: a third front end must call this, not derive it. `Client::click_category`
/ `click_tile` / `click_cell` are thin wasm-bindgen wrappers that fold and
serialize exactly as `key` does, so `app.js` still composes no `Command`.
The subtlety worth knowing: a click's three intents are resolved in one pure
call, so the activation must be asked about the row *clicked*, never about
`cat_panel_cursor` — the `SetPanelCursor` ahead of it has not been folded yet.
Injecting that exact fault turns `ToggleCatExpand("Product")` into
`ToggleCatExpand("Region")`, which is the silent half and is now pinned.
improvise-wsaw: `RenderModel::category_panel_open` existed from the start and
`app.js` never read it, so the sidebar was drawn unconditionally and `C` moved
only the mode label. It now hides on load and toggles with `C`, matching
`src/draw.rs:322` and the two panels PR #55 added. A first-time visitor sees
one fewer thing; the user chose front-end consistency, and the grid is still
populated.
Tests: harness/tests/pointer.spec.js, six assertions, **all six proved by
running them against the bundle built immediately before this commit** — where
the sidebar is wrongly visible and every click lands on an element with no
listener. Plus four Rust tests on the resolver, one of them proved by the
injection above.
Note this branch is based on gh/main but MERGES fix/web-ui-correctness (PR #55):
click_tile needs a tile bar to click, and that is #55's. Rebase or re-merge once
#55 lands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The review re-ran fold_bench across two shapes at the SAME 18,050 cells: the
old code cost 51 ms at 25x361 and 244 ms at 95x95, and after the fix the two
are indistinguishable. A 4.8x spread from group count alone is what a
per-member scan predicts and a constant factor does not, so the spread
vanishing is better evidence for the diagnosis than the absolute number.
Also records the depth asymmetry between the two Agg arms, which bounds a
chain of folded formulas and must not be tidied into one rule.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both are the reviewers' framings rather than mine, and both are better arguments
than the ones I gave.
AGENTS.md: under merge-as-soon-as-green a review round can finish AFTER its own
merge, and GitHub has deleted the branch by then — so a fix push to the old name
silently succeeds and accomplishes nothing. It re-creates a branch main will
never see, CI runs on it and passes, and the commit is nowhere. The tell is in
git's own output: `* [new branch]` where an `old..new` range was expected.
Happened on #51, four minutes after the merge.
design-principles §6: an argument must not live only in a commit message. I had
justified the context/ rule as "context/ is what readers grep", which is weak.
The real reason is that a commit message is write-once and unauditable — it
cannot be corrected without rewriting merged history, and NO check in this repo
can read one, because the ID audit greps source and docs and a message is in
neither. A reason recorded only there is a reason nothing can ever find wrong.
PR #51's 7d25762 is the worked example: a declined-test reason that was
factually wrong, cited across two rounds, and correctable only by #56 saying so
in context/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third-round review finding, and I verified it against the code before accepting
it. Commit 7d25762's message declined that test on the grounds that a formula
targeting a regular category should project Derived anyway, so the injected
behaviour "is not clearly a fault". That is a different key entirely.
RecordCol::Category(name) carries a CATEGORY name, and RecordRow::cell_key(arg)
puts its argument under _Measure (layout.rs:68-70). So answering Some there
yields `coords + _Measure=<category name>`, and formula_for matches it whenever
a _Measure item happens to share a name with a regular category — projecting
Derived for a COORDINATE column, which is never a formula target and which the
pivot path does not have at all. The two front ends would disagree, which is the
§5 defect this ticket exists to close. RecordCol's own doc names the collision:
the enum resolves a column from its index "so a category and a measure sharing a
name stay distinguishable".
The decision not to write the test is unchanged, and the reviewer's
recommendation too. What changes is the argument: the REACHABLE version of that
fault is a new RecordCol variant answered carelessly, and the exhaustive match
makes that unrepresentable rather than unlikely. A test would pin what the type
system already pins.
Recorded in context/code/core.md because a wrong argument in context/ outlives
the code it describes, and that one would have told a future reader a coordinate
column projecting Derived is defensible. The commit message cannot be corrected
without rewriting history while a review is in flight, so the durable record
says so explicitly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review of #54 found the fold reintroducing exactly the divergence the ruling
exists to remove, and the same line turned out to be the 244 ms stall. One
defect, two symptoms.
`eval_expr_cached`'s `Expr::Ref` looks the value up in `formula_cache` at
`context.with(cat, name)`. Inside the fold `context` is a MEMBER-RECORD key
carrying the none_cat coordinates, while `formula_cache` is keyed by stems with
none_cats STRIPPED — so the lookup could never hit, fell through to
`aggregate_raw`, found no raw cell behind a formula target, and answered
Err("ref:…"). A routing formula over a derived measure gave Number(11.0)
uncached and Error("ref:Margin") cached, and the cached path is the one a pivot
grid runs whenever any category sits on Axis::None.
`eval_expr`/`eval_bool` are hoisted to module level and BOTH evaluators now hand
the fold the same record-level `eval_expr`. The two agree by construction rather
than by a test. Note the repair not taken: teaching the fold to hit the cache by
stripping none_cats off the member key returns the GROUP's value to every
member — aggregate-then-route through a cache lookup, and silent.
That same per-member `aggregate_raw` was one poorly-narrowed scan per member, an
O(n^2) shape. Measured with the new `examples/fold_bench.rs`, five runs a point,
at 18,050 cells: 51.1-53.3 ms before, 18.1-19.2 ms after, against 7.1-9.0 ms for
a linear formula. Scaling went from superlinear (45x cells, 85x time) to linear
(45x cells, 45x time). Debug 2.75 s -> 132.9-135.1 ms. The fixed point runs
exactly 2 passes, measured by counting, so hoisting the member set has a 2x
ceiling and is declined on improvise-9iyv — a memo keyed by less than the whole
`partial` is a second answer to what a cell depends on.
`Expr::Is` has an arm in each evaluator and the cached one was untested: the
earlier fault was injected into the shared `eval_is`, which both arms call.
Replacing the cached arm alone left the suite green. The first replacement test
ALSO stayed green, which is the interesting part — once the fold routes through
`eval_expr`, an `Is` inside a SUM never reaches the cached arm, so that arm is
reachable only by a bare `Is` outside an aggregate. That is what the new test
uses, and injecting into that arm alone now fails it.
`IS` gained a word boundary: `Region ISLAND` parsed as Is(Region, "LAND"),
silently wrong where it was a parse error. `is_expr` is compound-atomic with
`sp+` written out, because a negative lookahead in an ordinary rule tests the
character AFTER pest's auto-inserted whitespace and fails backwards. The
lookahead was then redundant and removed — deleting it left the suite green,
while relaxing `sp+` to `sp*` fails the test, so `sp+` is the mechanism. `WHERE`
has the same hole, is far less exposed, and is filed as improvise-r08u.
Also: `ambiguous.sort_unstable()` before `dedup`, which only removes consecutive
duplicates, so the refusal could name a column twice.
Every fault re-injected after the refactor: no-fold 10 failures, shared eval_is
5, record-level Is arm 4, cached Is arm 1, feature_roles order 2, context-vs-
partial 1, the new cached-fold divergence 1, `sp+` relaxed 1. Baseline 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first version of the tile test asserted that the tile read 'Product Col'
after 'c' — the bar reporting itself, which would pass against a bar that
displayed an axis the document had not taken. It now also asserts Espresso
moves from a tbody row header to a thead column header.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A user reported "I can't expand categories, and the Tile + View UIs don't
work". Driving the assembled site under Chromium separates three different
answers, and the useful finding is that two of the three were missing only
their last layer.
Category expand already worked, by keyboard: Shift-C, j, Enter folds
Command::ToggleCatExpand client-side and the items appear. What does not work
is the mouse, because categoryList builds plain <li>s with no listener — the
whole page's pointer model, filed as improvise-7ybz rather than guessed at.
The tile bar and the view panel had the cache, the keymap, the resolver and
the host round trip, and no DOM. Before this commit the browser held five
TileBarCache entries and both ViewListCache views; Shift-T entered TileSelect,
ArrowRight moved tile_cat_idx, and r/c/p really did move a category between
axes — with nothing on screen to aim them at. RenderModel had no field for
either and index.html had no element. The formula panel was the third of
Panel::ALL in the same state, and is fixed by the same mechanism rather than
left arbitrarily behind.
So RenderModel grows `tiles`, `views` and `formulas`. The two panels follow
the help overlay's rule — an empty list is what says a widget is closed, so
the page asks no question about the mode — and the tile bar is drawn always
with only its *selection* conditional, exactly as src/ui/tile_bar.rs makes it
conditional on AppMode::TileSelect.
The view panel's axis line would have been a second derivation, so
ViewContent::axis_summary moves to improvise_protocol::viewmodel as
view_axis_summary and the TUI's own panel now calls it: one home, both ends
(design-principles §5).
Tests: harness/tests/widgets.spec.js, proved by running it against the
unfixed bundle, where the four new assertions fail on zero elements and the
category one passes — the control that says the keyboard really is being
driven. Two draft assertions were wrong and both corrections are recorded:
Axis::short_label is three columns ("Quarter Pag"), and switching views
leaves the panel open as the TUI does.
Not fixed, filed: improvise-7ybz (no pointer affordance anywhere on the page),
improvise-wsaw (app.js has never read category_panel_open, so #categories is
drawn unconditionally and is now inconsistent with the two panels added here).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second review round. Six accepted-but-no-op commands still reset the cursor and
bumped the epoch; five are fixed here and the sixth is filed.
The one to remember is AddItem { _Measure, <a formula target> }. The guard read
category.items, but _Measure's effective items are explicit UNION formula targets
(gotcha 7), so it called "new" a column the grid was already drawing. The three
item effects now compare effective_item_names ACROSS the mutation rather than
predicting from one side of it, which is correct in both directions at once and
needs no separate rule for dedupe, for formula targets, or for an unknown
category. Prediction is what was wrong; observation is the fix.
Four smaller ones: SetAxis on a category the view does not register (set_axis
no-ops, but try_axis_of returns None, which compares unequal to Some(axis));
HideItem/ShowItem naming something the grid never drew (hide_item records any
string); and ToggleGroup on a group that does not exist, which never got a guard
at all because it merged CLEANLY through the #48 merge -- the clean-merge hazard
arriving in the one place nobody re-read.
TogglePruneEmpty on a grid with nothing empty is NOT fixed. Guarding it means
computing the pruned layout, which is building the frame improvise-ni6k proposes
fingerprinting. It is one keystroke in ordinary use, so it is filed loudly rather
than patched quietly.
The fixture could not reach any of these by construction -- no formula, no
category on Axis::None, no unknown-name case: the one-category-fixture shape the
doctrine names. It now carries a Profit formula, a Channel category on Axis::None
and a Ghost entry hidden under a name the model never had.
Two of the five injections initially failed to reproduce, and BOTH TIMES THE
INJECTION WAS WRONG RATHER THAN THE GUARD: ShowItem needed a hidden ghost name,
since is_hidden is false for a name nobody hid; and AddItem had to compute its
predicate BEFORE the add, because afterwards the item really is in category.items.
An injection that does not reproduce the original ordering tests nothing.
ToggleGroup's first guard was also wrong -- add_item_in_group sets item.group
without pushing to Category::groups -- and the existing reshape test caught it.
All nine earlier injections re-run and still caught.
Filed: improvise-nynk (adopt_cursor_epoch resets clients on views nothing
reshaped -- bisected to main/#48, but this branch takes it from 2 effects to 12),
and TogglePruneEmpty plus the Axis::None class onto improvise-ni6k.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bd's pre-commit hook re-exports and stages .beads/issues.jsonl on every commit,
so the file rides along on any branch that does bd work and then conflicts with
every other branch that did the same. Restored to main's content with
--no-verify so the hook cannot re-add it. Treated like context/repo-map.md from
here: nobody commits it on a feature branch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bd's pre-commit hook re-exports and stages .beads/issues.jsonl on every commit,
so the file rides along on any branch that does bd work and then conflicts with
every other branch that did the same. Restored to main's content with
--no-verify so the hook cannot re-add it. Treated like context/repo-map.md from
here: nobody commits it on a feature branch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>