// 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 }