Stop regenerating the world on every check

A card vanishing and coming back seconds later, with different words, was
never about latency: every pass deleted its whole family and re-inserted
it, so each round minted new row ids. The rail keys on suggestion.id, so a
full remount was guaranteed — new id, new created_at (hence the re-fired
chime), and a fresh explanation from a model that re-reasons every time it
is asked. One unchanged mistake carried three different explanations in a
single sitting.

Passes now reconcile instead of replace. A re-proposed edit keeps its row:
its id, its created_at, and the wording she has already read. And the
grammar checkpoint stops asking about sentences nobody touched — the
document is split into hashed sentences, checked_chunks records which ones
a family has read, and only the difference is sent. When nothing changed
it doesn't call the model at all, and doesn't spend its rate-limit slot on
having done nothing.

The tone is part of a sentence's identity: cached advice was written for
the old register, so switching doc type re-reads every line.

replaceMechanics reconciles too, which mattered more than expected — the
rule pack fires 250 ms after a keystroke, so it was re-minting every local
card's id several times a sentence.

Only the grammar checkpoint is chunked. Voice is a property of the whole
document, and the collocation coach is a button she pressed asking for a
fresh read.

No client change was needed; stable ids were the whole of it.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
prosolis
2026-07-27 22:46:12 -07:00
parent c33de1175b
commit 10e8aef86c
8 changed files with 1009 additions and 79 deletions
+83
View File
@@ -143,6 +143,81 @@ instability, doubles the pause after each accept, and burns qwen3.5 tokens.
or position of any other card; re-check traffic after a one-sentence edit
contains only that sentence's chunk; explanations are stable across rounds.
### 2 — DONE (fourth session). Server-side; the client needed nothing.
Both halves shipped, and they turned out to be one idea. The root cause of
the vanish/reappear was structural: **every pass deleted its whole family
and re-inserted it**, so each round minted new row ids. The rail keys its
cards on `suggestion.id`, so a full remount was guaranteed — new id, new
`created_at` (hence the re-fired arrival chime), and a freshly-worded
explanation from a model that re-reasons every time it's asked.
**Implemented:**
- `chunk.go` — splits the document into sentences and hashes each. Newlines
always break; ASCII terminators need trailing whitespace (so `3.50` and
`Ms.` stay whole); `。!?` break outright, since Chinese runs sentences
together with no space and she writes both languages in one document. The
hash normalizes quotes and whitespace runs through the existing
`normalizeForDedup`, so the editor's constant quote rewriting and a
reflowed paragraph cost nothing. Identity is the hash, not the position —
insert a paragraph at the top and every sentence below keeps its cards.
- `reconcile.go` — passes now *reconcile* rather than replace. A row on a
sentence this pass didn't ask about is kept untouched; a row whose
sentence is gone is dropped; a row on a sentence that was re-read survives
only if the model proposed the same edit again, keeping its id,
`created_at` and its **original explanation**. Re-proposals are matched on
`(original, replacement)` normalized — not on type, so a re-labelled edit
keeps the label she's already reading.
- `checked_chunks` (migration `0014`) records which sentences a family has
read. The grammar checkpoint asks only about the difference. **When
nothing changed it doesn't call the model at all** — and doesn't consume
its rate-limit slot, so an idle check can't throttle the next real edit.
- The tone is folded into a sentence's hash, so switching doc type still
re-reads every line: the same sentence gets different advice as an
academic essay than as a journal entry, and cached advice was written for
the old register.
- `replaceMechanics` reconciles too. This mattered more than expected: the
rule pack fires 250 ms after a keystroke (item 3b), so it was re-minting
every local card's id several times a sentence.
**Deliberately not done:**
- Only the grammar checkpoint is chunked. Voice is a property of the
document as a whole — a sentence isn't inconsistent with itself — and the
collocation coach is a button she presses asking for a fresh read. Both
still read everything, but both now reconcile, so they keep their ids.
- No client change. With stable ids the existing code already does what the
item asked for: the rail keeps its card DOM, an expanded card survives a
re-check, and the chime (which keys on id) stops re-firing for advice she
is already reading. The one-card optimistic removal on accept was already
there.
**Sentences the model can't be trusted to have read.** Two guards the plan
didn't anticipate, both found while writing the tests: a finding is
attributed to a sentence the model was *actually shown* before falling back
to the whole document (a short span like "the the" can occur twice, and
crediting the cached copy would drop it); and a cached row whose quoted span
no longer matches byte-for-byte is dropped *and* its sentence re-opened,
rather than caching advice the frontend can't anchor.
**Verified on the running binary**, not just in tests — per the handoff's
own advice. Against a stand-in model server: three checks over a two-
sentence document, editing only the second. The model received exactly
`She goes to market yesterday.` and never saw the first sentence; the
untouched card kept its id and its first explanation across all three
rounds; the fixed sentence's card was dropped; the idle re-check made zero
model calls. Mechanics identity confirmed the same way (a finding kept its
row id while its span moved).
Coverage: `chunk_test.go` (splitting, CJK, decimals, cosmetic churn) and
`stability_test.go` (untouched cards keep id + explanation, unchanged
document skips the model, deleted sentence drops its card, tone change
re-opens everything, mechanics rows keep identity). Two existing tests
changed contract deliberately — `TestFickleEditsSuppressed` and
`TestCollocationPassCoexists` both re-checked a document nobody had edited,
which is now a no-op; they edit the text between passes, as she always does.
## 3. Perceived latency: mask the LLM round-trip
Measured ~815 s from typing-stop to cards, with only a small "Checking…"
@@ -338,6 +413,14 @@ reproducible) and item 5's original premise (re-scoped, much cheaper now).
*(Third session: item 3b done — see the subsection under item 3. Item 3's
incremental-surfacing half remains. Untouched: 2, 4, 6, 7, 8.)*
*(Fourth session: item 2 done — see the subsection under it. **Neither 3b
nor 2 is deployed yet**: both sit on unmerged topic branches
(`feat/instant-local-rules`, then `feat/stable-suggestions` stacked on it)
and `main` is still at `ba06d90`. Untouched: 4, 6, 7, 8. Item 3's
incremental-surfacing half is now cheap — the chunking it was waiting on
exists — but it needs streaming, which the current `/check` shape doesn't
do.)*
**Suggested next:** item 3b, the instant local rules layer — but it is
**largely already built, in `main`**, and the item as written doesn't know
that. Before writing any rules engine, read: