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:
@@ -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 ~8–15 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:
|
||||
|
||||
Reference in New Issue
Block a user