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:
66
internal/vocab/scheduler_test.go
Normal file
66
internal/vocab/scheduler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user