Phase 12 + 13: collocation coach + vocabulary garden

Phase 12 — collocation coach: a third suggestion family for gentle
"natives usually say…" hints on non-native word pairings, reusing the
existing runPass/pendingScope/rail machinery.
- llm/collocation.go (RunCollocation, 25s floor, reuses ParseCheckpoint)
  + collocationSystemPrompt/CollocationMessages (warm, Mandarin gloss,
  defers grammar/spelling to the grammar family)
- migration 0005 rebuilds the suggestions table to extend the type CHECK
  (SQLite can't ALTER a CHECK)
- collocationScope + CollocationLimit + POST /{id}/collocation
- fix: grammarScope was `type != 'voice'` and would wipe the new
  collocation flags; now `type NOT IN ('voice','collocation')`
- frontend: --color-blossom, "Make it sound natural 🌸" pill,
  collocating/runCollocation in useCheckpoint, StatusBar dot

Phase 13 — vocabulary garden: capture looked-up words and surface them
for gentle spaced repetition.
- new internal/vocab package: migration 0006 (vocab_words, SM-2-lite
  columns, doc_id ON DELETE SET NULL, UNIQUE(user_id,word)),
  scheduler.go (Leitner ladder 1/3/7/16/35 then geometric; gentle
  "again", no streak-shaming), handlers (capture-upsert/list/due/
  review/delete, owner-scoped, SQLite-side datetime math)
- auto-capture on word lookup (dictionary-known words only, captures
  the surrounding sentence + doc_id) + 🤍/💚 toggle on WordCard
- GardenPanel: blossom grid (bloom by reps), flashcard review (sentence
  blanked, flip, again/good/easy, recognition↔production), sleepy-kitten
  footer; opened from a global 🌷 header button

Tests: TestCollocationPassCoexists, vocab scheduler + handlers, db CHECK
extended. go build/vet/test + tsc + vite + vitest (51/51) clean;
migration verified against a copy of the live DB; live backend smoke
walked the full vocab lifecycle + the warm-502 collocation path.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 15:50:25 -07:00
parent 20442d1356
commit 8aa437ec82
23 changed files with 1614 additions and 58 deletions

View File

@@ -0,0 +1,66 @@
package vocab
import "testing"
// TestLadderProgression walks a card up the Leitner ladder on repeated "good"
// reviews: 1 → 3 → 7 → 16 → 35 days, then geometric growth by ease.
func TestLadderProgression(t *testing.T) {
s := State{Ease: startEase}
want := []int{1, 3, 7, 16, 35}
for i, w := range want {
s = s.next(GradeGood)
if s.Interval != w {
t.Fatalf("rung %d: interval=%d, want %d", i, s.Interval, w)
}
if s.Reps != i+1 {
t.Fatalf("rung %d: reps=%d, want %d", i, s.Reps, i+1)
}
}
// Past the ladder it grows by ease (35 * 2.5 = 87.5 → 88).
s = s.next(GradeGood)
if s.Interval != 88 {
t.Fatalf("post-ladder interval=%d, want 88", s.Interval)
}
}
// TestAgainStepsBackGently proves a lapse resets to a 1-day interval and counts
// a lapse, but only nudges ease down (never below the floor) — no harsh wipe.
func TestAgainStepsBackGently(t *testing.T) {
s := State{Reps: 4, Interval: 35, Ease: startEase}
s = s.next(GradeAgain)
if s.Interval != 1 {
t.Fatalf("again interval=%d, want 1", s.Interval)
}
if s.Reps != 0 {
t.Fatalf("again reps=%d, want 0", s.Reps)
}
if s.Lapses != 1 {
t.Fatalf("again lapses=%d, want 1", s.Lapses)
}
if s.Ease != startEase+easeAgainDelta {
t.Fatalf("again ease=%v, want %v", s.Ease, startEase+easeAgainDelta)
}
// Ease never drops below the floor no matter how many lapses.
low := State{Ease: minEase}
for i := 0; i < 5; i++ {
low = low.next(GradeAgain)
}
if low.Ease < minEase {
t.Fatalf("ease fell below floor: %v", low.Ease)
}
}
// TestEasyAdvancesFurther proves "easy" both bumps ease and lands a longer
// interval than a plain "good" at the same rung.
func TestEasyAdvancesFurther(t *testing.T) {
base := State{Reps: 2, Interval: 7, Ease: startEase}
good := base.next(GradeGood)
easy := base.next(GradeEasy)
if easy.Interval <= good.Interval {
t.Fatalf("easy interval %d should exceed good interval %d", easy.Interval, good.Interval)
}
if easy.Ease <= startEase {
t.Fatalf("easy should raise ease, got %v", easy.Ease)
}
}