Merge remote-tracking branch 'gh/main' into feat/doc-site

# Conflicts:
#	.github/workflows/ci.yml
This commit is contained in:
Edward Langley
2026-09-20 14:14:10 -07:00
11 changed files with 318 additions and 125 deletions
+191 -88
View File
@@ -13,6 +13,113 @@ env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
jobs: jobs:
# ---------------------------------------------------------------------------
# The one place wasm-pack runs (improvise CI dedup).
#
# Before this job there were two: `pages-build` ran `web/build.sh`, and the
# browser harness ran `harness/build.sh`, which CALLS `web/build.sh`. The two
# wasm-pack builds were therefore done twice per run, in parallel jobs, with
# separate rust-cache keys (`add-job-id-key` is on by default, so `browser`
# and `pages-build` never shared a target dir). Measured on run 35535225637
# (push to main, caches warm): 68s inside `pages-build`, and the same 68s
# inside the browser job's 110s build step.
#
# Everything downstream now DOWNLOADS. The invariant that buys is artifact
# identity: the harness and Pages are served bytes from one wasm-pack run,
# so "the harness runs against the tree Pages publishes" (improvise-vvl3) is
# true by construction rather than because two scripts agree.
#
# The native binary is deliberately NOT built here. Nothing downstream
# consumes one — `cargo test --workspace` in the `test` job builds its own,
# for its own target dir and its own cache key, and a `needs:` on this job
# would serialise a 109s job behind a 126s one for no artifact at all.
build:
name: Build all bundles
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
# Two workspaces, because `harness/opfs-contract` carries an empty
# `[workspace]` table and so has its own target dir, which the default
# single-workspace configuration never cached (it recompiled its whole
# dependency tree every run).
- uses: Swatinem/rust-cache@v2
with:
workspaces: |
. -> target
harness/opfs-contract -> target
# Pinned release binary rather than `cargo install` (a three-minute
# source build) or a third-party action. wasm-pack runs its own bundled
# `wasm-opt`, which is why improvise-wasm-client's Cargo.toml names
# `--enable-bulk-memory`: without it the optimizer rejects the
# `memory.copy` LLVM emits regardless.
# v0.15.0 is what `nix develop` supplies, and the bundle was built and
# measured against it locally (346 KB, 120 KB gzipped). Pinning the same
# version is what makes that local run evidence about this one.
- name: Install wasm-pack
env:
WASM_PACK_VERSION: v0.15.0
run: |
set -eu
url="https://github.com/rustwasm/wasm-pack/releases/download/${WASM_PACK_VERSION}/wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl.tar.gz"
curl -sSfL "$url" | tar -xz --strip-components=1 -C /usr/local/bin \
"wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl/wasm-pack"
wasm-pack --version
# Both assemblies below generate the prose site with node. The runner
# image ships one, but pinning it is what makes a local run evidence.
- uses: actions/setup-node@v4
with:
node-version: "24"
# The same script a developer runs locally, so there is one answer to
# "how is the bundle built" rather than two that can drift. It builds all
# three wasm bundles (client, worker, opfs-contract) and assembles
# `harness/serve` — the deployed arrangement plus /opfs/.
- name: Build the bundles and the harness tree
run: ./harness/build.sh
# Re-assembles from the pkg dirs the step above already produced — no
# wasm-pack runs here. A second assembly rather than a copy of
# `harness/serve` because the cache-bust token differs: the harness tree
# is stamped `harness` and the published site the commit SHA, which is
# what makes a returning visitor pick up a fresh bundle (improvise-9x6).
- name: Assemble the site
run: ./web/assemble.sh site "$GITHUB_SHA"
- run: find site -type f | sort
- run: du -sh site
# The tree the browser harness serves. Uploaded whole rather than
# reassembled downstream: a second assembly path is a second answer to
# "what does the harness serve", which is the thing improvise-vvl3 exists
# to prevent.
- name: Upload the harness tree
uses: actions/upload-artifact@v7
with:
name: harness-serve
path: harness/serve
retention-days: 1
include-hidden-files: true
# For a job that needs the raw wasm-pack output rather than the
# assembled site — the Node bundle suite under `web/tests/bundle/`.
- name: Upload the wasm bundles
uses: actions/upload-artifact@v7
with:
name: wasm-bundles
path: |
web/pkg
web/worker-pkg
retention-days: 1
include-hidden-files: true
- uses: actions/upload-pages-artifact@v5
with:
path: site
test: test:
name: Test and lint (host) name: Test and lint (host)
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -130,6 +237,37 @@ jobs:
nix shell nixpkgs#trealla --command tpl prolog-sessions/kb.pl \ nix shell nixpkgs#trealla --command tpl prolog-sessions/kb.pl \
-g "(kb_consistent -> halt(0) ; (kb_report, halt(1)))" -g "(kb_consistent -> halt(0) ; (kb_report, halt(1)))"
# ---------------------------------------------------------------------------
# Lints the one crate the `test` job cannot reach.
#
# `harness/opfs-contract` carries an empty `[workspace]` table, so it is its
# own root and `cargo clippy --workspace` at the repo root never sees it.
#
# Its own job rather than a step inside `browser`, for two reasons. It took
# 37s of the browser job's 263s on run 35535225637 while gating nothing the
# harness needs — a lint failure and a harness failure are different reports,
# and here the lint failure wore the harness's name. And `cargo fmt --all`
# from inside that directory formats the WHOLE outer workspace (114 files,
# measured) rather than the one crate, because cargo-fmt's `--all` walks past
# the nested workspace boundary — so a rustfmt violation anywhere in the repo
# failed the *Browser harness* job. `-p opfs-contract` scopes it to 1 file.
harness-lint:
name: Lint the harness crate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
with:
workspaces: harness/opfs-contract -> target
- working-directory: harness/opfs-contract
run: |
cargo fmt -p opfs-contract -- --check
cargo clippy --target wasm32-unknown-unknown --all-targets -- -D warnings
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# The browser harness (improvise-vvl3, absorbing improvise-xf1). # The browser harness (improvise-vvl3, absorbing improvise-xf1).
# #
@@ -139,55 +277,64 @@ jobs:
# by reading or by a person pressing keys, neither of which is repeatable. # by reading or by a person pressing keys, neither of which is repeatable.
# #
# It can fail the build, deliberately: a harness nobody runs is worse than # It can fail the build, deliberately: a harness nobody runs is worse than
# none, because it reads as coverage. Playwright's browser download is ~80 MB # none, because it reads as coverage.
# and takes well under a minute on ubuntu-latest, which is affordable next to #
# the two wasm-pack builds this job already needs. # It now needs NO Rust toolchain: the tree it serves is downloaded from
# `build`, which is what makes "the harness runs against the tree Pages
# publishes" (improvise-vvl3) a fact about bytes rather than about two
# scripts agreeing. The download is `harness/serve` whole, so there is still
# exactly one place that arrangement is decided — `harness/build.sh`.
browser: browser:
name: Browser harness (OPFS contract + shell) name: Browser harness (OPFS contract + shell)
needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
# Named rather than assumed: this job lints harness/opfs-contract,
# which the `test` job's workspace-wide fmt and clippy cannot reach.
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
- name: Install wasm-pack
env:
WASM_PACK_VERSION: v0.15.0
run: |
set -eu
url="https://github.com/rustwasm/wasm-pack/releases/download/${WASM_PACK_VERSION}/wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl.tar.gz"
curl -sSfL "$url" | tar -xz --strip-components=1 -C /usr/local/bin \
"wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl/wasm-pack"
wasm-pack --version
# Not a workspace member (it only builds for wasm32), so the `test` job's
# workspace-wide fmt and clippy do not reach it.
- name: Lint the harness crate
working-directory: harness/opfs-contract
run: |
cargo fmt --all -- --check
cargo clippy --target wasm32-unknown-unknown --all-targets -- -D warnings
- name: Install the harness - name: Install the harness
working-directory: harness working-directory: harness
run: npm ci run: npm ci
# Keyed on the resolved @playwright/test version, so a version bump is a
# miss and anything else is a hit. The download was 50s on run
# 35535225637 and is pure I/O — nothing about it depends on this commit.
- name: Playwright version
id: pw
working-directory: harness
run: echo "v=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
id: pw-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.pw.outputs.v }}
# All three, because improvise-xf1's acceptance criterion names Chrome, # All three, because improvise-xf1's acceptance criterion names Chrome,
# Firefox and Safari and `createSyncAccessHandle` is implemented # Firefox and Safari and `createSyncAccessHandle` is implemented
# separately by each engine. WebKit is installed even though its OPFS is # separately by each engine. WebKit is installed even though its OPFS is
# absent today: the spec skips on a runtime probe, so the day that build # absent today: the spec skips on a runtime probe, so the day that build
# gains OPFS the check starts running without a CI change. # gains OPFS the check starts running without a CI change.
# `--with-deps` installs apt packages OUTSIDE the cached directory, so it
# runs on a cache hit too; only the browser download is skipped.
- name: Install browsers - name: Install browsers
working-directory: harness working-directory: harness
run: npx playwright install --with-deps chromium firefox webkit run: npx playwright install --with-deps chromium firefox webkit
# Builds both site bundles, the harness's own wasm bundle, and assembles # The tree `build` assembled, not one this job rebuilds. Same bytes as
# `harness/serve` — the same tree `pages-build` publishes, plus /opfs/. # the Pages artifact, modulo the cache-bust token `web/assemble.sh`
- name: Build the harness # stamps — the harness tree is stamped `harness` and the site the commit
run: ./harness/build.sh # SHA, which was already true when both jobs built their own.
- name: Download the harness tree
uses: actions/download-artifact@v7
with:
name: harness-serve
path: harness/serve
# `web/pkg` and `web/worker-pkg` as wasm-pack left them, for the .d.ts
# the step below reads. The artifact's root is `web/`, so this restores
# both directories at the paths the test resolves relative to itself.
- name: Download the wasm bundles
uses: actions/download-artifact@v7
with:
name: wasm-bundles
path: web
# The JS↔wasm boundary: every call web/app.js makes is declared by the # The JS↔wasm boundary: every call web/app.js makes is declared by the
# bundle, with that arity. It needs the bundle and nothing else, so it # bundle, with that arity. It needs the bundle and nothing else, so it
# runs here rather than in the `js` job, and before Playwright — a # runs here rather than in the `js` job, and before Playwright — a
@@ -207,12 +354,13 @@ jobs:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# GitHub Pages delivery (improvise-d31). # GitHub Pages delivery (improvise-d31).
# #
# Two jobs on purpose. `pages-build` runs on every push and pull request, so # The artifact is uploaded by `build`, which runs on every push and pull
# a change that breaks `web/build.sh` or the bundle fails on the PR that made # request, so a change that breaks `web/build.sh` or the bundle fails on the
# it — the build is a gate. `pages-deploy` runs only on a push to main and # PR that made it — the build is a gate. `pages-deploy` runs only on a push
# `needs` the check jobs above, so nothing that failed `cargo test`, the # to main and `needs` the check jobs, so nothing that failed `cargo test`,
# wasm32 gate or the browser harness can reach the published site. The deploy # the wasm32 gate or the browser harness can reach the published site. The
# depends on those checks rather than repeating them (improvise-d31 AC 2). # deploy depends on those checks rather than repeating them (improvise-d31
# AC 2).
# #
# Not path-filtered, deliberately. The bundles are `improvise-wasm-client` and # Not path-filtered, deliberately. The bundles are `improvise-wasm-client` and
# `improvise-worker-server` plus, between them, most of the workspace — the # `improvise-worker-server` plus, between them, most of the workspace — the
@@ -221,60 +369,15 @@ jobs:
# that meant "skip when the bundle cannot have changed" would have to name # that meant "skip when the bundle cannot have changed" would have to name
# most of the workspace, and the failure mode of getting it wrong is a stale # most of the workspace, and the failure mode of getting it wrong is a stale
# site with no signal that it is stale. A redundant build is the cheaper error. # site with no signal that it is stale. A redundant build is the cheaper error.
pages-build: #
name: Browser bundle + static site # On improvise-864a (concurrent merges racing on one Pages environment, the
runs-on: ubuntu-latest # superseded run failing "Found 0 artifacts"): building once shortens the gap
steps: # between upload and deploy — 182s before, ~115s after, on the run 35535225637
- uses: actions/checkout@v7 # shape — so the window in which a superseding run can expire this run's
- uses: dtolnay/rust-toolchain@stable # artifact is smaller. It does not close the race; it does not widen it.
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
# Pinned release binary rather than `cargo install` (a three-minute
# source build) or a third-party action. wasm-pack runs its own bundled
# `wasm-opt`, which is why improvise-wasm-client's Cargo.toml names
# `--enable-bulk-memory`: without it the optimizer rejects the
# `memory.copy` LLVM emits regardless.
# v0.15.0 is what `nix develop` supplies, and the bundle was built and
# measured against it locally (346 KB, 120 KB gzipped). Pinning the same
# version is what makes that local run evidence about this one.
- name: Install wasm-pack
env:
WASM_PACK_VERSION: v0.15.0
run: |
set -eu
url="https://github.com/rustwasm/wasm-pack/releases/download/${WASM_PACK_VERSION}/wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl.tar.gz"
curl -sSfL "$url" | tar -xz --strip-components=1 -C /usr/local/bin \
"wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl/wasm-pack"
wasm-pack --version
# The same script a developer runs locally, so there is one answer to
# "how is the bundle built" rather than two that can drift.
- name: Build the wasm bundle
run: ./web/build.sh
# assemble.sh generates the prose site with node. The runner image ships
# one, but pinning it here is what makes a local run evidence about this.
- uses: actions/setup-node@v4
with:
node-version: "24"
# `web/assemble.sh`, not an inline copy of it: the browser harness job
# below assembles the same tree, and a harness that runs against a
# different arrangement of files is testing something nobody deploys.
- name: Assemble the site
run: ./web/assemble.sh site "$GITHUB_SHA"
- run: find site -type f | sort
- run: du -sh site
- uses: actions/upload-pages-artifact@v5
with:
path: site
pages-deploy: pages-deploy:
name: Deploy to GitHub Pages name: Deploy to GitHub Pages
needs: [test, wasm, js, browser, pages-build] needs: [test, wasm, js, browser, harness-lint, build]
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
+11
View File
@@ -191,6 +191,17 @@ exact-frame characterization tests that were the PR's only evidence the
migration changed nothing. Two right findings, different costs. File the migration changed nothing. Two right findings, different costs. File the
declined one. declined one.
**Under merge-as-soon-as-green, a review round can land after its own merge.**
GitHub deletes the branch when a PR merges, so a fix push to the old branch name
**silently succeeds and accomplishes nothing** — it re-creates a branch `main`
will never see, CI runs on it, and the commit is nowhere. The tell is the push
printing `* [new branch]` where you expected an `old..new` range. It happened on
#51: the third review round completed four minutes after the merge, and the
correction it prompted went to a re-created `wave/b-projection-gaps`. The repair
is to branch from `gh/main`, cherry-pick, open a follow-up PR, and delete the
stray re-creation. Check `gh pr view <n> --json state` *before* pushing a fix to
a PR you have not looked at in a few minutes, rather than after.
Merge only once Copilot passes. Prefer a **merge commit** — `AGENTS.md` says Merge only once Copilot passes. Prefer a **merge commit** — `AGENTS.md` says
merges over rebases, and a wave's commits are individually meaningful. merges over rebases, and a wave's commits are individually meaningful.
+1 -1
View File
@@ -180,7 +180,7 @@ improvise script setup.txt -f model.improv
## In the browser ## In the browser
The browser page is at **<https://fiddlerwoaroof.github.io/improvise/app/>**, The browser page is at **<https://fiddlerwoaroof.github.io/improvise/app/>**,
built and published from `main` by the `pages-build` / `pages-deploy` jobs in built and published from `main` by the `build` / `pages-deploy` jobs in
`.github/workflows/ci.yml`. A deploy only happens after `cargo test`, the `.github/workflows/ci.yml`. A deploy only happens after `cargo test`, the
wasm32 check and the Playwright browser harness have passed. wasm32 check and the Playwright browser harness have passed.
+1 -2
View File
@@ -79,8 +79,7 @@ authoritative `App`. A host that wants to apply a client's batch must face the
door a host may use: checking per command is the lost update improvise-955n door a host may use: checking per command is the lost update improvise-955n
fixed. It is now `#[cfg(test)]` (improvise-s17o), which makes "no production fixed. It is now `#[cfg(test)]` (improvise-s17o), which makes "no production
caller" a fact the compiler keeps rather than a note verified at one commit, and caller" a fact the compiler keeps rather than a note verified at one commit, and
leaves `apply_checked` as the one door. `context/design-principles.md` §5 names leaves `apply_checked` as the one door.
`Sessions::apply` as "dispatch — the one door"; read that as `apply_checked`.
### `Checked` — a wire `Command` the authoritative state has answered for ### `Checked` — a wire `Command` the authoritative state has answered for
+42 -1
View File
@@ -226,7 +226,7 @@ The pieces already exist and are worth naming as what they are:
| action | `Command` / `ModelCommand` (`improvise-protocol`) | | action | `Command` / `ModelCommand` (`improvise-protocol`) |
| reducer | `reduce_view`, and `WidgetCache::apply` per widget | | reducer | `reduce_view`, and `WidgetCache::apply` per widget |
| store | `ViewState` + the per-widget caches | | store | `ViewState` + the per-widget caches |
| dispatch | `Sessions::apply` — the one door (gotcha 36) | | dispatch | `Sessions::apply_checked` — the one door (gotcha 36) |
So the question to ask of any code in a front end is: **would a third front end So the question to ask of any code in a front end is: **would a third front end
have to write this again?** If yes, it is behaviour and it belongs in the shared have to write this again?** If yes, it is behaviour and it belongs in the shared
@@ -303,6 +303,16 @@ candidate for the store.
A ``` fence in a doc comment is a doctest — a running test, not prose — and A ``` fence in a doc comment is a doctest — a running test, not prose — and
is exempt. is exempt.
- **And an argument must not live only in a commit message.** A message is
write-once and unauditable: it cannot be corrected without rewriting merged
history, and no check in this repo can read one — the ID audit greps
`context`, `README.md`, `AGENTS.md`, `CLAUDE.md`, `crates`, `src`, `tests`,
`examples`, `web`, `harness` and `docs`, and a commit message is in none of
them. So a reason recorded only there is a reason nothing can ever find wrong.
When one turns out to be wrong, correct `context/` and say in `context/` that
the message is superseded; that is the fix rather than a compromise. PR #51's
`7d25762` is the worked example, corrected by #56.
--- ---
## 7. Testing ## 7. Testing
@@ -336,6 +346,37 @@ catches that: every `### name` heading must name a test that exists. It checks
one direction only, deliberately — a test may have no evidence (most do not), one direction only, deliberately — a test may have no evidence (most do not),
but evidence naming a test nobody can find is a dangling explanation. but evidence naming a test nobody can find is a dangling explanation.
### E2E pins the boundaries; unit tests work between them
**E2E and integration tests are rare and pin *boundary contracts*. Unit tests
*assume* those contracts and test between the boundaries** (user, 2026-09-20).
So the question to ask of a slow test is never "what feature does this cover" —
it is **"which boundary is this, and what is the contract at it?"** The answer
changes what you write. Four browser widgets had never worked — the tile bar,
the view panel, the formula panel, and the category sidebar ignoring
`category_panel_open` — and all four were **one** boundary failure: a
`RenderModel` field with no element painting it. Four per-widget Playwright
specs cost four slow tests and would still have missed the fifth; a single
contract assertion that *every field the render model exposes is painted*
catches all four and the next one. `harness/tests/opfs.spec.js` is the cleanest
instance of the right shape already in the tree: it runs the **storage
contract**, not a feature.
Three corollaries, in the order they bite:
- **A unit test may assume the contract rather than re-establishing it.** That
assumption is what makes it cheap, and it is only sound because something
slow pins the contract — so deleting the contract test silently invalidates
every unit test that leaned on it.
- **Never weaken a contract to make it cheap.** If the honest contract needs a
real browser, a real `Worker` or real OPFS, it stays slow. Moving it to a
faster harness that cannot see the thing it names is how a suite goes green
over a broken product.
- **Each slow test must say which contract it pins.** The difference between a
contract test and a feature test is invisible from the code, so it has to be
written down or the next person adds the fifth per-widget spec.
### Use property tests judiciously ### Use property tests judiciously
Property tests (`proptest`) are for **invariants that must hold across all Property tests (`proptest`) are for **invariants that must hold across all
+1 -1
View File
@@ -239,7 +239,7 @@ Each cast should be embedded with `AsciinemaPlayer.create()` configured with `th
### 4.2 Enable GitHub Pages ### 4.2 Enable GitHub Pages
**Not branch-and-folder.** Pages is served from the GitHub Actions artifact that `pages-build` / `pages-deploy` publish in `.github/workflows/ci.yml` (Settings → Pages → Source: GitHub Actions), and the tree is assembled by `web/assemble.sh`: `docs/index.html` and `docs/casts/` at the root, the browser app under `app/`. **Not branch-and-folder.** Pages is served from the GitHub Actions artifact that `build` / `pages-deploy` publish in `.github/workflows/ci.yml` (Settings → Pages → Source: GitHub Actions), and the tree is assembled by `web/assemble.sh`: `docs/index.html` and `docs/casts/` at the root, the browser app under `app/`.
Setting the source to branch `main` / folder `/docs` instead would serve `docs/` raw. That used to fail loudly — there was no `docs/index.html`, so it was a 404 — and since improvise-e61 it fails **quietly**: the landing page renders perfectly and its "Run improvise in your browser" link 404s, because `app/` exists only in the assembled tree. A page that looks right with a dead front door is worse than a page that is missing. Setting the source to branch `main` / folder `/docs` instead would serve `docs/` raw. That used to fail loudly — there was no `docs/index.html`, so it was a 404 — and since improvise-e61 it fails **quietly**: the landing page renders perfectly and its "Run improvise in your browser" link 404s, because `app/` exists only in the assembled tree. A page that looks right with a dead front door is worse than a page that is missing.
+14 -11
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -457,7 +457,7 @@ mod quality_tests {
#[test] #[test]
fn the_ranking_is_ordered_shares_with_the_unused_features_dropped() { fn the_ranking_is_ordered_shares_with_the_unused_features_dropped() {
let ranked = importance_ranking(&[1.0, 0.0, 3.0]); let ranked = importance_ranking(&[1.0, 0.0, 3.0]);
assert_eq!(ranked.len(), 2, "the zero-importance feature is listed"); assert_eq!(ranked.len(), 2, "the zero-importance feature is dropped");
assert_eq!(ranked[0].0, 2); assert_eq!(ranked[0].0, 2);
assert_eq!(ranked[1].0, 0); assert_eq!(ranked[1].0, 0);
assert!((ranked[0].1 - 75.0).abs() < 1e-3, "{ranked:?}"); assert!((ranked[0].1 - 75.0).abs() < 1e-3, "{ranked:?}");
+33 -1
View File
@@ -242,6 +242,12 @@ fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> Op
cmd: ModelCommand::TransposeAxes, cmd: ModelCommand::TransposeAxes,
}]), }]),
// The hint line advertises `>`, and an upstream key is a black hole
// (gotcha 47), so it said nothing at all. Interim: improvise-gd28.
"drill-into-cell" => Some(vec![Command::SetStatus(
"drill is not available in the browser yet".to_string(),
)]),
// ── editing ────────────────────────────────────────────────── // ── editing ──────────────────────────────────────────────────
"edit-or-drill" => edit_or_drill(state, AppMode::from_name(args.first()?)?), "edit-or-drill" => edit_or_drill(state, AppMode::from_name(args.first()?)?),
"commit-cell-edit" => commit(state, AppMode::from_name(args.first()?)?, Advance::Down), "commit-cell-edit" => commit(state, AppMode::from_name(args.first()?)?, Advance::Down),
@@ -1222,7 +1228,8 @@ mod tests {
(ev(KeyCode::Char('Z')), "<the Z prefix>"), (ev(KeyCode::Char('Z')), "<the Z prefix>"),
(ev(KeyCode::Char('H')), "hide-selected-row-item"), (ev(KeyCode::Char('H')), "hide-selected-row-item"),
(ev(KeyCode::Char('z')), "toggle-group-under-cursor"), (ev(KeyCode::Char('z')), "toggle-group-under-cursor"),
(ev(KeyCode::Char('>')), "drill-into-cell"), // `>` is NOT here any more: it answers with a status of its own,
// because it is advertised and silence read as broken (gd28).
(ev(KeyCode::Char('<')), "view-back"), (ev(KeyCode::Char('<')), "view-back"),
(ev(KeyCode::Char('/')), "search"), (ev(KeyCode::Char('/')), "search"),
(ev(KeyCode::Char('n')), "search-navigate"), (ev(KeyCode::Char('n')), "search-navigate"),
@@ -1259,6 +1266,31 @@ mod tests {
} }
} }
/// The advertised key must say what is actually true. Two refusals, two
/// subjects — see `context/test-evidence/clients.md`.
#[test]
fn the_drill_key_and_an_aggregate_edit_refuse_for_different_reasons() {
let mut state = in_mode(AppMode::Normal);
state.grid.extent = GridExtent { rows: 2, cols: 2 };
state.window = (2, 2);
let KeyOutcome::Local(drill) = resolve(&state, ev(KeyCode::Char('>'))) else {
panic!("`>` is advertised in the hint line; it must answer");
};
let [Command::SetStatus(said)] = &drill[..] else {
panic!("expected one status, got {drill:?}");
};
assert!(
said.contains("drill") && said.contains("browser"),
"the status must name drill and say it is the browser that lacks it: {said}"
);
assert!(
!said.contains("aggregate"),
"a sentence about aggregates is TRUE-SOUNDING about the wrong thing — \
`>` is refused whatever the cell is: {said}"
);
}
/// The refusal above is only half the property: the bug guarded is /// The refusal above is only half the property: the bug guarded is
/// *entering a room with no exit*. See `context/test-evidence/clients.md`. /// *entering a room with no exit*. See `context/test-evidence/clients.md`.
#[test] #[test]
+15 -13
View File
@@ -48,27 +48,29 @@ test.describe("drill is refused in the browser, not broken", () => {
await expect(page.locator("#grid input")).toHaveCount(0); await expect(page.locator("#grid input")).toHaveCount(0);
}); });
// The silent half, and the one worth pinning. `>` is `drill-into-cell`, // `>` is the key the hint line advertises as ":drill", and it used to be a
// which `resolve::step` does not name, so it becomes `KeyOutcome::Upstream` // name `resolve::step` did not handle — so `KeyOutcome::Upstream` ->
// -> `ClientFrame::Key` -> `Host::handle`, whose Key arm is an empty vec // `ClientFrame::Key` -> `Host::handle`'s empty vec, a black hole rather than
// (gotcha 47). An upstream key is a black hole, not a slow path. // a slow path (gotcha 47). It now answers. Interim until improvise-gd28.
test("the drill key does nothing at all, and the page is not merely dead", async ({ test("the drill key says drill is unavailable, not something about aggregates", async ({
page, page,
}) => { }) => {
await anAggregateGrid(page); await anAggregateGrid(page);
const before = await page.locator("#status").textContent();
await page.keyboard.press(">"); await page.keyboard.press(">");
// Nothing moves: not the mode, not the status, not the grid. await expect(page.locator("#status")).toContainText(
"drill is not available in the browser yet",
);
await expect(page.locator("#mode")).toHaveText("NORMAL"); await expect(page.locator("#mode")).toHaveText("NORMAL");
await expect(page.locator("#status")).toHaveText(before);
await expect(page.locator("#grid td.editable")).toHaveCount(0);
// check_the_checker: a test that asserts "nothing happened" passes against // The two refusals have two SUBJECTS, and conflating them would be a
// a page that has stopped listening. This proves the keyboard is live, so // true-sounding sentence about the wrong thing — worse than silence,
// `>`'s silence is specific to `>` — the refusal below DOES change #status. // because it stops the reader looking. `>` must not talk about aggregates.
await expect(page.locator("#status")).not.toContainText("aggregate");
// And `i` still gives the OTHER refusal, which is what proves the two are
// distinct rather than one message reached by two keys.
await page.keyboard.press("i"); await page.keyboard.press("i");
await expect(page.locator("#status")).not.toHaveText(before);
await expect(page.locator("#status")).toContainText( await expect(page.locator("#status")).toContainText(
"drilling into an aggregate needs the host", "drilling into an aggregate needs the host",
); );
+8 -6
View File
@@ -8,7 +8,7 @@ crate(gp_tree,'crates/gp-tree','genetic-programming tree fitting; problem-agnost
crate(improvise_gp,'crates/improvise-gp','bridge: emits a fitted gp-tree Tree as an improvise formula string and validates it through parse_formula. Exists because gp-tree must stay problem-agnostic and improvise-formula must not drag GP into the wasm bundle.'). crate(improvise_gp,'crates/improvise-gp','bridge: emits a fitted gp-tree Tree as an improvise formula string and validates it through parse_formula. Exists because gp-tree must stay problem-agnostic and improvise-formula must not drag GP into the wasm bundle.').
crate(improvise_ws_server,'crates/improvise-ws-server','the socket host: tokio + tungstenite transport and a Registry of Hosts keyed by DocumentId. Re-exports Host and the frames; defines neither. Native only.'). crate(improvise_ws_server,'crates/improvise-ws-server','the socket host: tokio + tungstenite transport and a Registry of Hosts keyed by DocumentId. Re-exports Host and the frames; defines neither. Native only.').
crate(improvise_wasm_client,'crates/improvise-wasm-client','the browser client: the fold, the key classifier, and the render model the DOM paints. Non-dev deps are improvise-protocol + serde + wasm-bindgen and nothing else.'). crate(improvise_wasm_client,'crates/improvise-wasm-client','the browser client: the fold, the key classifier, and the render model the DOM paints. Non-dev deps are improvise-protocol + serde + wasm-bindgen and nothing else.').
crate(improvise_worker_server,'crates/improvise-worker-server','the other host: the same App, Sessions and Host as the socket server, behind self.onmessage/self.postMessage with OPFS underneath. worker.rs and vfs.rs are ordinary Rust and hold every decision and every test; bindings.rs is the wasm32 shim and is never run.'). crate(improvise_worker_server,'crates/improvise-worker-server','the other host: the same App, Sessions and Host as the socket server, behind self.onmessage/self.postMessage with OPFS underneath. worker.rs and vfs.rs are ordinary Rust and hold every decision and every test; bindings.rs is the wasm32 shim, run by harness/tests/shell.spec.js under Chromium and by no Rust test.').
layer(model,improvise_core). layer(model,improvise_core).
layer(view,improvise_core). layer(view,improvise_core).
layer(formula,improvise_formula). layer(formula,improvise_formula).
@@ -102,7 +102,7 @@ invariant(a_cats_change_reidentifies_every_position,'A cells identity is AxisFra
invariant(the_header_width_rule_has_one_home,'improvise_protocol::header_level_widths: per level, the widest label plus a gap, clamped (improvise-1125). It lived twice -- inline in src/ui/grid.rs::render_grid and again in ui::viewport::compute_row_header_width -- and a browser sizing its own row header would have been a third. It takes a borrowed iterator rather than an AxisFrame because its other caller holds a GridLayout on the keypress path and building a frame there would clone every row tuple on every key.'). invariant(the_header_width_rule_has_one_home,'improvise_protocol::header_level_widths: per level, the widest label plus a gap, clamped (improvise-1125). It lived twice -- inline in src/ui/grid.rs::render_grid and again in ui::viewport::compute_row_header_width -- and a browser sizing its own row header would have been a third. It takes a borrowed iterator rather than an AxisFrame because its other caller holds a GridLayout on the keypress path and building a frame there would clone every row tuple on every key.').
invariant(saving_is_a_frame_not_a_command,'ClientFrame::Save (improvise-368h) carries no path -- a client cannot see the hosts storage -- and is a FRAME because as a Command it put a runtime-fallible operation inside a batch ClientFrame::Commands promises is whole-or-nothing, so [Save, SetCell] reported a failed write and applied the edit anyway. It also fails the test every Command passes: it mutates no shared state and no clients slice, so reduce_view has nothing to fold and Checked has no basis to answer for. Refused as NoDocumentPath or SaveFailed to the asker alone; success is silent.'). invariant(saving_is_a_frame_not_a_command,'ClientFrame::Save (improvise-368h) carries no path -- a client cannot see the hosts storage -- and is a FRAME because as a Command it put a runtime-fallible operation inside a batch ClientFrame::Commands promises is whole-or-nothing, so [Save, SetCell] reported a failed write and applied the edit anyway. It also fails the test every Command passes: it mutates no shared state and no clients slice, so reduce_view has nothing to fold and Checked has no basis to answer for. Refused as NoDocumentPath or SaveFailed to the asker alone; success is silent.').
invariant(the_persist_trigger_is_an_observation,'improvise-worker-server persists on model_state.dirty, not on a table of Command variants (touches_document is deleted) -- the same choice as watching cursor_epoch rather than matching CycleAxis, and document_generation rather than matching LoadModel. It is strictly tighter: a refused batch raises nothing, so the "was anything rejected" guard went with it.'). invariant(the_persist_trigger_is_an_observation,'improvise-worker-server persists on model_state.dirty, not on a table of Command variants (touches_document is deleted) -- the same choice as watching cursor_epoch rather than matching CycleAxis, and document_generation rather than matching LoadModel. It is strictly tighter: a refused batch raises nothing, so the "was anything rejected" guard went with it.').
invariant(opfs_is_run_but_bindings_is_not,'harness/ (improvise-vvl3/xf1) executes improvise_io::storage::contract::storage_contract against a real OpfsFs in a real dedicated Web Worker under Chromium and Firefox, gated by the browser job in CI; two bugs fell out of the first run. Playwrights WebKit provides no OPFS at all (getDirectory rejects with UnknownError), so xf1s three-engine criterion is met for two of three and the project is kept so it starts the day that build gains OPFS. harness/tests/shell.spec.js runs web/app.js under CHROMIUM ONLY -- engine-independent logic, and routeWebSocket is slower across three. Never instantiated by any harness: bindings.rs.'). invariant(the_harness_runs_the_worker_chain_under_chromium_only,'harness/ (improvise-vvl3/xf1) executes improvise_io::storage::contract::storage_contract against a real OpfsFs in a real dedicated Web Worker under Chromium and Firefox, gated by the browser job in CI; two bugs fell out of the first run. Playwrights WebKit provides no OPFS at all (getDirectory rejects with UnknownError), so xf1s three-engine criterion is met for two of three and the project is kept so it starts the day that build gains OPFS. harness/tests/shell.spec.js runs web/app.js under CHROMIUM ONLY -- engine-independent logic, and routeWebSocket is slower across three. It also runs BINDINGS.RS: its no-?ws test loads the app, which spawns web/worker.js, which imports the glue, which calls bindings.rs start() and OpfsFs::open(), and the test then asserts a grid draws. This invariant was named opfs_is_run_but_bindings_is_not and asserted the opposite in FIVE places for months (here, the crate/3 fact above, two context/repo-map.md lines and improvise-bck) -- every copy written before the harness landed and none re-read after. A never-run claim in prose is unfalsifiable by anything in this repo: the CI job checks this KB for CONSISTENCY, not truth, and the error UNDERSTATED coverage so nothing failed and nobody looked. What is genuinely unrun, BY ENGINE: Safari/WebKit (no OPFS in Playwrights build) and any non-Chromium engine for the shell spec. That is deliberately scoped to engines rather than written as a closed list of everything unreached, because a closed list is how the previous version of this invariant went wrong in the other direction: two wasm_bindgen exports on WorkerServer, documents() and refresh(), have NO caller in web/*.js or harness/ and so are run by nothing (the .refresh( hits in harness/opfs-contract are OpfsFs::refresh, a different method). The lesson the rename is about applies to its own replacement: a coverage claim in prose is unfalsifiable by anything in this repo, so state what IS covered and scope what is not, rather than enumerating an absence.').
doctrine(bug_first,'write a failing test demonstrating the bug before fixing it'). doctrine(bug_first,'write a failing test demonstrating the bug before fixing it').
doctrine(unit_over_e2e,'prefer small unit tests over integration/e2e'). doctrine(unit_over_e2e,'prefer small unit tests over integration/e2e').
doctrine(suite_speed,'Two numbers, because the ambiguity between them is what let this drift: NO SINGLE TEST BINARY may exceed 2 seconds, and the WORKSPACE WALL CLOCK is the sum of them. Measured at 22d43b2: 1309 tests over 33 binaries, ~2.2s wall, longest single binary 1.42s -- so the workspace is already past 2s and the rule that bites is the per-binary one. Re-MEASURE rather than re-assert: cargo test --workspace prints a "finished in" per binary. Excludes compilation, and excludes harness/ (Playwright, minutes, its own CI job).'). doctrine(suite_speed,'Two numbers, because the ambiguity between them is what let this drift: NO SINGLE TEST BINARY may exceed 2 seconds, and the WORKSPACE WALL CLOCK is the sum of them. Measured at 22d43b2: 1309 tests over 33 binaries, ~2.2s wall, longest single binary 1.42s -- so the workspace is already past 2s and the rule that bites is the per-binary one. Re-MEASURE rather than re-assert: cargo test --workspace prints a "finished in" per binary. Excludes compilation, and excludes harness/ (Playwright, minutes, its own CI job).').
@@ -122,11 +122,12 @@ doctrine(two_questions_one_enum,'when a new enum variant shares one property wit
doctrine(contract_test_for_the_unreachable_backend,'when one implementation of a trait cannot be exercised by the test harness, write the contract as a function over &dyn Trait and run it against the reachable implementations; it becomes the executable specification the unreachable one is held to'). doctrine(contract_test_for_the_unreachable_backend,'when one implementation of a trait cannot be exercised by the test harness, write the contract as a function over &dyn Trait and run it against the reachable implementations; it becomes the executable specification the unreachable one is held to').
doctrine(derive_the_enum_from_the_effects,'when a wire vocabulary must mirror an existing mutation set, enumerate the mutations and let that be the enum; inventing variants ahead of the mutations produces a protocol nobody can implement against'). doctrine(derive_the_enum_from_the_effects,'when a wire vocabulary must mirror an existing mutation set, enumerate the mutations and let that be the enum; inventing variants ahead of the mutations produces a protocol nobody can implement against').
doctrine(force_the_failure,'an unchecked status whose failure yields a usable-looking value is not a style nit: force the failure and look at what the next line does with it. Four on one branch -- bd init exits non-zero but still leaves an empty database behind, an unchecked mv lands the new tracker INSIDE the old directory and reports success, jq status swallowed by a process substitution reads as \"no inputs found\" and exits 0, and a failed mktemp turns \"$TMP/$NAME\" into an absolute path at the filesystem root. Every one looked like success at runtime, so reading the failure paths found none of them; stubbing mv and mktemp to fail is what exposed them. Note chmod a-w proves nothing when the tests run as root -- it returned 0 and looked like a pass. The same shape appears in tooling: requesting a Copilot PR review over REST returns HTTP 200 and silently ignores the reviewer.'). doctrine(force_the_failure,'an unchecked status whose failure yields a usable-looking value is not a style nit: force the failure and look at what the next line does with it. Four on one branch -- bd init exits non-zero but still leaves an empty database behind, an unchecked mv lands the new tracker INSIDE the old directory and reports success, jq status swallowed by a process substitution reads as \"no inputs found\" and exits 0, and a failed mktemp turns \"$TMP/$NAME\" into an absolute path at the filesystem root. Every one looked like success at runtime, so reading the failure paths found none of them; stubbing mv and mktemp to fail is what exposed them. Note chmod a-w proves nothing when the tests run as root -- it returned 0 and looked like a pass. The same shape appears in tooling: requesting a Copilot PR review over REST returns HTTP 200 and silently ignores the reviewer.').
doctrine(an_injection_that_did_not_compile_reads_as_a_clean_run,'a fault injection is evidence only if the build RAN. A grep for compiler errors that matches ^error[ but not a bare error: line turns a failed compile into a green read -- the identical defect class injection exists to catch, one level up in the instrument. Caught mid-review on PR #48 by the reviewer in its own tooling, one step from a false unguarded finding, and then re-checked against the earlier runs rather than assumed away. The fix is not a wider grep, because a pattern can always be one case short: require a POSITIVE signal that the injected build ran -- the test count moved, or the named test appears in the output. A count cannot be silently empty.').
doctrine(kb_is_maintained_through_the_mcp,'prolog-sessions/kb.pl is the MCP serialized clause database, not a hand-written source file. Add facts by loadSession, then loadProgram, then saveSession -- never by appending text to the file. Note loadProgram REPLACES a whole predicate rather than appending to it, so the program you load must carry every existing clause of every predicate it touches, or the rest are silently dropped. Verify counts with findall/length before saving. assertz is NOT an escape hatch: a consulted predicate is static and assertz raises permission_error, so the whole-predicate reload is the only route.'). doctrine(kb_is_maintained_through_the_mcp,'prolog-sessions/kb.pl is the MCP serialized clause database, not a hand-written source file. Add facts by loadSession, then loadProgram, then saveSession -- never by appending text to the file. Note loadProgram REPLACES a whole predicate rather than appending to it, so the program you load must carry every existing clause of every predicate it touches, or the rest are silently dropped. Verify counts with findall/length before saving. assertz is NOT an escape hatch: a consulted predicate is static and assertz raises permission_error, so the whole-predicate reload is the only route.').
doctrine(fold_tests_must_test_order_not_the_axiom,'a test asserting fold(prefix) then fold(rest) == fold(whole) is VACUOUS: that identity is the definition of a left fold and holds for any apply() whatsoever. Proven 2026-09-19 by rewriting GridCache::apply to write every cell update to (0,0) -- both headline fold tests stayed green while the two real tests failed. Test the properties the design actually rests on: ORDER SENSITIVITY (which updates are dropped when they arrive early, i.e. that the fold is deliberately not commutative) and COMPACTION EQUIVALENCE (fold(compact(history)) == fold(history)), which is what \"seeded, then follows the event stream\" means in production. A seed taken as the literal head of the history presupposes the conclusion.'). doctrine(fold_tests_must_test_order_not_the_axiom,'a test asserting fold(prefix) then fold(rest) == fold(whole) is VACUOUS: that identity is the definition of a left fold and holds for any apply() whatsoever. Proven 2026-09-19 by rewriting GridCache::apply to write every cell update to (0,0) -- both headline fold tests stayed green while the two real tests failed. Test the properties the design actually rests on: ORDER SENSITIVITY (which updates are dropped when they arrive early, i.e. that the fold is deliberately not commutative) and COMPACTION EQUIVALENCE (fold(compact(history)) == fold(history)), which is what \"seeded, then follows the event stream\" means in production. A seed taken as the literal head of the history presupposes the conclusion.').
doctrine(a_macro_must_not_supply_a_forced_classification,'invariant(effect_kind_no_default) makes the compiler demand an EffectKind per effect precisely because a wrong one is silent. A macro that hard-codes the kind reintroduces the default under another name, for a category defined by SYNTAX (one field, one place) rather than SEMANTICS (per-client). Proven 2026-09-19: a simple_effect! instantiation writing the active view number format -- a textbook LayoutGeometry change -- compiled, reported ViewOnly, and passed all 294 command tests. If a macro covers a classified thing, make the classification a mandatory argument even when the answer is always the same.'). doctrine(a_macro_must_not_supply_a_forced_classification,'invariant(effect_kind_no_default) makes the compiler demand an EffectKind per effect precisely because a wrong one is silent. A macro that hard-codes the kind reintroduces the default under another name, for a category defined by SYNTAX (one field, one place) rather than SEMANTICS (per-client). Proven 2026-09-19: a simple_effect! instantiation writing the active view number format -- a textbook LayoutGeometry change -- compiled, reported ViewOnly, and passed all 294 command tests. If a macro covers a classified thing, make the classification a mandatory argument even when the answer is always the same.').
doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only,'when a rule must hold on several arms -- both directions of a fit, every variant that touches one collection, each side of a symmetry -- a test driving ONE arm says nothing about the others, and will sit there green while the rule is violated next door. Worked example 2026-09-19: improvise-rkeq fixed col_widths never being re-fitted when ColLabels changed, and fixed only the label arms; the ColWidths arm, named after the very collection it fits, kept assigning verbatim and put the length mismatch straight back. col_widths_always_span_the_column_labels passed the entire time because it only ever drove the label arm. Copilot caught it on the PR. Enumerate the arms and test each, or assert the rule over a generated set of arms rather than one chosen by hand.'). doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only,'when a rule must hold on several arms -- both directions of a fit, every variant that touches one collection, each side of a symmetry -- a test driving ONE arm says nothing about the others, and will sit there green while the rule is violated next door. Worked example 2026-09-19: improvise-rkeq fixed col_widths never being re-fitted when ColLabels changed, and fixed only the label arms; the ColWidths arm, named after the very collection it fits, kept assigning verbatim and put the length mismatch straight back. col_widths_always_span_the_column_labels passed the entire time because it only ever drove the label arm. Copilot caught it on the PR. Enumerate the arms and test each, or assert the rule over a generated set of arms rather than one chosen by hand.').
doctrine(lockstep_binds_every_session_not_the_asker,'when two copies of a value must move together and one copy is per-client, moving it for the client that ASKED is not lockstep -- it is a desync with a polite name. Proven 2026-09-19 (improvise-kvyt, found by Copilot): Sessions::apply moved workbook.active_view and then only the addressing session mirror, but grid_of builds EVERY client grid from workbook.active_view(), so a bystander left naming the outgoing view had its cursor read under a stale key and was projected the new view rows positioned by the old view scroll. The fix gives every mirror the ACCEPTED value -- not the requested one, which also corrects a client that optimistically folded a name the document turned down -- and apply_switch takes no SessionId at all, because the change is addressed to no one session and that absence is the statement. Ask of any shared value: who else derives something from it?'). doctrine(lockstep_binds_every_session_not_the_asker,'when two copies of a SHARED value must move together and one copy is per-client, moving it for the client that ASKED is not lockstep -- it is a desync with a polite name. Proven 2026-09-19 (improvise-kvyt, found by Copilot) on active_view, and the reasoning generalises past the example that taught it: ask of any shared value, who else derives something from it? improvise-jytm then REMOVED active_view from the doctrine scope by making it per-client (the model is shared, the UI is not), so what remains under it is the document half -- cursor_epoch, document_generation -- and the doctrine is now a test for whether a value is shared AT ALL before it is a test for who hears about it.').
doctrine(an_invariant_is_made_unrepresentable_not_validated,'when a reviewer asks a function to validate a precondition -- matching lengths of parallel slices, a count supplied alongside the collection it counts -- prefer changing the type so the bad state cannot be built. Proven twice on one branch 2026-09-19: prune_axis_items stopped taking &[bool] plus a debug_assert and took a predicate over the data index instead, so no parallel length exists to get wrong; and the same reviewer request against gp-tree five public scoring functions was declined in favour of giving them one slice of rows rather than parallel x/y, which removes the zip truncation AND the empty-input NaN as one shape rather than four edge cases. Validation also pushes Result into five signatures, which AGENTS.md keeps at the boundaries.'). doctrine(an_invariant_is_made_unrepresentable_not_validated,'when a reviewer asks a function to validate a precondition -- matching lengths of parallel slices, a count supplied alongside the collection it counts -- prefer changing the type so the bad state cannot be built. Proven twice on one branch 2026-09-19: prune_axis_items stopped taking &[bool] plus a debug_assert and took a predicate over the data index instead, so no parallel length exists to get wrong; and the same reviewer request against gp-tree five public scoring functions was declined in favour of giving them one slice of rows rather than parallel x/y, which removes the zip truncation AND the empty-input NaN as one shape rather than four edge cases. Validation also pushes Result into five signatures, which AGENTS.md keeps at the boundaries.').
doctrine(same_kind_is_not_the_same_effect,'a classification shared by an operation and its INVERSE cannot check a mapping between them. Proven 2026-09-19 (improvise-kmgs review): model_effects sends 28 ModelCommand variants to one effect each, and HideItem/ShowItem, AddItem/RemoveItem, CreateView/DeleteView and SetCell/ClearCell all declare the same EffectKind -- so swapping three of them simultaneously left the workspace at 918 passing, 0 failing, with three wire messages doing the exact inverse of what they said. A test asserting only that the target declares the right KIND is therefore no guard at all. Where a mapping is name-for-name by construction, ASSERT THE NAME: it is total, costs nothing at runtime, and a variant whose effect must be named something else is a variant that stopped being derived and should fail loudly.'). doctrine(same_kind_is_not_the_same_effect,'a classification shared by an operation and its INVERSE cannot check a mapping between them. Proven 2026-09-19 (improvise-kmgs review): model_effects sends 28 ModelCommand variants to one effect each, and HideItem/ShowItem, AddItem/RemoveItem, CreateView/DeleteView and SetCell/ClearCell all declare the same EffectKind -- so swapping three of them simultaneously left the workspace at 918 passing, 0 failing, with three wire messages doing the exact inverse of what they said. A test asserting only that the target declares the right KIND is therefore no guard at all. Where a mapping is name-for-name by construction, ASSERT THE NAME: it is total, costs nothing at runtime, and a variant whose effect must be named something else is a variant that stopped being derived and should fail loudly.').
doctrine(a_rename_can_hide_a_stale_key,'a fixture that RENAMES a key across the change under test can make a stale entry unreachable rather than wrong, so the test passes for the wrong reason. Proven 2026-09-19: the first document-swap test used a fixture whose incoming active view was renamed Default->Sheet, which made apply_model broadcast a SwitchView; the client folded it and then read its cursor under a key nothing had written -- right by accident, bug invisible. The replacement keeps the view NAME and changes only the shape, so there is nothing to hide behind. General form: when testing that stale state is corrected, hold the identifiers constant and vary only what makes the state stale. Same family as the one-category fixture that could not tell TransposeAxes from CycleAxis.'). doctrine(a_rename_can_hide_a_stale_key,'a fixture that RENAMES a key across the change under test can make a stale entry unreachable rather than wrong, so the test passes for the wrong reason. Proven 2026-09-19: the first document-swap test used a fixture whose incoming active view was renamed Default->Sheet, which made apply_model broadcast a SwitchView; the client folded it and then read its cursor under a key nothing had written -- right by accident, bug invisible. The replacement keeps the view NAME and changes only the shape, so there is nothing to hide behind. General form: when testing that stale state is corrected, hold the identifiers constant and vary only what makes the state stale. Same family as the one-category fixture that could not tell TransposeAxes from CycleAxis.').
@@ -136,7 +137,7 @@ doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished,'two de
doctrine(a_missing_variant_is_a_date_before_it_is_a_decision,'when two vocabularies for the same job differ, ask of each difference whether the ARGUMENT for it names the thing they differ in. In improvise-xks6 none of Viewport, Key or batching mentioned a transport -- each was justified by the session model or the fold -- so each difference was an unfinished feature or a stale file. THE TELL FOR STALENESS IS INCOHERENCE RATHER THAN DIFFERENCE: the worker envelope carried a Basis it had no Rejection to answer.'). doctrine(a_missing_variant_is_a_date_before_it_is_a_decision,'when two vocabularies for the same job differ, ask of each difference whether the ARGUMENT for it names the thing they differ in. In improvise-xks6 none of Viewport, Key or batching mentioned a transport -- each was justified by the session model or the fold -- so each difference was an unfinished feature or a stale file. THE TELL FOR STALENESS IS INCOHERENCE RATHER THAN DIFFERENCE: the worker envelope carried a Basis it had no Rejection to answer.').
doctrine(a_struct_variant_closes_omission_not_ignoring,'making a field mandatory in the MESSAGE stops a sender forgetting it. It does nothing to stop a RECEIVER discarding it: the two-dot rest pattern is exactly that, with no compile error. If the receiver must not ignore it, the receiver PARAMETER is the place to say so -- improvise-ondj gave apply_command a Checked newtype rather than a guard.'). doctrine(a_struct_variant_closes_omission_not_ignoring,'making a field mandatory in the MESSAGE stops a sender forgetting it. It does nothing to stop a RECEIVER discarding it: the two-dot rest pattern is exactly that, with no compile error. If the receiver must not ignore it, the receiver PARAMETER is the place to say so -- improvise-ondj gave apply_command a Checked newtype rather than a guard.').
doctrine(a_documented_door_with_no_caller_is_the_dangerous_kind,'reduce_full was named by ui/reduce.rs module doc as the entry point every host should use, and used by NONE of them. Nothing exercised its Command::Model arm, so nothing could notice it skipped the basis check. Audit the functions the DOCS ADVERTISE, not only the ones the callers reach.'). doctrine(a_documented_door_with_no_caller_is_the_dangerous_kind,'reduce_full was named by ui/reduce.rs module doc as the entry point every host should use, and used by NONE of them. Nothing exercised its Command::Model arm, so nothing could notice it skipped the basis check. Audit the functions the DOCS ADVERTISE, not only the ones the callers reach.').
doctrine(the_audience_follows_the_optimistic_fold,'a relay to other clients is ToOthers exactly when the asker folded the command optimistically; otherwise ToAll. A Command::Model is never folded optimistically, so every relay out of apply_model is ToAll. Copying apply_switch ToOthers into apply_model left the asker own slice naming the outgoing view while its server mirror named the incoming one -- and a test asserting through a what-do-the-others-hear helper cannot see it, because that helper looks away from the asker by construction.'). doctrine(the_audience_follows_the_optimistic_fold,'a relay is addressed by whether the asker folded the command optimistically and by whose UI the change reaches. A Command::Model is never folded optimistically, so every relay out of apply_model is ToAll. A view switch IS folded optimistically and reaches nobody else UI, so an accepted one relays NOTHING and a refused one is To(asker) alone -- improvise-6c6q, where ToOthers told every client but the one that was wrong. Two traps, both proven by injection on PR #48. A test written through a what-do-the-others-hear helper looks away from the asker by construction, which is how the sibling bug in apply_model shipped green. And a test whose bystander is already on the value being relayed cannot see a wrongly WIDENED audience either, because the wrong relay folds to a no-op -- so assert the Broadcast ITSELF, since the audience is the message, and seat the bystander somewhere it can be yanked off.').
doctrine(half_a_lockstep_fix_is_worse_than_none,'resetting the server mirrors without telling the clients makes the DIFF believe they are already current, so it sends nothing: stale FOREVER rather than until the next scroll. When a fix has a server half and a client half, shipping one is not partial progress, it is a worse bug than the original.'). doctrine(half_a_lockstep_fix_is_worse_than_none,'resetting the server mirrors without telling the clients makes the DIFF believe they are already current, so it sends nothing: stale FOREVER rather than until the next scroll. When a fix has a server half and a client half, shipping one is not partial progress, it is a worse bug than the original.').
doctrine(force_is_a_recomposition_not_a_bypass,'force never applies a batch whose basis is stale; it re-composes the same INTENT against the current state and submits a fresh batch with a current basis. A mechanism whose only purpose is to violate the invariant is not a feature, it is the invariant deleted with extra steps.'). doctrine(force_is_a_recomposition_not_a_bypass,'force never applies a batch whose basis is stale; it re-composes the same INTENT against the current state and submits a fresh batch with a current basis. A mechanism whose only purpose is to violate the invariant is not a feature, it is the invariant deleted with extra steps.').
doctrine(a_simulated_fix_must_be_the_fix_you_named,'The sibling of an_injected_fault_must_be_the_fault_you_named, one level up, and it fails in the opposite direction: an injected FAULT that is not the one named proves a check that does not fire; a simulated FIX that is not the one named proves a BUG THAT DOES NOT EXIST. Worked example: the harness agent measured the 8s watchdog overwrite honestly, on a tree predating PR #21, then reported it "survives improvise-taxx, confirmed by simulating the opened flag". PR #21 is TWO changes -- the flag AND routing both close arms through setShellFailure, which clears the timer. Simulating half of it reproduced the bug and read exactly like a confirmation. improvise-xps8 was filed on that report, propagated into repo-map gotcha 48 and a spec comment, and was closed INVALID. The repair is not a deletion: the property is now executed by a test that outlasts HANDSHAKE_TIMEOUT_MS, proved discriminating by removing setShellFailures clearTimeout and watching "sent no document" appear.'). doctrine(a_simulated_fix_must_be_the_fix_you_named,'The sibling of an_injected_fault_must_be_the_fault_you_named, one level up, and it fails in the opposite direction: an injected FAULT that is not the one named proves a check that does not fire; a simulated FIX that is not the one named proves a BUG THAT DOES NOT EXIST. Worked example: the harness agent measured the 8s watchdog overwrite honestly, on a tree predating PR #21, then reported it "survives improvise-taxx, confirmed by simulating the opened flag". PR #21 is TWO changes -- the flag AND routing both close arms through setShellFailure, which clears the timer. Simulating half of it reproduced the bug and read exactly like a confirmation. improvise-xps8 was filed on that report, propagated into repo-map gotcha 48 and a spec comment, and was closed INVALID. The repair is not a deletion: the property is now executed by a test that outlasts HANDSHAKE_TIMEOUT_MS, proved discriminating by removing setShellFailures clearTimeout and watching "sent no document" appear.').
@@ -200,7 +201,8 @@ decision('improvise-dz8',filter_persists_in_the_format,'added \"filter\" to axis
decision('improvise-i34',no_indexeddb_fallback,'user decision 2026-08-31: skip the IndexedDB fallback unless recent Safari needs it. It does not -- Safari 16.4+ has synchronous access handles -- so the fallback is out of scope and the async question it would have forced does not arise.'). decision('improvise-i34',no_indexeddb_fallback,'user decision 2026-08-31: skip the IndexedDB fallback unless recent Safari needs it. It does not -- Safari 16.4+ has synchronous access handles -- so the fallback is out of scope and the async question it would have forced does not arise.').
decision('improvise-i34',delete_is_deferred,'removeEntry is async and the trait is sync, so delete/1 drops the name from the index and queues the removal. A delete is therefore visible to read and list at once and lands in OPFS when commit_deletes is awaited. Invisible to a single worker; the alternative was making the whole trait async for one operation.'). decision('improvise-i34',delete_is_deferred,'removeEntry is async and the trait is sync, so delete/1 drops the name from the index and queues the removal. A delete is therefore visible to read and list at once and lands in OPFS when commit_deletes is awaited. Invisible to a single worker; the alternative was making the whole trait async for one operation.').
decision('improvise-cqi',command_set_derived_not_invented,'the client->server half of Command is exactly the set of EffectKind::ViewOnly effects whose target is the portable slice. Effects touching host-local state (wizard, drill_state, storage, layout cache) are absent by construction -- improvise-h0b had already moved those off ViewState, so the eviction did the enumeration.'). decision('improvise-cqi',command_set_derived_not_invented,'the client->server half of Command is exactly the set of EffectKind::ViewOnly effects whose target is the portable slice. Effects touching host-local state (wizard, drill_state, storage, layout cache) are absent by construction -- improvise-h0b had already moved those off ViewState, so the eviction did the enumeration.').
decision('improvise-cqi',view_state_carries_its_own_active_view,'reduce_view runs on a client with no Workbook, and cursors is keyed by view name, so ViewState needs its own active_view. Kept identical to Workbook::active_view by App::switch_to_view and App::install_workbook; whether they MAY diverge is improvise-g0f.'). decision('improvise-cqi',view_state_carries_its_own_active_view,'reduce_view runs on a client with no Workbook, and cursors is keyed by view name, so ViewState needs its own active_view. Kept identical to Workbook::active_view until improvise-jytm, which answered improvise-g0f: they MAY diverge. Workbook::active_view is now the document DEFAULT (what a fresh client opens, what .improv writes as Initial View) and ViewState::active_view is authoritative per client.').
decision('improvise-jytm',active_view_is_split_default_from_client,'the model is shared and the UI is not (user, 2026-09-19), so which view you are looking at is UI. Four things worth keeping. The split is made to AGREE WITH EffectKind rather than by a new mechanism: SwitchView/ViewBack/ViewForward became ViewOnly, which took the latter two off ModelCommand for free, because ModelCommand is DERIVED from the Model effects. A switch is therefore invisible to Dirty, so ClientSession OBSERVES its own view move (projected_view) rather than being told -- the cursor_epoch shape. App::resolve_view is the ONE answer to what a view name names in this document; a second copy of that fallback was the first thing review found. And the split CREATES a case it must then close: a DeleteView can take away the view another client is on, answered by observation in adopt_deleted_views rather than by a table of which commands remove views. Follow mode is deliberately NOT foreclosed: per-session UI state stays addressable, so a follow relationship can later be one session DERIVING its ViewState from another.').
decision('improvise-sef',retroactive_ticket,'The three kb commits landed with no beads issue at all. Filed and closed 2026-09-19 during a commit/tracker reconciliation rather than left untracked, because the CI job they add is a durable capability someone will later ask the provenance of. Every other commit back to cc3889d already named a ticket that is closed with a close_reason.'). decision('improvise-sef',retroactive_ticket,'The three kb commits landed with no beads issue at all. Filed and closed 2026-09-19 during a commit/tracker reconciliation rather than left untracked, because the CI job they add is a durable capability someone will later ask the provenance of. Every other commit back to cc3889d already named a ticket that is closed with a close_reason.').
decision('backlog-groom-2026-09-19',render_cache_name_collision,'improvise-cqi landed RenderCache as a flat grid-only wire type, while six open migration tickets (35e, n10, pca, 764, jb3, edp) were written against a fat per-widget cache of the same name. Two objects, one name, and nothing in the tree complains until someone writes from_app. Filed improvise-rkeq to decide the shape and made it block 35e rather than silently rewriting six tickets to match whichever shape a groomer preferred.'). decision('backlog-groom-2026-09-19',render_cache_name_collision,'improvise-cqi landed RenderCache as a flat grid-only wire type, while six open migration tickets (35e, n10, pca, 764, jb3, edp) were written against a fat per-widget cache of the same name. Two objects, one name, and nothing in the tree complains until someone writes from_app. Filed improvise-rkeq to decide the shape and made it block 35e rather than silently rewriting six tickets to match whichever shape a groomer preferred.').
decision('backlog-groom-2026-09-19',retroactive_tickets_for_untracked_merges,'PR #7 (cloud-session tooling) landed 18 commits with no beads issue, the same gap the kb commits had. Filed improvise-3qz2 closed. Rule being followed: work that adds a durable capability -- a CI job, a hook, a script someone will later ask the provenance of -- gets a ticket even after the fact; a chore commit does not.'). decision('backlog-groom-2026-09-19',retroactive_tickets_for_untracked_merges,'PR #7 (cloud-session tooling) landed 18 commits with no beads issue, the same gap the kb commits had. Filed improvise-3qz2 closed. Rule being followed: work that adds a durable capability -- a CI job, a hook, a script someone will later ask the provenance of -- gets a ticket even after the fact; a chore commit does not.').
@@ -379,7 +381,7 @@ kb_issue_found(mcp_lenience,'the prolog MCP server accepted \`:- discontiguous f
kb_issue_found(static_str_blocks_deserialize,'a borrowed &\'static str field makes Deserialize unimplementable, and rustc reports it on every ENCLOSING enum variant rather than at the offending field -- five near-identical \"lifetime may not live long enough\" errors pointing at MinibufferConfig, none naming buffer_key. Read past the reported site to the borrowed field.'). kb_issue_found(static_str_blocks_deserialize,'a borrowed &\'static str field makes Deserialize unimplementable, and rustc reports it on every ENCLOSING enum variant rather than at the offending field -- five near-identical \"lifetime may not live long enough\" errors pointing at MinibufferConfig, none naming buffer_key. Read past the reported site to the borrowed field.').
kb_issue_found(ticket_premise_false_8uz,'improvise-8uz asserted selected/row_offset/col_offset were \"PERSISTED in .improv\". They were not: improv.pest has no cursor production and format_md emitted none. The claim shaped the ticket\'s whole option set, so it was checked with a test before any of the options was weighed.'). kb_issue_found(ticket_premise_false_8uz,'improvise-8uz asserted selected/row_offset/col_offset were \"PERSISTED in .improv\". They were not: improv.pest has no cursor production and format_md emitted none. The claim shaped the ticket\'s whole option set, so it was checked with a test before any of the options was weighed.').
kb_issue_found(ticket_premise_false_rml,'improvise-rml called category_panel.rs an \"exact copy\" of tile_bar.rs. It was not: the colors were identical but the labels differed (\"Row ↕\" vs \"Row\", \"None ∅\" vs \"·\"). Only the color table was true duplication; the four label sets are four deliberate vocabularies and were preserved as four methods.'). kb_issue_found(ticket_premise_false_rml,'improvise-rml called category_panel.rs an \"exact copy\" of tile_bar.rs. It was not: the colors were identical but the labels differed (\"Row ↕\" vs \"Row\", \"None ∅\" vs \"·\"). Only the color table was true duplication; the four label sets are four deliberate vocabularies and were preserved as four methods.').
kb_issue_found(duplicated_active_view,'adding ViewState::active_view created a second copy of \"which view is active\". The improvise-8uz characterization test caught the desync immediately because it called workbook.switch_view directly. Two writers only (switch_to_view, install_workbook), guarded by the_two_active_view_names_never_diverge.'). kb_issue_found(duplicated_active_view,'adding ViewState::active_view created a second copy of "which view is active". The improvise-8uz characterization test caught the desync immediately because it called workbook.switch_view directly. Held in lockstep by two writers until improvise-jytm, which made the divergence deliberate: the guard test the_two_active_view_names_never_diverge was REPLACED, not deleted, by the_documents_default_view_outlives_this_clients_switches and a_fresh_session_opens_the_documents_default_view -- a deliberate split with no guard is how one silently regresses into the other.').
functional(invariant/2). functional(invariant/2).
functional(location/3). functional(location/3).
functional(doctrine/2). functional(doctrine/2).