Repository navigation
Spaced repetition
Spaced repetition — three difficulties, a longer ceiling, and a number that says where the schedule is set
The review loop works and is better argued than most. What it lacks is a way to ask harder or easier on purpose, a ceiling that suits a library rather than a deck, and any record of what was answered — which is why nothing here can be measured, only asserted.
Four rulings, taken with the research in front of them:
| Question | Ruling |
|---|---|
| The 100-day ceiling | Raise it, as far as the evidence goes and no further |
| What "easy" means | Deliberately far options — the tier gives up the better-teaching condition on purpose, to lower the floor |
| Hard, and the sibling problem | Keep hard as described, and interleave the siblings it borrows from |
| The scheduler | Adaptive becomes the default, plus a review log, an overall retention figure, and a title derived from it |
Every decision below carries its citation, and every citation has an
infodot string written for it — the reader should be able to press ⓘ beside a
control and read why it is that way. INFODOT_MAX is 240 characters
(test/rules/infodot-copy.test.js:62); the strings in The infodots are inside it
and are the builder's to use verbatim or trim, in en.txt and bn.txt both.
Verified against v3 at the commit this file lands on.
| Piece | State |
|---|---|
| The model | An exponential forgetting curve evaluated in SQL at query time — no due-date column, no sweep. p = 2^(-elapsed/stability), due when elapsed >= stability
|
| Default rule | Fixed ladder 7 → 30 → 100 days. A correct recall climbs one rung; a lapse falls straight to 7 from any height |
| Adaptive rule |
Built, opt-in. grow 2.5, shrink 0.5, late 1.2 — and it never awards less than the elapsed gap warrants |
| Bounds |
reviewMinStability 7, reviewMaxStability 100
|
| Grace |
reviewNewItemDays 7: a new quote is not in the Daily Quiz for its first week, and reads "not yet reviewed" (until 3.0.3 it read "remembered") |
| Deck | Quota 8/day; since 3.0.3 two slots in three go to quotes never asked and every third to the most overdue review (it was one unseen slot in three); due cards ordered most-overdue-first by elapsed/stability; deterministic per day by seed. Practice deals the same two-to-one mix, without the due filter or the grace week |
| Directions | Seven — source, quote, cloze (typed), cloze-mcq, speaker, author, flip. Daily excludes flip. Chosen by a hash of card and day, not by difficulty |
| Cloze | Blank is the longest content-word run; 1 word until stability ≥ 30, then up to 3; ≥ 6 tokens, ≥ 75% Latin script; graded token-by-token with a per-word edit budget |
| Synonyms |
Seven hard-coded pairs in cloze.go, worth 0.5 of an exact recall |
| Distractors |
distractorScore — same medium +1,000,000, same author +100,000, shared genres ×100; films score genres ×1,000 and shared actors ×10 |
| Feedback | Already right. The answer, the correct option and each option's source are revealed after grading, never before |
| Difficulty today | Only as weights on the move: typed cloze 1.25 up / 0.85 down, synonym 0.5. No tier, no setting |
| Per-answer history |
None. item_reviews keeps current state only — stability, review_count, lapse_count, last_result, two timestamps |
| Leeches | Derived at read time at 5 lapses; the card stays in the deck and an exclusion is offered |
| Reserved already |
docs/wiki/Design-decisions.md — "the fixed ladder stays the default; adaptive intervals would ship beside it", and an approved-but-unshipped entry, "Measured difficulty feeds the schedule"
|
The schedule has a capacity, and it is about 800 quotes. At equilibrium a card
is asked once per stability days, so a library of N cards owes N / stability
reviews a day. With the ceiling at 100 and the quota at 8, the largest library the
loop can keep current is 8 × 100 = 800. Above that the deck runs permanently
behind — it still serves the most-overdue first, so nothing breaks, but a growing
library quietly stops being reviewed and only the staleset end of it is ever seen.
Nothing in the app says so. This is arithmetic, not opinion, and it is the
strongest argument for the first ruling.
The loop targets 50% recall, and that is invisible. Due at elapsed >= stability is exactly p = 0.5 — the app waits until a coin-flip before asking.
That is a legitimate setting and a defensible one (a harder retrieval that
succeeds is worth more — Bjork's desirable difficulties), but it is far below the
0.90 that FSRS and Anki take as the usual aim, and it was never chosen out loud. It
matters twice over now: an "overall retention" readout of ~50% would look like a
failing grade when it is in fact the schedule working exactly as built. The
target makes it a choice rather than a constant, and the score is read against it
— see The target, and the score against it.
docs/wiki/Design-decisions.md already approved most of the difficulty work, unshipped. The
entry "Measured difficulty feeds the schedule" says the deck should count its own
plausible distractors per card and feed that to the grader, so "a correct answer on
a card the deck knows was easy does not earn the full step" and "a lapse on a card
the deck knows was unfair is not decisive". It also rules the signal must be
measured, not self-reported, and must not become a column. Three difficulty
tiers are that entry's other half: it measures what the pool happened to give, and
this chooses what the pool gives.
Feedback is already correct, which removes the main risk of the tier work. The documented hazard of multiple choice is that lures get learned as facts (Roediger & Marsh, 2005); the documented fix is showing the answer afterwards (Butler & Roediger, 2008). The app already reveals the answer and every option's source after grading, and gates the edit pencil so the answer cannot be read early. Nothing to build; something to not break.
| Finding | Source | What it decides here |
|---|---|---|
| The optimal gap grows with the retention interval; ISI and RI act jointly | Cepeda, Pashler, Vul, Wixted & Rohrer (2006), Psych. Bulletin 132, 354–380 — 839 assessments across 317 experiments | A fixed ceiling cannot be right for all cards; the ceiling is a budget decision, not a scientific one |
| Optimal gap ≈ 20% of the delay at a few weeks, falling to ≈ 5% at one year; performance rises then falls gradually, so erring long is cheaper than erring short | Cepeda, Vul, Rohrer, Wixted & Pashler (2008), Psych. Science 19, 1095–1102 — 1,354 people, gaps to 3.5 months, tests to 1 year | The ceiling goes to 365 days — one year is the longest interval the evidence covers |
| Multiple choice with competitive (plausible) alternatives teaches related, untested material better than cued recall, because you retrieve why each wrong option is wrong — and it avoids test-induced forgetting | Little, Bjork, Bjork & Angello (2012), Psych. Science 23, 1337–1344 | Competitive lures are the better-teaching condition. Easy mode gives them up deliberately, and the plan says what that costs |
| More lures read → smaller testing effect and more lure intrusions later; multiple choice can create false knowledge | Roediger & Marsh (2005), JEP:LMC 31, 1155–1159 | Feedback is not optional at any tier |
| Immediate or delayed feedback raises correct responses and cuts lure intrusions | Butler & Roediger (2008), Memory & Cognition 36, 604–616 | The existing reveal-after-grading is load-bearing; hard mode must keep it |
| Retrieving some items from a category impairs later recall of related, unpractised items from it | Anderson, Bjork & Bjork (1994), JEP:LMC 20, 1063–1087 | Hard mode's same-series lures need the siblings queued soon after |
| Expanding intervals win at 10 minutes; equal intervals win at 2 days | Karpicke & Roediger (2007), JEP:LMC 33, 704–719 | The expanding ladder is a compromise, not a law — worth saying in the infodot rather than defending as settled |
| Performance during practice is an unreliable, sometimes misleading index of learning | Bjork — desirable difficulties | The title must not read as a grade |
| Desired retention ≈ 0.90 is the usual aim, 0.80–0.95 reasonable; true retention is measured from review history and should sit near the desired figure | FSRS / Anki documentation | The retention readout is a check on the schedule, not a score for the reader |
| Gist outlives verbatim | Reyna & Brainerd, fuzzy-trace theory | Noted, not acted on — a commonplace book may not want verbatim recall at all. Out of scope, named below |
On over-reach, stated once: Cepeda's designs are study → gap → restudy → one test. Applying their ratios directly to a repeating schedule is an extrapolation. That is precisely why the ceiling stops at the edge of their data rather than at five years.
reviewMaxStability goes from 100 to 365. The ladder gains a fourth rung:
7 → 30 → 100 → 365.
- Why 365 and not more. One year is the longest retention interval Cepeda et al. (2008) tested. Beyond it there is no measurement, only extrapolation, and a number invented past the evidence is exactly the kind of thing this repo writes entries about.
-
Why not less. Capacity is
quota × ceiling. At 8 a day, 365 makes about 2,900 quotes sustainable instead of 800 — a real library. - A larger library is fine and is said so. Above capacity, the most-overdue-first ordering means the reader always gets the stalest thing. That is the correct degradation; what is wrong today is that it is silent. The deck's own screen should be able to say "your library is larger than the schedule can keep current", which is a fact the status counts can already compute.
- The ladder stays legible, which is the reason it beat the tunable rule: four numbers, still holdable in the head, still the review intervals themselves.
- The tuning range for
Ladder3widens to 365, andLadder4joinsreviewTuningwith the same clamp shape.
Migration 0019 clamped stored stabilities above 100 down to it. Raising the ceiling needs no migration — nothing is above 100 to rescue, and the off-ladder climb rule already lets a value join the nearest rung at its next answer.
A tier is chosen for a round (Daily takes the reader's default; Practice offers the
picker). It changes which directions are offered, how distractors are
chosen, how wide the blank is, and what is shown beside the quote. It does
not become a column: like the difficulty signal Design-decisions.md already approved, it is a
property of the round, applied when the pool is built.
-
Distractors deliberately far.
distractorScoreinverts: candidates are ranked down the same scale, so same-author and same-series are avoided rather than preferred, and cross-medium is acceptable. - Two options, not four.
- Speaker and character chips visible beside the quote, with the face.
- One-word blank, whatever the stability, with full surrounding context.
- MCQ only — cloze-mcq, source, quote, speaker, author. No typed cloze, no flip.
- The cost, recorded rather than hidden: non-competitive alternatives teach less than competitive ones (Little et al., 2012). Easy is a lower floor, not a better question, and its infodot says so.
-
Competitive distractors, which is today's
distractorScoreunchanged. - Same author or series appears, but not often. A per-round quota — at most one card in three draws a same-author lure — because "close" loses its meaning when every card is close. The quota is the new thing; the scorer is not.
-
Closer cloze options. Today's seven synonym pairs are for grading, not for
generating lures. Two ways to make options close, and I recommend the first:
-
Corpus-derived —
clozePhraseOfalready lifts same-word-count phrases from other quotes; rank those by surface similarity (length, initial letter, shared stem) as well as by parent-work similarity. No dependency, and the lures are always real language from the reader's own library. - A synonym/antonym dictionary. A real one is a dependency, and
go.modhas exactly three direct requirements for reasonsDesign-decisions.mddefends by name. A hand-curated list is affordable — the existing seven pairs are the seed — but it will always be thin, and thin coverage means the feature works on some cards and not others with no way for the reader to tell why.
-
Corpus-derived —
- Four options. Chips shown for the work, not for the speaker.
-
Which language is this proverb? A new direction, medium only. It shows the
English translation alone and asks which language the proverb is in.
- Eligible rows are
utteranceswithkind = 'proverb'(0053's fixed list, with its ownCHECK), a non-emptytranslationand a non-emptylanguage— both columns landed in 0035. - Options are the distinct
languagevalues across the reader's own proverbs, so the lures are always languages they actually keep. - Offered only when at least two languages exist among them, which is the owner's rule and the honest one: a question with one possible answer is not a question.
- With exactly two languages it is a coin-flip, and that matters now that a retention figure is being reported: a direction with a 50% floor inflates it. Prefer three options where the library allows, and where only two exist the card should be asked and not counted toward the figure — the same separation Daily already makes for self-marked flip cards.
-
region(0047) is the obvious sibling question and is deliberately not being asked for. It stays out until it is.
- Eligible rows are
- Typed cloze is the norm, with the widest blank the quote allows (up to 3 words, no longer gated on stability ≥ 30 — the tier is the gate).
- Same-series lures are the norm wherever options appear: if the card's work belongs to a series and the series has other quotes, they are the options.
-
People questions: who said it, who played them, who wrote it — the existing
speakerandauthordirections, weighted up rather than left to the hash. -
New directions worth having at this tier (each cheap, each already has the
data): which chapter/act, which season and episode, who is being addressed
(the
recipientcolumn), which of these two lines comes first in the work (position is already stored), and which year for a film or a book. -
Names in the line are blanked too. Every character and actor name that
appears in the quote's own text is masked, on top of the chosen content-word
span. Two reasons, and the second is a defect fix:
- A name is often the line. Blanking the longest content-word run and leaving the character's name standing asks the easier half of the question.
-
A quote that names its own speaker answers the speaker card before it is
asked. That leak exists today at every tier, on any line whose text
contains the character's name — so the masking belongs to the
speakerandauthordirections at all tiers, not only to hard.
-
Matching reuses what the cast already stores.
work_castholdscharacter_keyandactor_key, bothstore.CastKey(...)folds (0048); the quote's owncharacter,actor,speakerandrecipientfold the same way. Fold the quote's tokens and compare — no second normalisation, which is the rule this repo keeps writing entries about.- Longest match wins, so a name inside a longer one masks the whole of it.
- Possessives and inflections come free from the existing stem fold in
cloze.go. - Name masking need not be gated on script. The cloze span selection requires ≥75% Latin because the stopword list is English; a name match needs no stopword list, so a Devanagari or Bengali line naming its speaker can still have the name blanked. That is a small widening of where hard cards can be asked.
- A line that is only a name has nothing left to ask: refuse the card, the way
TestClozeRefusesWhatItCannotAskalready refuses a quote that is all stopwords.
- Feedback stays exactly as built. Hard means no options, not no answer.
Picks a tier per card, not per round, from a seeded hash so a refresh is stable —
the same mechanism dailyDirection already uses. Worth having because a fixed tier
over a mixed library is either too easy for the known lines or too hard for the new
ones, and this is the cheapest approximation of per-card difficulty until the
measured signal ships.
When a hard card takes its lures from a series, the quotes used as lures are queued to be asked soon — the antidote to the effect that makes them worth worrying about (Anderson, Bjork & Bjork, 1994): related items are suppressed when they are never themselves retrieved.
Mechanism, and it needs the log from §3: the review log records when a quote was used as a lure. The deck's ordering gives a bonus to cards recently used as a lure, so they surface within the next few rounds. No due-date column, no sweep, computed at query time — the section's founding constraint holds.
reviewAdaptive flips: multiplicative growth and a halved, not reset, lapse
become what a reader gets without choosing. The ladder stays available as the
opt-in, because it is the version that can be explained in one sentence and some
readers will prefer it.
Design-decisions.md already reserved this shape and named the reason to move: "a lapse
currently drops you to 7 from any rung, and that is the one place the loop is
harsher than the science asks." The builder records the switch in docs/wiki/Design-decisions.md
as a reversal of the 1.15.0 entry, with the ladder's continued availability as the
part that is not reversed.
The adaptive rule needs one addition against the raised ceiling: grow 2.5 from 100
overshoots 365 in one step. Either the clamp does the work (simplest, and the
existing clamp() already would), or growth tapers near the ceiling. The clamp is
enough and adding a taper would be a parameter nobody can evaluate — the same
argument that retired the sliders.
One append-only table. It is the prerequisite for everything else in this section and for the measured-difficulty entry already approved.
CREATE TABLE review_log (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL, -- book | screen | utterance
item_id INTEGER NOT NULL, -- no FK: polymorphic, as item_reviews
answered_at TEXT NOT NULL,
mode TEXT NOT NULL, -- daily | practice
tier TEXT NOT NULL, -- easy | medium | hard
direction TEXT NOT NULL, -- the seven
result TEXT NOT NULL, -- got | forgot | skip
elapsed REAL NOT NULL, -- days since last review, at answer time
stability REAL NOT NULL, -- stability BEFORE the answer
next REAL NOT NULL, -- stability AFTER
lure_of TEXT NOT NULL DEFAULT '' -- kind:id list this card's lures came from
);Three cautions the schema has to answer, all of which this repo has met before:
-
Polymorphic with no FK, exactly as
item_reviewsandanthology_entries— so it needs the sameAFTER DELETEtrigger per parent, and 0018's warning about hand-recreating them after a table rebuild now covers a fifth join. - It grows without bound, which is the first unbounded table in the app. It needs a retention rule decided up front — my recommendation is to keep everything (a row is ~80 bytes; ten years of 8/day is under 2.5 MB) and to say so, rather than to add a sweep, which the no-background-jobs invariant forbids anyway.
-
It must not slow the answer path. One
INSERTinside the transaction that already updatesitem_reviews.
The due point stops being a constant. srTargetRetention is a preference — the
share of cards the reader expects to recall when one comes back — and the due
rule derives from it:
due when p <= target, i.e. elapsed >= stability × log2(1 / target)
-
The default is 0.5, which is exactly today's rule.
log2(1/0.5) = 1, so the multiplier is 1 andelapsed >= stabilitysurvives untouched. Nothing any reader has is rescheduled unless they move the dial themselves — no migration, no surprise. - Range 0.5 to 0.95. The upper end is where the usual guidance stops (FSRS/Anki call 0.80–0.95 reasonable, ~0.90 the common aim); the lower end is today's behaviour, kept rather than deprecated because it is defensible on desirable-difficulty grounds.
- RAISING THE TARGET MAKES ALMOST EVERYTHING DUE AT ONCE, and this is the hazard to write down rather than discover. At 0.9 the multiplier is 0.152, so every card past about 15% of its half-life becomes due — which on a settled library is nearly all of it. The daily quota is what saves this: the deck drains at 8 a day in most-overdue-first order rather than flooding. The control still has to say so before it is moved, because the backlog count will jump and nothing else on the screen would explain it.
True retention = got / (got + forgot) over a window, from the log. Skips are
excluded, and same-day repeats — already idempotent in Daily — must be excluded here
too, or a device retry inflates it. So must any direction whose guessing floor is
50%, which today means a two-option proverb-language card.
The score is the delta. Both numbers, and the signed difference:
target 90% · recalling 86% · 4 points under
- Young (stability below the second rung, 30) and mature (at or above it), because one figure hides which half is drifting. Anki's convention is 21 days; the app's own rung is 30, and using the app's own number keeps one vocabulary.
- The delta is a reading, not a verdict, and the wording has to carry that. Above target means the schedule is asking sooner than it needs to and the gaps could lengthen; below means it is asking too late. That is the FSRS framing, and it makes the number actionable instead of judgmental — which is the same reason the title does not come from it.
Not from retention. Retention says where the schedule is set, not how well someone reads (FSRS/Anki: true retention should sit near desired retention, and lower desired retention is a legitimate choice). A title derived from it would rank a reader for a setting — and would call a correctly-tuned schedule a failure.
From mature count — how many quotes have reached the long rungs. It only ever grows, every level is an achievement, and it measures the thing the loop is for. Retention sits beside it as a dial reading with a plain sentence, not as a rank.
Bands and names are the owner's to write; the mechanism is: bands over mature-card
count, no band named for a deficit, the first band earned by arriving rather
than by performing, and every name translatable — bn.txt needs a twin that is a
name and not a description. A workable seed, offered to be replaced: Reader ·
Keeper · Annotator · Commonplacer · Anthologist · Archivist. Nothing in the ladder
may read as "beginner", "novice", "poor" or their Bengali equivalents.
Ready to use, all inside the 240-character budget. Placement is the builder's; the pairing is not.
| Beside | Text |
|---|---|
| The interval ceiling / schedule settings | The best gap grows with how long you want to keep something — about a fifth of the delay at a few weeks, a twentieth at a year (Cepeda et al., 2008). Past a year the evidence thins, so the longest interval here stops there. |
| The Easy tier | Close wrong answers teach more than obvious ones: you recall why each is wrong, not just why one is right (Little et al., 2012). Easy mode gives that up on purpose, to lower the floor. |
| The answer reveal | Wrong options can be learned as facts. Showing the answer afterwards removes most of that risk (Roediger & Marsh, 2005; Butler & Roediger, 2008), which is why every round ends with the answer. |
| The Hard tier / series lures | Recalling one line from a set can push its neighbours further out of reach (Anderson, Bjork & Bjork, 1994). When a hard card borrows from a series, those neighbours are queued soon after. |
| The ladder | Expanding gaps win a test ten minutes later; equal gaps win two days later (Karpicke & Roediger, 2007). The ladder is a compromise, not a law. |
| The retention figure | How often you recall at review says where the schedule is set, not how well you read. Around nine in ten is the usual aim (FSRS/Anki); lower means longer gaps, not worse memory. |
| The target dial | Your target is the share you expect to recall when a card returns. A higher one means shorter gaps and more reviews; a lower one means longer gaps and more misses, on purpose (Cepeda et al., 2008; Bjork). |
| The proverb-language card | Offered only once your proverbs hold more than one language, because a question with one possible answer is not a question. |
| A wrong answer / the streak | Getting it wrong feels like failure and is not: practice that feels harder often remembers better, and easy practice flatters the moment (Bjork, desirable difficulties). |
help-budget.test.js and infodot-copy.test.js both measure these; the builder
runs them rather than counting by hand.
The 50% due point was the open question, and the target answers it. Rather than
choosing a number for every reader, the number becomes the reader's, defaults to
what the app already does, and is the thing the score is measured against. That also
dissolves the framing problem the retention figure had on its own: a bare 50% reads
as failure, target 50% · recalling 51% does not.
Recorded as it happened rather than tidied away, because two of these change what the remaining steps have to do.
item_recalls (0064/0065/0066) is the log. It carries mode and counted, which
this plan did not ask for and which are the two facts that stop the panel reporting
thirty-seven practice answers beside an unmoved half-life. What it does NOT carry is
direction, tier or lure_of.
And direction cannot simply be added, because review_handlers.go already ruled
against it. The comment at the scheduling block is explicit: "THE DIFFICULTY IS
DERIVED, NOT DECLARED. A client-sent 'direction' would be the client telling the
server what its own answer was worth, and the obvious abuse — claim every answer was
the hardest kind — would inflate a schedule invisibly." The answer request carries
{kind, id, result, mode, offset, attempt} and nothing else; the server infers
dirCloze from attempt != nil and treats every other direction as weight 1.
So the log's direction would be a client claim. That is acceptable for the log,
which decides nothing — but §3's retention figure is computed FROM the log and §7
requires a two-option proverb card to be excluded by direction, which makes a
client claim load-bearing for a number on screen. Either the figure is the reader's
own number about their own answers (defensible, per-user isolation means nobody else
is affected) or the direction has to be derived. Only daily could be: dailyDirection
is a hash of card and day and the server can recompute it; Practice picks at random,
server-side, and is not recoverable. Not decided here. The three columns are
deferred to one migration alongside the tiers rather than three separate ones.
The due point is stated twice, and the two statements are in different languages:
-
dueSQL—julianday('now') - julianday(r.last_reviewed_at) >= MAX(r.stability, 7) -
recallStatus—case p >= 0.9: remembered; case p >= 0.5: forgetting; default: probably-forgotten
elapsed >= stability is exactly p <= 0.5, so the deck's due point and the dot's
bottom threshold are one number written down twice — and dueSQL's own comment makes
that a promise: "a card is due exactly when its dot reads probably-forgotten."
srTargetRetention moves one of them. At a target of 0.9 the multiplier is 0.152, so
a card becomes due at p = 0.9 — while its dot still says remembered. The deck
would fill with cards the app describes as remembered, and the comment above would be
false.
Three ways out, and this plan chose none of them:
-
Thread the target through both.
recallStatus's bottom threshold becomes the target and "remembered" scales to preserve today —p >= target + (1-target)*0.8gives exactly 0.9 at a target of 0.5. Correct, and the largest:recallStatushas six non-test call sites, three of them instats_handlers.go. That last leg of the blocker is now retired — the commit that recorded it also gavestats_handlers.goaloadPrefsfor the capacity, so preferences are in hand there. What remains is threading the value, not finding it. -
Decouple them deliberately. The dot answers "how likely are you to recall this
now?" (absolute); due answers "does the schedule want to ask?" (the reader's).
Cheaper, and it makes
dueSQL's comment false — it would have to be rewritten, and the screen consequence at a high target stated rather than discovered. - Ship the dial after the figure it exists for. Its only consumer is §3's score, which is step 10. Nothing else in the app reads the target.
Not built, and not because it was forgotten. A dial that changes when every card is due, whose own hazard note says raising it makes nearly everything due at once, is a poor thing to land days before a launch when its consumer does not exist yet.
The duplication itself IS fixed (3.1.0): reviewDuePoint and reviewHeldPoint are
constants, dueSQL splices dueMultiplier(reviewDuePoint) — log2(1/target), exactly
1 at 0.5 — and recallStatus switches on the same two. Behaviour is unchanged and
TestTheDotAndTheDeckAgreeOnDue holds them together. So the dial is now a small
change: the constant becomes the preference, in one place.
Built: the direction sets, the inverted scorer for Easy (twice — attachSpeaker does
not consult distractorScore at all and had to be inverted separately), the wider
blank for Hard, Random by seeded hash, and — later, after a rater measured it — the
same-author quota for Medium.
The quota turned out to matter more than the plan's sentence suggests. Over a
ten-card deck of one author's quotes with six other authors parked in the pool, the
same author won ten cards out of ten: distractorScore pays 100,000 for a shared
author against 100 per shared genre. And the plan's shape for it was not enough.
Withholding the bonus — scoring a same-author candidate as though the authors did not
match — was built first and does not deliver "at most one card in three": with the
scores level a shuffle still puts a same-author title in the top three about half the
time. The cap needs the candidate DEMOTED below the whole pool, not merely unrewarded.
Easy's chips landed after that, and the plan was wrong about which component draws
them. "Speaker and character chips visible beside the quote, with the face" reads as a
job for SpeakerChips, the app's rich chip row — and review.jsx cannot import it:
people.jsx imports usePractice from review.jsx, which is the cycle credits.jsx
exists to keep open. Moving the row down into credits.jsx was tried and reverted, and
two guards said why before any of this was reasoned out — locale-shadow.test.js (the
move collided with two local const t in that file) and person-router.test.jsx, whose
own comment already recorded the answer: "review.jsx defines its OWN PersonChip —
display-only, because there the answer buttons own the tap." A chip that became a door
mid-question would take the reader out of the round from the one place every tap is
already spoken for. So the quiz card's own display-only chip draws them, in the same
wrapping row SourceLines uses for the attribution side.
And the corpus-derived cloze lures landed last, as the plan's first recommendation
specified — clozeSurfaceScore, shared stem then initial letter then length, over a
bounded pool of candidates that the work ranking has already ordered. Two things the plan
did not say:
-
At one word — the width every new card is asked at — the stem term is unreachable.
A single word that shares the answer's stem IS the answer. So the initial letter and the
length carry the whole of it there, and the stem term only does work on a multi-word
blank, which means a guard for it has to park a card past
clozeMultiWordFromrather than seeding a fresh one. - A wrong option could be a phrase the TYPED card would accept, and that was a defect rather than a gap. The duplicate check was normalised equality, so a plural, a tense or one of the app's own synonym pairs could be offered as a wrong answer: pick it and you are told you forgot a line the other direction calls correct. The grader itself is the check now.
- And the round's same-author cap must not reach these lures. It was passed straight in with the tier, so two cards in three drew their phrases from the FARTHEST works in the library — the exact opposite of what this step is for. A phrase carries no visible author, so the cap buys nothing here; it is about which TITLE is offered.
And Easy's chips shipped with a leak, which is recorded in docs/wiki/Design-decisions.md and belongs
here too because the plan's own sentence is what produced it. "Never on a card that asks
it" reads as a list of directions, and a fill-in-the-blank card asks who whenever the
phrase it hides is a name: the chip was the answer, and the reader could type it and be
graded right on a card never recalled. The rule is the mask, not the direction.
And Hard's same-series lures cannot be built at all yet, which is recorded in
docs/wiki/Design-decisions.md rather than here: there is no series term in distractorScore and no
series field on workRef. A separate entry proposes adding them.
This plan says the masking exists because "a quote that names its own speaker answers the speaker card before it is asked… on any line whose text contains the character's name". Two corrections:
-
The character's name is not the answer. A film line's speaker card asks for the
actor (
attachSpeakertakescard.ActorforkindScreen). A line reading "Neil, what are you doing here?" gives up Robert De Niro only to a reader who knows Heat — which is precisely what the card is testing. Masking it would blank a large share of the dialogue in a library to remove the knowledge being examined. -
The prompt-side leak was already closed.
review.jsxused to send every direction that was not "source" downSourceLines, which prints the actor as a face chip and the character in its meta line — the answer directly above its own four options. The comment there records the fix.
What actually leaks is the answer string appearing in the words the card shows, so
that is what hideTheAnswer masks: the credit, each split credit, and each credit's
surname. A surname is matched case-sensitively — an author called "Stephen King"
would otherwise have the app blanking "the king was dead" in every line of the
library, and prose naming a person capitalises them while prose using the same word as
a common noun does not. A line with nothing left to read is refused, and
buildQuestion falls through to another direction.
Not gated on script: the cloze span selection needs mostly-Latin text because its
stopword list is English, but a name match needs no stopword list, so the boundary is
"not a letter or a number" rather than \b — which is an ASCII-word rule and would
never have fired on a Bengali line.
-
The review log — table, triggers, the one
INSERT. Nothing else in this plan can be measured without it. — a migration,review_handlers.go -
Ceiling to 365, fourth rung, tuning range widened. No migration needed.
—
review_handlers.go,review_tuning.go -
Adaptive as the default, ladder kept as the opt-in. —
review_handlers.go,review_tuning.go, and theDesign-decisions.mdreversal entry -
The target dial —
srTargetRetention, defaulting to 0.5 so nothing moves, and the due rule derived from it. Ships before the tiers because the retention figure is unreadable without it. —review_handlers.go,review_tuning.go,auth_handlers.go,Settings.jsx -
Name masking, and the speaker/author leak it closes at every tier. Folding
through
store.CastKeyagainstwork_castand the quote's own credit columns. —cloze.go,speaker.go,review_handlers.go -
The tiers — direction sets, the inverted scorer for Easy, the same-author
quota for Medium, the wider blank and series lures for Hard, Random by seeded
hash. —
review_handlers.go,review_questions.go,cloze.go,speaker.go -
The proverb-language direction, with its two-language gate and its exclusion
from the retention figure while it has only two options.
—
review_questions.go,review_handlers.go -
The sibling interleave, reading
lure_offrom the log in the deck ordering. —review_handlers.go -
The new hard directions — chapter/act, season/episode, recipient, which came
first, which year. —
review_questions.go -
The score and the title — computed from the log, shown with its
target. —
review_handlers.go,StatsPage.jsx,review.jsx -
The infodots, in
en.txtandbn.txt. -
Docs —
docs/wiki/Design-decisions.md§8 (the ceiling, the default switch as a reversal, the tiers, and the measured-difficulty entry this half-satisfies),CHANGELOG.md,docs/ui-glossary.htmlif the tier picker is documented,How-this-was-written.mdif verification changes.
go vet ./... && go test ./internal/httpapi/ -run 'Review|Cloze|Quiz|Stability|Tier' -v
go test ./...
cd web/frontend && npm test # infodot-copy, help-budget, locale-complete
make frontend && make glossary && npm run glossary:checkNew guards worth naming:
-
Capacity is stated, not discovered. A test that asserts
quota × ceilingis reported somewhere the reader can see when the library exceeds it. - Easy never draws a same-author lure; Hard always does where a series exists. The tier's whole definition, asserted rather than eyeballed.
- A lure is queued. Answer a hard card whose options came from a series; assert those siblings appear within the next N decks.
- True retention excludes skips and same-day repeats. The two ways the figure inflates.
- No title band is derogatory — a list check against a named vocabulary, in both locales, because this is the one string in the app that describes the reader.
-
The default target changes nothing. With
srTargetRetentionat 0.5, the set of due cards is byte-identical to today's. This is the migration test, and it is the reason the default is 0.5 rather than 0.9. - A quote never shows a name it is about to ask for. Build a speaker card from a line containing its own character's name and assert the name is masked — the leak that exists today at every tier.
- A line that is only a name is refused, the way an all-stopword quote already is.
- The proverb card is absent at one language and present at two.
By hand, against a real backup rather than seed.mjs: run a Daily at each tier and
read every infodot on the way past.
- Verbatim versus gist. Cloze tests exact wording; gist outlives verbatim (Reyna & Brainerd). A commonplace book may want "what was this about" more than "which word was missing" — a real question, a different feature, and one that would need question types the app does not have.
-
Per-card measured difficulty —
Design-decisions.md's approved entry. The tiers choose what the pool gives; that entry measures what it gave. They compose, and it should ship after the log exists. -
A synonym/antonym dependency, for the reason
go.modgives. - Changing the due point from p = 0.5, until the question above is answered.
These pages are the documentation itself, not a summary of it — they live in
docs/wiki/ and nowhere else.
Report anything wrong.
The code
Running it
The parts
Generated — not wiki pages