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
+77
View File
@@ -150,3 +150,80 @@ func TestResolvedAtBackfill(t *testing.T) {
t.Errorf("pending row got resolved_at = %v, want NULL — nothing was decided", *pending)
}
}
// TestSuggestionSourceBackfill runs migration 0013 against a database that
// predates it — the shape the live box is actually in. `source` is the column
// that lets the offline rule pack and the model share the collocation family
// without deleting each other's rows, and it can only do that if the existing
// rows are labelled correctly on the way in: everything the old deterministic
// pass wrote is local, and everything else came from a model.
func TestSuggestionSourceBackfill(t *testing.T) {
path := filepath.Join(t.TempDir(), "old.db")
d, err := Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
// Rewind to the state before 0013.
if _, err := d.Exec(`ALTER TABLE suggestions DROP COLUMN source`); err != nil {
t.Fatalf("rewind schema: %v", err)
}
if _, err := d.Exec(`DELETE FROM schema_migrations WHERE name = '0013_suggestion_source'`); err != nil {
t.Fatalf("rewind migration record: %v", err)
}
if _, err := d.Exec(`INSERT INTO documents (id, user_id) VALUES ('d1', ?)`, LocalUserID); err != nil {
t.Fatalf("insert document: %v", err)
}
for _, s := range []struct{ id, typ string }{
{"s-mech", SuggestionTypeMechanics},
{"s-gram", SuggestionTypeGrammar},
{"s-coll", SuggestionTypeCollocation},
} {
if _, err := d.Exec(
`INSERT INTO suggestions (id, doc_id, from_pos, to_pos, original, replacement, explanation, type)
VALUES (?, 'd1', 0, 3, 'teh', 'the', 'x', ?)`,
s.id, s.typ,
); err != nil {
t.Fatalf("seed %s: %v", s.id, err)
}
}
d.Close()
d2, err := Open(path)
if err != nil {
t.Fatalf("reopen (migrate): %v", err)
}
defer d2.Close()
// A pre-0013 collocation row can only have come from the coach — the offline
// miscollocation list did not exist yet — so it must NOT be claimed as local.
for id, want := range map[string]string{
"s-mech": SuggestionSourceLocal,
"s-gram": SuggestionSourceLLM,
"s-coll": SuggestionSourceLLM,
} {
var got string
if err := d2.QueryRow(`SELECT source FROM suggestions WHERE id = ?`, id).Scan(&got); err != nil {
t.Fatalf("read %s: %v", id, err)
}
if got != want {
t.Errorf("%s: source = %q, want %q", id, got, want)
}
}
// And a row written after the migration defaults to the model, so a code path
// that forgets to name a source can never silently claim to be offline.
if _, err := d2.Exec(
`INSERT INTO suggestions (id, doc_id, from_pos, to_pos, original, replacement, explanation, type)
VALUES ('s-new', 'd1', 0, 3, 'teh', 'the', 'x', 'grammar')`,
); err != nil {
t.Fatalf("insert new row: %v", err)
}
var fresh string
if err := d2.QueryRow(`SELECT source FROM suggestions WHERE id = 's-new'`).Scan(&fresh); err != nil {
t.Fatalf("read new row: %v", err)
}
if fresh != SuggestionSourceLLM {
t.Errorf("default source = %q, want %q", fresh, SuggestionSourceLLM)
}
}