kanji-buddy
SRS engine
Spaced repetition, unlocking, and grading an answer. The three pieces that make it a trainer rather than a dictionary.
Stages
An item sits at a stage from 0 to 9. Answer correctly and it moves up one, waiting longer each time; answer wrongly and it falls back. Reach stage 9 and it is burned — learned, never asked again.
| Stage | Name | Then wait |
|---|---|---|
| 0 | Locked / lesson | — |
| 1–4 | Apprentice | 4h · 8h · 23h · 47h |
| 5–6 | Guru | 1 week · 2 weeks |
| 7 | Master | 1 month |
| 8 | Enlightened | 4 months |
| 9 | Burned | never again |
Those intervals are read from the spaced_repetition_systems table, not hard-coded. There are two systems — the standard one and an accelerated one for levels 1–2 — and the engine simply uses whichever the subject names.
The penalty formula
new_stage = current − ceil(incorrect / 2) × penalty
penalty = 2 when current ≥ passing stage, else 1
floored at the starting stageGetting something wrong twice costs the same as getting it wrong once — ceil(2/2) = 1 — but a third mistake costs another step. And once an item has reached Guru, every step back costs double: the further you have come, the more a lapse means you had not actually learned it.
This is documented, not guessed
It is published on knowledge.wanikani.com. I initially wrote in a comment that the formula was undocumented and reverse-engineered; that was wrong, and the comment now carries the source. Worth stating because the alternative — an SRS whose arithmetic nobody can check — is a bad thing to have in a learning tool.
Unlocking
This is what makes the ordering feel earned rather than arbitrary: you meet a kanji only once you know its parts. It lives in lib/progression.ts, runs inside the same transaction as the review that triggered it, and is also what bootstraps a brand-new account — otherwise you would sign in to an app with nothing to study.
The same graph is what projectedPassAt walks to answer when something would unlock, for the “unlock ahead of schedule” preview: sum a component’s remaining intervals up to the passing stage, then take the maximum across its components, since a subject waits on its slowest one. Each component is projected through its own SRS system — levels 1–2 run the accelerated one at roughly half the intervals, so borrowing another item’s system would misdate exactly the early levels that button is most used on. It assumes every review is correct and on time, which makes it good for ordering a list and useless as a promise. See The web app.
Grading an answer
Three verdicts, not two. lib/answer.ts:
| Verdict | When | Effect |
|---|---|---|
| correct | Exact match, an accepted synonym, or close enough | Advances |
| incorrect | Wrong, or a blacklisted near-synonym | Counts against the stage |
| retry | Not an attempt — nudged instead | Nothing at all |
Meanings tolerate typos; readings do not. A learner shouldn’t lose a stage to “grond”, but accepting a near-miss kana would teach the wrong word. The distance is Damerau-Levenshtein, which scores swapped adjacent letters as one edit rather than two — transposition being the commonest typing slip, and two edits putting it beyond every tolerance allowed.
Tolerance scales with length: three characters or fewer must be exact, or “no” would match “go”.
Retry covers the cases that are neither right nor wrong: typing the reading when asked for the meaning, giving a valid kun’yomi when the on’yomi was wanted, or romaji that never converted to kana. None of them costs a stage — you knew something, just not the thing being asked.
Vacation Mode: stopping the clock
Time spent away does not count. An item due in four hours when you leave is due in four hours when you return, whether that is a weekend or three months.
Nothing is actually frozen while the vacation runs. Every available_at keeps its value and the wall clock keeps moving, so from the database’s point of view the whole queue quietly falls due — which is exactly why the app must refuse to serve lessons or reviews while users.current_vacation_started_at is set. On return, the server pushes every timed assignment forward by the time away, which restores every interval at once.
- Shift on return, not rewrite on departure. A vacation has no known end, so “now plus the remaining interval” would have to be computed on return anyway — and one relative shift beats a per-row recalculation that has to know each item’s stage.
- Overdue items shift too. Leave two hours late and you return two hours late. Filtering to future items would forgive a backlog, which is the opposite of what someone who left because of a backlog wants.
- Lessons are untouched. They have no timer, so
unlocked_atis not shifted. Burned items carry noavailable_atand fall out of the update on their own. - Starting twice cannot restart the clock, which would silently discard the time already banked.
- The shift is clamped at zero, so a clock that has gone backwards can never drag the queue earlier and dump a day of reviews on someone the moment they return.
The daily lesson maximum
A separate, softer brake: max_daily_lessons caps what the app offers automatically — the dashboard’s count, the nav’s counter, and the session either starts. A lesson is an assignment gaining a started_at, and the day rolls over at midnight in the account’s own timezone rather than on a rolling 24 hours.
0 means zero — no new lessons at all while reviews carry on, which is the “let me catch up” setting. The default is 100, WaniKani’s ceiling, because a default of 0 would have stopped serving lessons to every account that upgraded into the setting.
The Advanced picker ignores it, deliberately
WaniKani’s own wording is that Advanced is how you “do more, or less” than the maximum. So a queue built in the picker is the deliberate step over the line, and at a maximum of zero the picker is the only way to start a lesson at all. It is a pace you set for yourself and step over knowingly, not a lock.
The review queue
An item leaves the queue only when every aspect is correct. Radicals have one question (meaning); kanji and vocabulary have two (meaning and reading), asked back to back. A wrong answer sends the item to the back to be asked again later in the session.
The rule is a pure function in lib/quiz.ts, which is why it can be tested at all — it lived inside the component until a bug that re-asked the same radical three times made the case for pulling it out. See Web app & quiz.
A consequence worth knowing
Because a result is only written once every aspect is correct, leaving mid-item discards it — and that includes the mistake. Bailing out before finishing a kanji dodges the penalty. WaniKani behaves the same way; it is a known quirk rather than an oversight here.