Correctness: - useCheckpoint: clear the busy flag unconditionally so overlapping explicit passes don't strand each other's spinner; explicit actions now also supersede a queued auto-check and clear the stranded "checking" dot. Deduped runVoice/runCollocation into runExplicitPass. - EditorCore: token-guard the auto-capture so a late capture can't resurrect a removed word; move toggleSaveWord side effects out of the setWordInfo updater (StrictMode double-fire); fix sentenceAround offset desync via shared exampleAt (textBetween + parentOffset, single resolve); optimistic saved state so the heart doesn't flash unsaved. - vocab capture: normalize word to lower+trim (matches lexicon) so "Apple"/"apple" don't make duplicate cards; check rows.Err() in queryList. - GardenPanel: Promise.allSettled so a /due failure doesn't blank the whole garden; scrim click during review ends the review (mirrors Esc); gate footer on !error; O(1) due lookup via a Set. Features requested in review: - Definition-only review card: add vocab_words.definition (migration 0007) as an English fallback meaning, threaded through capture and used by review/garden when there's no Chinese gloss. - Scheduler caps: maxEase 3.0 + maxInterval 365d so "easy" growth can't push a word out of rotation for years. Tests: TestCaptureCaseInsensitive, TestCaptureStoresDefinitionFallback, TestCapsBoundGrowth. go build/vet/test, tsc, vitest 51/51, vite build clean. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
115 lines
3.5 KiB
Go
115 lines
3.5 KiB
Go
// Package vocab implements the vocabulary garden: it captures words the writer
|
|
// looks up and schedules them for gentle spaced-repetition review. The scheduler
|
|
// is a deliberately forgiving SM-2-lite / Leitner hybrid — no streaks to break,
|
|
// no harsh resets beyond a single step back — because this is a confidence
|
|
// builder, not a drill sergeant.
|
|
package vocab
|
|
|
|
import "math"
|
|
|
|
// Grade is the writer's self-assessment after seeing a flashcard's answer.
|
|
type Grade string
|
|
|
|
const (
|
|
GradeAgain Grade = "again" // didn't recall it — show again soon
|
|
GradeGood Grade = "good" // recalled it — advance one rung
|
|
GradeEasy Grade = "easy" // knew it instantly — advance a little further
|
|
)
|
|
|
|
// ladder is the Leitner interval ladder in days for the first several successful
|
|
// reviews: 1 → 3 → 7 → 16 → 35. Beyond the ladder, intervals grow by the card's
|
|
// ease factor so well-known words drift far into the future.
|
|
var ladder = []int{1, 3, 7, 16, 35}
|
|
|
|
const (
|
|
minEase = 1.3
|
|
maxEase = 3.0 // ease ceiling — keeps "easy" from compounding growth without bound
|
|
startEase = 2.5
|
|
easyBonus = 1.3 // multiplier applied on top of a "good" step for "easy"
|
|
easeAgainDelta = -0.20 // ease nudged down on a lapse (still floored at minEase)
|
|
easeEasyDelta = 0.15 // ease nudged up when a card feels easy
|
|
|
|
// maxInterval caps how far a card can drift into the future. Even a word
|
|
// graded "easy" many times resurfaces about once a year, so nothing silently
|
|
// leaves the garden forever (the whole point is to keep words in gentle
|
|
// rotation, not to retire them).
|
|
maxInterval = 365
|
|
)
|
|
|
|
// State is the spaced-repetition state of one card. It mirrors the scheduling
|
|
// columns on vocab_words so a review is "load State → next(grade) → persist".
|
|
type State struct {
|
|
Reps int
|
|
Interval int // days until the next review
|
|
Ease float64
|
|
Lapses int
|
|
}
|
|
|
|
// next returns the card's state after a review with the given grade. It never
|
|
// mutates the receiver. "again" steps the card back to a 1-day interval and
|
|
// counts a lapse (but only nudges ease down, never wipes progress harshly);
|
|
// "good" advances one rung of the ladder; "easy" advances a rung and a bit more.
|
|
func (s State) next(grade Grade) State {
|
|
ease := s.Ease
|
|
if ease == 0 {
|
|
ease = startEase
|
|
}
|
|
|
|
switch grade {
|
|
case GradeAgain:
|
|
return State{
|
|
Reps: 0,
|
|
Interval: 1,
|
|
Ease: clampEase(ease + easeAgainDelta),
|
|
Lapses: s.Lapses + 1,
|
|
}
|
|
case GradeEasy:
|
|
ease = clampEase(ease + easeEasyDelta)
|
|
reps := s.Reps + 1
|
|
return State{
|
|
Reps: reps,
|
|
Interval: clampInterval(int(math.Round(float64(goodInterval(reps, s.Interval, ease)) * easyBonus))),
|
|
Ease: ease,
|
|
Lapses: s.Lapses,
|
|
}
|
|
default: // GradeGood
|
|
reps := s.Reps + 1
|
|
return State{
|
|
Reps: reps,
|
|
Interval: clampInterval(goodInterval(reps, s.Interval, ease)),
|
|
Ease: ease,
|
|
Lapses: s.Lapses,
|
|
}
|
|
}
|
|
}
|
|
|
|
// goodInterval returns the day-interval for a successful review: the explicit
|
|
// ladder while it lasts, then geometric growth by ease once past it.
|
|
func goodInterval(reps, prevInterval int, ease float64) int {
|
|
if reps >= 1 && reps <= len(ladder) {
|
|
return ladder[reps-1]
|
|
}
|
|
prev := prevInterval
|
|
if prev < ladder[len(ladder)-1] {
|
|
prev = ladder[len(ladder)-1]
|
|
}
|
|
return int(math.Round(float64(prev) * ease))
|
|
}
|
|
|
|
func clampEase(e float64) float64 {
|
|
if e < minEase {
|
|
return minEase
|
|
}
|
|
if e > maxEase {
|
|
return maxEase
|
|
}
|
|
return e
|
|
}
|
|
|
|
func clampInterval(d int) int {
|
|
if d > maxInterval {
|
|
return maxInterval
|
|
}
|
|
return d
|
|
}
|