Finish Phase 22: the half of Petal that works with the tunnel down

Grammar lite, the false-friend list, the daily invitation and the offline
miscollocations — the four remaining §5–§6 items, all client-side and all
alive on a box that cannot reach the model.

The offline collocations forced a schema change. `type` had been doubling
as the answer to "which engine found this" — `mechanics` meant offline —
and that stops being true the moment an offline rule proposes a
collocation. Migration 0013 adds `source` (llm | local) and every pass now
scopes its DELETE by engine; without it the coach silently wiped every
offline chunk on the page. Existing rows backfill by type, so a pre-0013
collocation row is claimed as the coach's, which it was: the offline list
did not exist yet.

The rule pack is hand-curated rather than mined, and the entries left out
are the point — `married with` is wrong until "married with children",
`arrive to` wants at or in depending on the noun. A pack running on every
keystroke must not correct correct writing.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
prosolis
2026-07-27 15:05:55 -07:00
parent e9b8595456
commit 1bbc8fc8d3
20 changed files with 1678 additions and 50 deletions
+203
View File
@@ -0,0 +1,203 @@
package suggestions
import (
"encoding/json"
"net/http"
"testing"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// The offline rule pack and the LLM now share the collocation family, which is
// the point: the writer sees one rail and is never told which engine spoke. What
// makes that safe is `source` — each pass replaces only its own rows. These tests
// pin the two ways that could go wrong, both of which the old type-scoped DELETEs
// would have hit.
// pendingOfType counts the pending rows of one family in a response body.
func pendingOfType(got []db.Suggestion, typ string) []db.Suggestion {
var out []db.Suggestion
for _, s := range got {
if s.Type == typ {
out = append(out, s)
}
}
return out
}
// TestOfflineCollocationFilesAsCollocation proves a miscollocation the rule pack
// found is stored in the collocation family (so accepting it plants a garden
// card, exactly as the coach's would) while still being marked as locally found.
func TestOfflineCollocationFilesAsCollocation(t *testing.T) {
srv, docID, _ := newTestServer(t, &stubClient{response: `{"suggestions":[]}`})
got := postMechanics(t, srv, docID, `[
{"from":0,"to":13,"original":"do a decision","replacement":"make a decision","explanation":"pairing","type":"collocation"},
{"from":20,"to":27,"original":"the the","replacement":"the","explanation":"doubled word","type":"mechanics"}
]`)
if len(got) != 2 {
t.Fatalf("want both findings, got %+v", got)
}
coll := pendingOfType(got, db.SuggestionTypeCollocation)
if len(coll) != 1 {
t.Fatalf("want 1 collocation, got %+v", got)
}
if coll[0].Source != db.SuggestionSourceLocal {
t.Errorf("offline finding should be source=local, got %q", coll[0].Source)
}
if mech := pendingOfType(got, db.SuggestionTypeMechanics); len(mech) != 1 {
t.Fatalf("want 1 mechanics finding, got %+v", got)
}
}
// TestUnknownLocalTypeFallsBackToMechanics: a family the offline pass isn't
// allowed to claim (or an older client sending none at all) must land in
// mechanics. Otherwise a stray label would smuggle a row into an LLM family,
// where nothing would ever replace it.
func TestUnknownLocalTypeFallsBackToMechanics(t *testing.T) {
srv, docID, _ := newTestServer(t, &stubClient{response: `{"suggestions":[]}`})
got := postMechanics(t, srv, docID, `[
{"from":0,"to":5,"original":"aaaaa","replacement":"bbbbb","explanation":"x","type":"voice"},
{"from":6,"to":11,"original":"ccccc","replacement":"ddddd","explanation":"y"}
]`)
if len(got) != 2 {
t.Fatalf("want 2 findings, got %+v", got)
}
for _, s := range got {
if s.Type != db.SuggestionTypeMechanics {
t.Errorf("offline finding claimed family %q; only mechanics/collocation are allowed", s.Type)
}
}
}
// TestCoachDoesNotWipeOfflineCollocations is the collision the source column
// exists for: the LLM collocation pass replaces the collocation family, and the
// rule pack's share of that family has to survive it. Before `source`, running
// the coach silently deleted every offline chunk on the page.
func TestCoachDoesNotWipeOfflineCollocations(t *testing.T) {
client := &stubClient{response: `{"suggestions":[
{"original":"apple","replacement":"an apple","explanation":"article","type":"collocation"}
]}`}
srv, docID, _ := newTestServer(t, client)
// The seeded doc is "I has two apple." — the coach's flag anchors on "apple"
// at [10,15], so the offline finding is given a span well clear of it. Two
// findings fighting over the same characters is a different rule (see
// TestOfflineCardWinsSpanCollision); this test is about the DELETE.
postMechanics(t, srv, docID, `[
{"from":0,"to":5,"original":"do a decision","replacement":"make a decision","explanation":"pairing","type":"collocation"}
]`)
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/collocation", "")
if rec.Code != http.StatusOK {
t.Fatalf("collocation pass: code=%d body=%s", rec.Code, rec.Body)
}
var got []db.Suggestion
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
var local, llm int
for _, s := range pendingOfType(got, db.SuggestionTypeCollocation) {
if s.Source == db.SuggestionSourceLocal {
local++
} else {
llm++
}
}
if local != 1 {
t.Errorf("the coach wiped the offline collocation: local=%d, got %+v", local, got)
}
if llm != 1 {
t.Errorf("want the coach's own flag alongside it: llm=%d, got %+v", llm, got)
}
}
// TestOfflinePassReplacesItsOwnCollocations is the mirror: the rule pack
// recomputes the whole document every run, so a chunk the current text no longer
// warrants must go — and the coach's flags must stay. Scoping the offline DELETE
// by type instead of source would have stranded the first row forever.
func TestOfflinePassReplacesItsOwnCollocations(t *testing.T) {
client := &stubClient{response: `{"suggestions":[
{"original":"apple","replacement":"an apple","explanation":"article","type":"collocation"}
]}`}
srv, docID, _ := newTestServer(t, client)
// A coach flag, then an offline chunk, then a rerun that no longer finds it.
do(t, srv, http.MethodPost, "/docs/"+docID+"/collocation", "")
postMechanics(t, srv, docID, `[
{"from":0,"to":13,"original":"do a decision","replacement":"make a decision","explanation":"pairing","type":"collocation"}
]`)
got := postMechanics(t, srv, docID, `[]`)
for _, s := range got {
if s.Source == db.SuggestionSourceLocal {
t.Errorf("stale offline finding survived a recompute: %+v", s)
}
}
if len(pendingOfType(got, db.SuggestionTypeCollocation)) != 1 {
t.Fatalf("the coach's own flag should be untouched, got %+v", got)
}
}
// TestOfflineCollocationPlantsOnAccept closes the loop the family split was for:
// a chunk the rule pack found, accepted, becomes a vocabulary-garden card — with
// no model involved anywhere in the path.
func TestOfflineCollocationPlantsOnAccept(t *testing.T) {
srv, docID, h := newTestServer(t, &stubClient{response: `{"suggestions":[]}`})
if _, err := h.DB.Exec(
`UPDATE documents SET content_text = ? WHERE id = ?`,
"I had to do a decision about the job.", docID,
); err != nil {
t.Fatalf("set content: %v", err)
}
got := postMechanics(t, srv, docID, `[
{"from":9,"to":22,"original":"do a decision","replacement":"make a decision","explanation":"pairing","type":"collocation"}
]`)
if len(got) != 1 {
t.Fatalf("want the offline chunk, got %+v", got)
}
if rec := do(t, srv, http.MethodPost, "/suggestions/"+got[0].ID+"/accept", ""); rec.Code != http.StatusNoContent {
t.Fatalf("accept: code=%d body=%s", rec.Code, rec.Body)
}
cards := gardenCards(t, h)
if len(cards) != 1 || cards[0].word != "make a decision" {
t.Fatalf("want a planted phrase card, got %+v", cards)
}
// The example is the corrected sentence — the phrasing she kept, not the one
// she just left behind.
if cards[0].example != "I had to make a decision about the job." {
t.Errorf("example should be the corrected sentence, got %q", cards[0].example)
}
}
// TestOfflineCardWinsSpanCollision: the tiebreak is by engine, not by family. An
// offline miscollocation has an exact span; the coach's overlapping flag is only
// advisory, so it is the one that goes.
func TestOfflineCardWinsSpanCollision(t *testing.T) {
client := &stubClient{response: `{"suggestions":[
{"original":"do a decision about","replacement":"decide about","explanation":"wordy","type":"collocation"}
]}`}
srv, docID, h := newTestServer(t, client)
if _, err := h.DB.Exec(
`UPDATE documents SET content_text = ? WHERE id = ?`,
"I had to do a decision about the job.", docID,
); err != nil {
t.Fatalf("set content: %v", err)
}
do(t, srv, http.MethodPost, "/docs/"+docID+"/collocation", "")
got := postMechanics(t, srv, docID, `[
{"from":9,"to":22,"original":"do a decision","replacement":"make a decision","explanation":"pairing","type":"collocation"}
]`)
if len(got) != 1 {
t.Fatalf("want the overlapping coach flag dropped, got %+v", got)
}
if got[0].Source != db.SuggestionSourceLocal {
t.Errorf("the exact offline card should own the span, got %+v", got[0])
}
}