diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-31 07:55:46 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-31 07:55:46 -0500 |
| commit | 532ce532465834ce06238648ba1490c48bed29ca (patch) | |
| tree | 042658fa4824eb2b5a860dea696e50408114fb49 | |
| parent | bafa281b9c0b3ccc78b4a8420a817662d50ca86f (diff) | |
| download | org-drill-532ce532465834ce06238648ba1490c48bed29ca.tar.gz org-drill-532ce532465834ce06238648ba1490c48bed29ca.zip | |
docs: fold Review 2 into the FSRS spec (Response 2)
Review 2 (Codex) flagged three blockers in my Response 1, and two were mine to own. I'd reversed DRILL_CARD_WEIGHT: I described SM as multiplying the interval when it actually divides the delta, so weight 2 means more frequent review, not less. And I'd locked a quality mapping that both claimed to honor org-drill-failure-quality and hard-coded a fixed table.
Craig's calls: FSRS matches the existing SM/Simple8 delta interpolation for weight, and honors org-drill-failure-quality for the Again boundary with a fixed Hard/Good/Easy sub-mapping. I fixed both throughout the spec and grounded each claim in the cited org-drill.el line so the description can't drift again.
I also reframed the status to "Needs research" with the three real prerequisites stated honestly (pin the py-fsrs source, cross-check the equations, generate the reference vectors), fixed DRILL_LAST_REVIEWED ownership back to the shared reschedule flow, and noted that fsrs has to join the algorithm defcustom and the safe-local whitelist. The review file is committed alongside the spec as the audit trail.
The spec is still not implementation-ready, and now says so plainly.
| -rw-r--r-- | docs/design/fsrs-spec-review.org | 146 | ||||
| -rw-r--r-- | docs/design/fsrs-spec.org | 634 |
2 files changed, 623 insertions, 157 deletions
diff --git a/docs/design/fsrs-spec-review.org b/docs/design/fsrs-spec-review.org new file mode 100644 index 0000000..7eea3b6 --- /dev/null +++ b/docs/design/fsrs-spec-review.org @@ -0,0 +1,146 @@ +#+TITLE: Spec review - FSRS scheduler for org-drill +#+DATE: 2026-05-28 +#+AUTHOR: Codex + +* Verdict + +*Needs research before implementation.* + +The spec is directionally strong and much closer than the previous draft, but +it is not implementation-ready. The blockers are not mostly product questions; +they are reproducibility and integration-contract questions: the reference +implementation is not pinned, the formulas have not been cross-checked against +that pin, the reference vectors do not exist yet, and two parts of the prose +conflict with current org-drill behavior. + +* Scope Reviewed + +- [[file:fsrs-spec.org][docs/design/fsrs-spec.org]] +- [[file:../../org-drill.el][org-drill.el]] scheduler, safe-local, property, and quality-threshold paths +- [[file:../../todo.org][todo.org]] FSRS tracking entry +- Upstream =py-fsrs= and =fsrs4anki= public docs/release pages + +* Blocking Findings + +** B1. Source pinning is still unresolved, but the spec says no blocking questions remain + +The status correctly says implementation must wait for a pinned =py-fsrs= tag, +equation cross-check, and generated reference-vector fixture +([[file:fsrs-spec.org::7][fsrs-spec.org:7]]). The Open questions section then +says there are "None blocking implementation" and treats the exact =py-fsrs= +tag as "not a design question" ([[file:fsrs-spec.org::88][fsrs-spec.org:88]], +[[file:fsrs-spec.org::92][fsrs-spec.org:92]]). + +That split leaves an implementer with contradictory marching orders. The +reference-vector tests are part of the acceptance plan +([[file:fsrs-spec.org::426][fsrs-spec.org:426]], +[[file:fsrs-spec.org::497][fsrs-spec.org:497]]), so the source pin is a blocking +implementation prerequisite, regardless of whether it is a product decision. + +Suggested fix: make the readiness state explicit, e.g. "Needs research: +implementation starts only after =py-fsrs= tag/commit X is pinned, formulas are +verified against that source, and =tests/fixtures/fsrs-vectors.py= is generated." + +** B2. =DRILL_CARD_WEIGHT= semantics are reversed relative to current org-drill + +The spec says FSRS should multiply the final interval by card weight +([[file:fsrs-spec.org::37][fsrs-spec.org:37]], +[[file:fsrs-spec.org::79][fsrs-spec.org:79]], +[[file:fsrs-spec.org::213][fsrs-spec.org:213]], +[[file:fsrs-spec.org::473][fsrs-spec.org:473]]). Current org-drill does the +opposite: the scheduler comment says intervals are divided by weight so weight +2 means reviewing twice as often, and the code divides the interval delta by +weight ([[file:../../org-drill.el::1648][org-drill.el:1648]], +[[file:../../org-drill.el::1670][org-drill.el:1670]], +[[file:../../org-drill.el::1719][org-drill.el:1719]]). + +This is a behavioral regression waiting to happen. If FSRS uses multiplication, +existing users who rely on =DRILL_CARD_WEIGHT= for more frequent review would +get less frequent review under FSRS. + +Suggested fix: change the spec to match existing semantics unless Craig +explicitly wants to redefine =DRILL_CARD_WEIGHT= for FSRS. The likely contract +is "apply the same post-algorithm adjustment as SM/Simple8: interpolate from +last interval toward computed interval by dividing the delta by positive card +weight." + +** B3. Quality mapping contradicts the custom failure threshold test + +The Agreed decisions lock a fixed mapping of 0/1/2 to Again, 3 to Hard, 4 to +Good, 5 to Easy ([[file:fsrs-spec.org::76][fsrs-spec.org:76]]). The algorithm +section says this mapping respects =org-drill-failure-quality= +([[file:fsrs-spec.org::136][fsrs-spec.org:136]]), and the tests require custom +=org-drill-failure-quality= to shift the Again boundary +([[file:fsrs-spec.org::443][fsrs-spec.org:443]]). + +Those cannot all be true. Current org-drill has a configurable +=org-drill-failure-quality= and central failure predicate +([[file:../../org-drill.el::153][org-drill.el:153]], +[[file:../../org-drill.el::1955][org-drill.el:1955]]). FSRS needs a concrete +policy: either honor that threshold dynamically, or deliberately ignore it and +remove the custom-boundary test. + +Suggested fix: decide this in the spec before implementation. If honoring the +existing threshold, define the exact mapping for threshold values other than 2. +If hard-coding FSRS's boundary, update the prose and tests to say FSRS is +independent of =org-drill-failure-quality=. + +* Medium Findings + +** M1. =DRILL_LAST_REVIEWED= ownership is unclear + +The state section says FSRS reuses =DRILL_LAST_REVIEWED= and that the SM path +already writes it ([[file:fsrs-spec.org::160][fsrs-spec.org:160]]). Elsewhere +the proposed =org-drill-store-fsrs-result= responsibilities appear to include +writing FSRS result state, while =org-drill-smart-reschedule= currently owns the +shared schedule write flow. The spec should say whether FSRS storage writes +=DRILL_LAST_REVIEWED= itself or whether the existing reschedule path remains the +single owner. + +Suggested fix: keep =DRILL_LAST_REVIEWED= in the shared reschedule flow if +possible, and make FSRS-specific IO responsible only for =DRILL_FSRS_*=. + +** M2. Defcustom and safe-local updates need to cover the algorithm symbol too + +The spec covers =org-drill-fsrs-desired-retention= safe-local validation +([[file:fsrs-spec.org::83][fsrs-spec.org:83]]), but the existing algorithm +defcustom and safe-local predicate also need =fsrs= added. Today the +algorithm choice and safe-local whitelist only include =sm2=, =sm5=, and +=simple8= ([[file:../../org-drill.el::528][org-drill.el:528]], +[[file:../../org-drill.el::928][org-drill.el:928]]). + +Suggested fix: add an explicit implementation/test bullet for the algorithm +defcustom choice list and safe-local whitelist. + +** M3. Project tracking is stale + +=todo.org= still describes the FSRS spec as implementation-ready and says +Review 1 was incorporated by Codex ([[file:../../todo.org::119][todo.org:119]]). +That conflicts with the spec status and with the spec history's neutral +"External reviewer" wording. The todo entry should be updated as part of this +review pass so work is not picked up prematurely. + +* Notes On Upstream Sources + +The current =py-fsrs= README documents 21 default parameters and four ratings; +the releases page shows =v6.3.1= as latest on 2026-03-10 and the =v6.0.0= +release notes say Py-FSRS 6 moved custom parameters from 19 to 21. Those +facts support the spec's caution: do not implement from paraphrased formulas +until the exact v4.x/v4.5 source of truth is pinned and captured in the spec. + +Sources: +- https://github.com/open-spaced-repetition/py-fsrs +- https://github.com/open-spaced-repetition/py-fsrs/releases +- https://github.com/open-spaced-repetition/fsrs4anki/wiki/The-Algorithm + +* Recommended Next Iteration + +1. Pin the exact reference source: tag/commit, repository, file path, and + function names used for formulas and vectors. +2. Cross-check the v4.5 equations against that source and replace paraphrases + with implementation-grade formulas. +3. Generate and commit the reference-vector fixture. +4. Resolve =DRILL_CARD_WEIGHT= and =org-drill-failure-quality= semantics in the + Agreed decisions table, algorithm section, and tests. +5. Re-run spec-review. If those items are resolved, this should be close to + implementation-ready. diff --git a/docs/design/fsrs-spec.org b/docs/design/fsrs-spec.org index e2e308a..76ec4db 100644 --- a/docs/design/fsrs-spec.org +++ b/docs/design/fsrs-spec.org @@ -1,12 +1,20 @@ -#+TITLE: FSRS scheduler for org-drill — v0 design spec +#+TITLE: FSRS scheduler for org-drill — v1 design spec #+AUTHOR: Craig Jennings -#+DATE: 2026-05-27 +#+DATE: 2026-05-28 * Status -Draft v0 — pending decisions marked =DECIDE:= inline. The companion todo -entry is =todo.org=:125 [#B] "FSRS (Free Spaced Repetition Scheduler) -Algorithm". No implementation has started. +*Needs research — not implementation-ready.* Review 2 (2026-05-28, Codex) is folded in as Response 2: the two reversed/contradictory product points are corrected (=DRILL_CARD_WEIGHT= now matches existing org-drill semantics; the quality mapping now honors =org-drill-failure-quality= for the Again boundary), and the readiness framing is made honest. + +The product design is settled — the decisions in =Agreed decisions= below resolve every design question. Implementation is still blocked on three research prerequisites, all listed in =Open questions=: + +1. Pin the exact =py-fsrs= reference source (tag/commit, repo, file, function names). +2. Cross-check the v4.5 update equations against that pinned source. The equations in this spec are *paraphrased and unverified* — do not implement from them as written. +3. Generate and commit the reference-vector fixture from the pinned source. + +See =Review and iteration history= at the bottom for the provenance trail. + +Companion todo entry is =todo.org=:125 [#B] "FSRS (Free Spaced Repetition Scheduler) Algorithm". No implementation has started. * Context and motivation @@ -34,6 +42,10 @@ disturbing existing SM-family decks. rating quality + algorithm-specific extras). - Per-card FSRS state persisted to org properties. Existing SM-style properties stay untouched on cards that haven't been switched. +- =DRILL_CARD_WEIGHT= applies to FSRS intervals the same way it applies + to SM intervals: the same delta interpolation org-drill already uses + (=org-drill.el=:1681), preserving the existing per-card "review me more + often" affordance. Weight 2 means more frequent review, not less. - Test coverage at parity with the SM* test files: Normal / Boundary / Error per public function, plus reference-vector tests checking the algorithm output against the canonical FSRS implementation. @@ -52,38 +64,80 @@ disturbing existing SM-family decks. - *No bulk rescheduling pass.* Switching algorithms does not retroactively rewrite SCHEDULED dates on existing cards. Cards keep their current SCHEDULED until their next review. +- *No FSRS learning or relearning steps.* py-fsrs models cards as moving + through Learning → Review → Relearning states with multi-step intervals + (default 1m, 10m for learning; 10m for relearning). v1 uses org-drill's + existing daily-review scheduling model. A failed FSRS review + (=Again=) schedules same-day, matching org-drill's current failure flow, + while still updating the D/S/R state. Multi-step learning is the + separate todo.org "Graduated Intervals for New Cards" task. + +* Agreed decisions + +Locked product decisions for v1. These resolve the design questions, but +implementation is still blocked on the research prerequisites in =Status= +above (source pin, equation cross-check, reference vectors). + +| # | Decision | Value | +|---+----------+-------| +| 1 | FSRS version pin | FSRS-4.5 (17 parameters), with the v4.5 default tuple from the fsrs4anki algorithm wiki | +| 2 | Default parameters | See =Version pin= below — the exact v4.5 tuple | +| 3 | Forgetting curve | v4.5: =DECAY = -0.5=, =FACTOR = 19/81= | +| 4 | Quality mapping | Again iff quality ≤ =org-drill-failure-quality=; otherwise Hard (≤3), Good (4), Easy (5). At the default threshold of 2 this is 0/1/2 → Again, 3 → Hard, 4 → Good, 5 → Easy | +| 5 | State shape | =cl-defstruct org-drill-fsrs-state= | +| 6 | Result shape | =cl-defstruct org-drill-fsrs-result= | +| 7 | DRILL_CARD_WEIGHT applies to FSRS | Yes — same delta interpolation as SM/Simple8: =next = max(1.0, last + (computed − last) / weight)= on success intervals; weight 2 = more frequent review. The =Again= same-day path (interval 0) bypasses weight | +| 8 | Failure semantics | =Again= schedules same-day (=next-interval = 0=); D/S/R state still updates | +| 9 | First-FSRS-review cold-start message | No message (silent transition) | +| 10 | Malformed =DRILL_FSRS_*= behavior | User-facing error; scheduling untouched (data-loss safety) | +| 11 | =org-drill-fsrs-desired-retention= as file-local | Yes — safe-local, bounded 0 < x < 1 | +| 12 | DRILL_FSRS_* property ownership | Add to =org-drill-scheduling-properties=; undo/strip/copy/migration cover them via the existing list | + +* Open questions + +No open *product* questions — everything material is resolved in the table +above. But three *research prerequisites* block the v1 build, and none can +be skipped because the reference-vector tests are part of the acceptance plan: + +1. *Pin the exact =py-fsrs= source.* Tag/commit, repo, file path, and the + function names used for the formulas and vectors. Recommend the + highest-numbered v4.x release tag on + =github.com/open-spaced-repetition/py-fsrs= that still uses 17-parameter + defaults. This is a lookup, not a design question, but it gates the next two. +2. *Cross-check the equations.* The =Update equations= below are paraphrased + from secondary sources and have not been verified against the pinned + =py-fsrs=. Replace them with implementation-grade formulas read from the + pinned source before writing code. +3. *Generate the reference-vector fixture.* =tests/fixtures/fsrs-vectors.py= + produces the expected-output table the tests check against. It does not + exist yet and must be generated once from the pinned source. * Version pin -=DECIDE:= FSRS-4.5 (recommended) vs FSRS-6.x. +*FSRS-4.5* — 17 parameters, the last release whose generic defaults +provide meaningful retention improvement over SM-family scheduling +without optimization. FSRS-4.5 is also where the v4.5 forgetting curve +(=DECAY = -0.5=, =FACTOR = 19/81=) was introduced. The current stable upstream is FSRS-6.3.1 (March 2026) with 21 -parameters. FSRS-4.5 is the last release whose generic defaults provide -meaningful retention improvement over SM-family scheduling without -optimization. The FSRS community's own guidance — quoting Expertium's -algorithm explainer, March 2026 — is that "generic v6 defaults offer -minimal improvement over v4.5" because v6's gains come from optimizing -=w[17]..w[20]= on a user's data. - -Since v1 ships without an optimizer (above), the pragmatic pin is -*FSRS-4.5*: 17 parameters, well-documented defaults, the most-published -test vectors, and the version with the largest delta over SM* in the -no-optimizer regime. Upgrading to v6 becomes a separate ticket alongside -the optimizer one — by then there'd be a real reason to take v6's -additional surface. - -Reference parameters (FSRS-4.5 defaults, source: =py-fsrs= up to v4 and -the original =fsrs4anki= release notes): +parameters. The FSRS community itself notes that "generic v6 defaults +offer minimal improvement over v4.5" because v6's gains come from +optimizing =w[17]..w[20]= on a user's data. Since v1 ships without an +optimizer, v4.5 is the pragmatic pin. Upgrading to v6 becomes a +separate ticket alongside the optimizer one. + +*FSRS-4.5 default parameter tuple* (source: fsrs4anki algorithm wiki, +March 2026): #+begin_src elisp -;; w[0..16], FSRS-4.5 -(0.4 0.6 2.4 5.8 4.93 0.94 0.86 0.01 1.49 0.14 0.94 2.18 0.05 0.34 1.26 0.29 2.61) +;; w[0..16], FSRS-4.5 defaults +(0.4872 1.4003 3.7145 13.8206 5.1618 1.2298 0.8975 0.031 1.6474 + 0.1367 1.0461 2.1072 0.0793 0.3246 1.587 0.2272 2.8755) #+end_src -(=DECIDE:= confirm this exact tuple before code lands. The 4.5 defaults -were tuned twice during 4.x's life; pinning the final 4.5-stable tuple is -a one-line lookup against the =py-fsrs= 4.x release tag, deferred to -implementation.) +The reference-vector tests pin against a specific =py-fsrs= release tag +(resolved in the implementation session — see =Open questions= above) +so the test fixtures are reproducible. * Algorithm shape (v4.5) @@ -96,8 +150,19 @@ The four-rating scale FSRS uses internally: | 3 | Good | recalled normally | | 4 | Easy | trivial recall | -org-drill uses a 0–5 quality scale. The mapping respects -=org-drill-failure-quality= (default 2): +org-drill uses a 0–5 quality scale. The mapping (locked, see Agreed +decisions #4) honors =org-drill-failure-quality= for the Again boundary, +then applies a fixed sub-mapping to the successful grades: + +#+begin_src elisp +;; central failure predicate, org-drill.el:1966 — (<= quality org-drill-failure-quality) +(cond ((<= quality org-drill-failure-quality) 'again) ; failure → Again + ((<= quality 3) 'hard) + ((= quality 4) 'good) + (t 'easy)) ; quality 5 +#+end_src + +At the default threshold of 2 this is the familiar table: | org-drill quality | FSRS rating | |-------------------+-------------| @@ -106,49 +171,56 @@ org-drill uses a 0–5 quality scale. The mapping respects | 4 | Good (3) | | 5 | Easy (4) | -=DECIDE:= mapping table. Above is the natural reading of -=org-drill-failure-quality=, but org-drill historically lets users -remap session keys, and the mapping should respect that or be its -own defcustom. Simplest: pin the mapping above for v1 and add -=org-drill-fsrs-quality-mapping= as a future enhancement. +A custom =org-drill-failure-quality= shifts the Again boundary the same way +it does for the SM-family schedulers: a threshold of 3 sends quality 3 to +Again, a threshold of 1 leaves quality 2 as a success (Hard). The success +sub-mapping stays fixed so FSRS's Hard/Good/Easy grades remain stable across +threshold settings. A future =org-drill-fsrs-quality-mapping= defcustom +could let users remap the success grades; v1 keeps them fixed. ** State model -Per-card persisted state: +Per-card persisted state lives in four properties plus the existing +=DRILL_LAST_REVIEWED=: -| Property | Type | Meaning | -|-----------------------+--------+--------------------------------------| +| Property | Type | Meaning | +|-------------------------+--------+----------------------------------------| | =DRILL_FSRS_STABILITY= | float | =S=, the memory-stability estimate | | =DRILL_FSRS_DIFFICULTY= | float | =D=, the difficulty estimate (1..10) | -| =DRILL_FSRS_REVIEWS= | int | review count under FSRS (≥ 0) | -| =DRILL_FSRS_LAPSES= | int | failures (rating=Again) under FSRS | -| =DRILL_LAST_REVIEWED= | time | reused from the existing SM property | - -The last-reviewed timestamp is already written by the SM path -(=org-drill-store-item-data= writes it via =org-set-property= elsewhere in -the flow); FSRS reads the same property. - +| =DRILL_FSRS_REVIEWS= | int | review count under FSRS (≥ 0) | +| =DRILL_FSRS_LAPSES= | int | failures (rating = Again) under FSRS | +| =DRILL_LAST_REVIEWED= | time | reused from the existing SM property | + +=DRILL_LAST_REVIEWED= is written by the shared reschedule flow +(=org-drill.el=:1930, unconditional after the algorithm dispatch), not by +any algorithm-specific path. FSRS only *reads* it. =org-drill-store-fsrs-result= +writes the four =DRILL_FSRS_*= properties only; the shared flow stays the +single owner of =DRILL_LAST_REVIEWED= (see M1 in the Review dispositions). A virgin FSRS card has all four =DRILL_FSRS_*= properties absent; the scheduler treats absence as "first review." -** Update equations (v4.5 form) +The four =DRILL_FSRS_*= properties join =org-drill-scheduling-properties= +so all existing strip, undo, copy, and migration paths handle them +without additional plumbing (see =Scheduling property ownership= below). + +** Update equations (v4.5) First review (no prior FSRS state) with rating R ∈ {1..4}: #+begin_src elisp -S0 = w[R-1] ; initial stability per rating -D0 = clamp (- w[4] (* w[5] (- R 2))) 1 10 +S0 = w[R-1] ; initial stability per rating +D0 = clamp (- w[4] (* w[5] (- R 2))) 1 10 ; initial difficulty #+end_src -Subsequent review at time =elapsed-days= since last review, with current -stability =S=, difficulty =D=, and rating =R=: +Subsequent review at time =elapsed-days= since last review, with +current stability =S=, difficulty =D=, and rating =R=: #+begin_src elisp -;; Retrievability when the card is shown -R-retrieval = (expt (1+ (/ elapsed-days (* 9 S))) -1) +;; Retrievability at review time — v4.5 forgetting curve (DECAY=-0.5, FACTOR=19/81) +R-retrieval = (expt (+ 1 (* (/ 19.0 81) (/ elapsed-days S))) -0.5) ;; Difficulty update (linear with mean reversion toward w[4]) -D-delta = (* w[6] (- (- R 3))) ; positive on Again, neutral on Good +D-delta = (* w[6] (- (- R 3))) ; positive on Again, neutral on Good D-new-raw = (+ D D-delta) D-new = (+ (* w[7] w[4]) (* (- 1 w[7]) D-new-raw)) ; mean reversion D-new = (clamp D-new 1 10) @@ -169,84 +241,192 @@ S-new = w[11] * exp((1 - R-retrieval) * w[14]) #+end_src -Next interval given desired retention =DR= and new stability =S=: +Next interval given desired retention =DR= and new stability =S= +(v4.5 form, derived from R = (1 + (19/81)·t/S)^(-0.5) inverted to t): #+begin_src elisp -I = (* 9 S (- (expt DR -1) 1)) ; days, rounded down (floor) +I-raw = (* (/ 81.0 19) S (- (expt DR -2) 1)) ; days, the FSRS-computed interval + +;; DRILL_CARD_WEIGHT: same delta interpolation as SM/Simple8 (org-drill.el:1681). +;; Interpolates from the card's last interval toward the computed interval, +;; dividing the delta by weight, so weight 2 = more frequent review. Applied +;; only on success; the Again path below returns 0 and bypasses this. +I = (if (and (numberp weight) (cl-plusp weight)) + (max 1.0 (+ last-interval (/ (- I-raw last-interval) weight))) + I-raw) +I = (max 0 (floor I)) #+end_src -Default desired retention is *0.9* (a common Anki default). Pinned as -=org-drill-fsrs-desired-retention= defcustom. +Default desired retention is *0.9*, the common Anki default. Pinned +as the =org-drill-fsrs-desired-retention= defcustom. -=DECIDE:= the exact form of the v4.5 update equations above is paraphrased -from Expertium's algorithm walkthrough (March 2026, v6-oriented) and the -=py-fsrs= reference implementation. Implementation will cross-check the -exact constants and grouping against a tagged =py-fsrs= v4.x release -before writing the function body. +*Failure semantics.* =Again= (R = 1) returns =I = 0= so org-drill's +existing failure flow re-queues the card same-day, while +=S-new= / =D-new= still update from the lapse equations above. This +keeps v1 inside the daily-review scheduling model (see Non-goals). ** Default parameters (org-drill defcustoms) #+begin_src elisp (defcustom org-drill-fsrs-parameters - '(0.4 0.6 2.4 5.8 4.93 0.94 0.86 0.01 1.49 0.14 0.94 2.18 0.05 0.34 1.26 0.29 2.61) - "17-tuple FSRS-4.5 default parameters. See =docs/design/fsrs-spec.org=.") + '(0.4872 1.4003 3.7145 13.8206 5.1618 1.2298 0.8975 0.031 1.6474 + 0.1367 1.0461 2.1072 0.0793 0.3246 1.587 0.2272 2.8755) + "17-tuple FSRS-4.5 default parameters. See =docs/design/fsrs-spec.org= +for the algorithm and the parameter-tuple source." + :group 'org-drill-algorithm + :type '(repeat number)) (defcustom org-drill-fsrs-desired-retention 0.9 - "Target retention for FSRS scheduling. Higher = shorter intervals.") + "Target retention for FSRS scheduling. Higher = shorter intervals. +Bounded 0 < x < 1. Safe as a file-local variable." + :group 'org-drill-algorithm + :type 'float + :safe (lambda (v) (and (numberp v) (< 0 v 1)))) #+end_src +* Scheduling property ownership + +The four =DRILL_FSRS_*= properties are org-drill scheduling state and +participate in every workflow that already handles SM scheduling +properties. V1 adds them to =org-drill-scheduling-properties= so the +existing helpers cover them without per-call plumbing: + +| Workflow | Helper / call site | Behavior | +|----------+---------------------+----------| +| Rating snapshot for undo | =org-drill--snapshot-entry-data= | Captures =DRILL_FSRS_*= alongside SM properties | +| Undo last rating | =org-drill-undo-last-rating= → =org-drill--restore-entry-data= | Restores =DRILL_FSRS_*= verbatim | +| Strip single entry | =org-drill-strip-entry-data= | Removes =DRILL_FSRS_*= | +| Strip all entries | =org-drill-strip-all-data= | Removes =DRILL_FSRS_*= across the scope | +| Copy / share to marker | =org-drill--copy-scheduling-to-marker= | Migrates =DRILL_FSRS_*= | +| Org property completion | =org-drill-scheduling-properties= consumers | =DRILL_FSRS_*= appear in completion | + +*Implementation.* Add the four FSRS property names to the +=org-drill-scheduling-properties= defvar list. No new branching; +existing iterations over that list pick the FSRS properties up +automatically. + +*Tests.* Each of the six workflows above gets an FSRS-specific +regression test pinning the property-set behavior (see =Test +strategy=). + +* Validation and malformed-state behavior + +** Parameter validation + +- =org-drill-fsrs-parameters=: exactly 17 numbers. Fewer or more + signals a user-error at FSRS-arm dispatch time with a message naming + the count mismatch. +- =org-drill-fsrs-desired-retention=: strictly between 0 and 1 + (exclusive). Out-of-range values signal a user-error. +- The defcustom :safe predicates handle the file-local case so a + malformed file-local value doesn't crash org-drill load. + +** Per-card malformed state + +If any =DRILL_FSRS_*= property is present but not parseable as the +expected type (e.g. =DRILL_FSRS_STABILITY= contains "nan" or a stray +string), =org-drill-get-fsrs-state= signals a clear user-facing error +and refuses to schedule the card, leaving the existing +SCHEDULED/DRILL_FSRS_* state untouched. Rationale: data-loss safety — +silently treating malformed state as "virgin" would erase the user's +actual FSRS history on the next save. + +The error message names the property, the card heading, and the +offending value so the user can find and fix it manually. + * Integration points ** Public function #+begin_src elisp -(defun org-drill-determine-next-interval-fsrs (state quality) - "Return next-interval (in days) plus updated FSRS state for STATE after -QUALITY (0-5). STATE here is a 5-tuple (S D reviews lapses last-reviewed), -not an `org-drill-card-state' — FSRS stores its own state and treats the SM -slots as opaque.") +(cl-defstruct org-drill-fsrs-state + "FSRS per-card scheduling state, read from DRILL_FSRS_* properties. + +Slots: +- stability :: float, S, memory-stability estimate +- difficulty :: float, D, difficulty estimate (1..10) +- reviews :: int, total reviews under FSRS (≥ 0) +- lapses :: int, lapses under FSRS (≥ 0) +- last-reviewed :: time, DRILL_LAST_REVIEWED (shared with SM)" + stability difficulty reviews lapses last-reviewed) + +(cl-defstruct org-drill-fsrs-result + "Result of one FSRS scheduling computation. + +Slots: +- next-interval :: integer days, post-weight, ≥ 0 +- new-stability :: updated S +- new-difficulty :: updated D +- new-reviews :: incremented review count +- new-lapses :: incremented lapse count if R = Again" + next-interval new-stability new-difficulty new-reviews new-lapses) + +(defun org-drill-determine-next-interval-fsrs (state quality &optional card-weight) + "Run one FSRS scheduling step. + +STATE is an `org-drill-fsrs-state' or nil (virgin card). +QUALITY is the org-drill 0-5 rating. +CARD-WEIGHT is the DRILL_CARD_WEIGHT (default 1.0). + +Returns an `org-drill-fsrs-result' with the new D/S/R, the updated +review and lapse counts, and the next interval in days (after the +weight delta-interpolation, floored, clamped at zero).") #+end_src -The shape =(state quality)= matches the post-#147 scheduler signature -convention. =state= for FSRS is a *separate* struct =org-drill-fsrs-state= -(or a plist — =DECIDE:= which), populated from the =DRILL_FSRS_*= -properties at the call site. - -=DECIDE:= the return shape. Options: - -1. Same shape as the SM* schedulers (a list with =next-interval= - first plus updated state). Consistent but the trailing slots are - irrelevant to FSRS. -2. A small =org-drill-fsrs-result= struct (=next-interval=, - =new-stability=, =new-difficulty=, =new-reviews=, =new-lapses=). - Cleaner for FSRS but introduces a return-shape special case in the - =cl-case= caller. - -Recommended: option 2. =cl-case= already special-cases =new-ofmatrix= -for SM5; one more is fine and the FSRS state shape is genuinely different. +The =(state quality)= shape matches the post-#147 scheduler signature +convention. =state= for FSRS is the FSRS-specific struct, populated +from the =DRILL_FSRS_*= properties at the call site. ** Caller integration -In =org-drill-smart-reschedule=: +=org-drill-smart-reschedule= gets a dedicated =fsrs= arm before the +SM-shaped destructuring, because the FSRS result struct does not match +the SM positional list shape: #+begin_src elisp -;; sketch — exact destructure shape TBD per the return-shape DECIDE (fsrs - (let ((fsrs-state (org-drill-get-fsrs-state))) - (org-drill-fsrs-apply-result - (org-drill-determine-next-interval-fsrs fsrs-state quality)))) + (let* ((fsrs-state (org-drill-get-fsrs-state)) + (weight (org-drill--card-weight)) ; DRILL_CARD_WEIGHT, defaults to 1.0 + (result (org-drill-determine-next-interval-fsrs + fsrs-state quality weight))) + (org-drill-store-fsrs-result result) ; writes DRILL_FSRS_* only + (org-schedule nil (format "+%dd" (org-drill-fsrs-result-next-interval result))))) #+end_src -Two new helpers parallel the existing item-data round-trip: +=org-drill-hypothetical-next-review-date= mirrors this without the +store-side-effect — it reads state, computes the result, and returns +the interval without writing anything. The "read state, compute, +discard result" pattern keeps the prompt preview side-effect-free. -- =org-drill-get-fsrs-state= — reads the =DRILL_FSRS_*= properties, returns - the state struct or nil for a virgin card. -- =org-drill-store-fsrs-state= (or =org-drill-fsrs-apply-result=) — writes - the updated state back. The =SCHEDULED= timestamp is set via - =org-schedule= using the returned =next-interval=, the same way - =smart-reschedule= already does for SM*. +Two new helpers parallel the existing item-data round-trip: -Same in =hypothetical-next-review-date= (read-only, no store). +- =org-drill-get-fsrs-state= — reads the four =DRILL_FSRS_*= + properties. Returns an =org-drill-fsrs-state= or nil for a virgin + card. Raises a clear user-error on malformed values (see + =Validation= above). +- =org-drill-store-fsrs-result= — writes the four =DRILL_FSRS_*= + properties back from a result struct. It does *not* write + =DRILL_LAST_REVIEWED= — the shared reschedule flow (=org-drill.el=:1930) + owns that for every algorithm. + +** Architecture: pure formula core, thin IO shell + +The implementation separates pure functions from property IO so each +layer is independently testable: + +| Layer | Functions | Tested in | +|-------+-----------+-----------| +| Pure quality mapping | =org-drill--fsrs-rating-from-quality= | mapping tests | +| Pure retrievability | =org-drill--fsrs-retrievability= | reference-vector tests | +| Pure initial state | =org-drill--fsrs-initial-state= | reference-vector tests | +| Pure update step | =org-drill--fsrs-update= | reference-vector tests | +| Pure interval calc | =org-drill--fsrs-interval= | reference-vector tests | +| Property IO | =org-drill-get-fsrs-state=, =org-drill-store-fsrs-result= | round-trip tests | +| Orchestration | =org-drill-determine-next-interval-fsrs= | integration tests | + +The =cl-case= branch in =org-drill-smart-reschedule= is the +orchestration layer. Everything below the branch is pure or +property-IO-only. ** Defcustom entry @@ -258,6 +438,21 @@ Same in =hypothetical-next-review-date= (read-only, no store). and the state model. #+end_src +Three places currently enumerate the three SM-family algorithms and all +need =fsrs= added (M2 from Review 2): + +- The =org-drill-spaced-repetition-algorithm= defcustom =:type= choice + list (=org-drill.el=:541) — add =(const fsrs)=. +- The =cl-case= dispatch in =org-drill-smart-reschedule= and + =org-drill-hypothetical-next-review-date= — the new =fsrs= arm (covered + in =Caller integration= above). +- The safe-local-variable predicate (=org-drill.el=:942), currently + =(memq val '(simple8 sm5 sm2))= — add =fsrs= so a file-local + =org-drill-spaced-repetition-algorithm: fsrs= is accepted. + +A test asserts all four symbols are accepted by the safe-local predicate +and present in the defcustom choice list. + * Backward compatibility A card last reviewed under SM* has =DRILL_LAST_INTERVAL=, @@ -274,81 +469,206 @@ FSRS review after a switch: The cold-start tradeoff is a known one-time loss of the SM-derived estimate of card difficulty. Anki's migration heuristic estimates initial =D= and =S= from the SM2 ease factor and interval; pulling that in -is its own ticket (=todo:= "FSRS migration heuristic from SM history"). +is its own ticket (todo: "FSRS migration heuristic from SM history"). -=DECIDE:= whether to also surface a one-line "first FSRS review on this -card — cold-starting" message during the prompt for the first FSRS review. -Probably not — users don't need to know. +V1 does NOT surface a "first FSRS review on this card" message — the +transition is silent (per Agreed decisions #9). A per-card notice +would be noisy and doesn't change what the user can do. * Test strategy -Three categories per public function, matching the existing scheduler -test files: - -1. =tests/test-org-drill-determine-next-interval-fsrs.el= — Normal / - Boundary / Error coverage of =org-drill-determine-next-interval-fsrs=. - *Reference-vector tests* check =(state, rating) -> (next-interval, - new-state)= against pre-computed outputs from =py-fsrs= at a tagged - v4.x release. About a dozen vectors covering the four ratings × first - review / nth review / lapse / long-elapsed-time, plus boundary ones - (=elapsed-days=0=, =D= and =S= at clamp limits). - -2. =tests/test-org-drill-fsrs-state-roundtrip.el= — get-fsrs-state / - store-fsrs-state round-trip parity, mirroring the SM - item-data-roundtrip test. - -3. =tests/test-org-drill-fsrs-integration.el= — end-to-end through - =smart-reschedule= with =org-drill-spaced-repetition-algorithm= set to - =fsrs=, covering: virgin card first review, returning card under - FSRS, switched-from-SM card cold-starts cleanly, both algorithms can - coexist on the same buffer. - -Mapping helpers (org-drill quality 0–5 → FSRS rating 1–4) and the -defcustoms get their own small tests. +Four test files cover the FSRS work, matching the existing +file-per-area convention: + +** =tests/test-org-drill-determine-next-interval-fsrs.el= + +Normal / Boundary / Error coverage of the public function, plus +*reference-vector tests* that check =(state, quality, weight) → +(next-interval, new-state)= against pre-computed outputs from +=py-fsrs= at the pinned v4.x release tag. + +Reference vectors cover, at minimum: + +- All six org-drill qualities (0, 1, 2, 3, 4, 5) on a virgin card. +- All six qualities on a returning card with non-trivial elapsed days. +- A lapse path (Again on a returning card) confirming D/S/R update + AND =next-interval = 0=. +- Long-elapsed-days case (e.g. 365 days since last review). +- =elapsed-days = 0= boundary (same-day review). +- =D= and =S= near clamp limits (D = 1.0, D = 10.0, S near 0). +- =card-weight = 1.0= (default, no change) and =card-weight = 2.0= + (verify the delta interpolation: a weight-2 success interval lands + between the last interval and the computed interval, i.e. shorter than + computed = more frequent — not 2× longer). +- =Again= with a non-default weight confirms the failure path returns 0 + and bypasses the weight interpolation. + +Quality-mapping tests for the 0..5 → Again/Hard/Good/Easy mapping, +including verification that a custom =org-drill-failure-quality= shifts +the Again boundary while the Hard/Good/Easy sub-mapping stays fixed +(threshold 1, 2, and 3 cases). + +** =tests/test-org-drill-fsrs-state-roundtrip.el= + +=org-drill-get-fsrs-state= and =org-drill-store-fsrs-result= round-trip +parity, mirroring the SM item-data-roundtrip test. + +Cases: + +- Virgin card (all four properties absent) → state is nil. +- Complete valid properties → state matches. +- Partial properties (some present, some absent) → user-error. +- Malformed numeric value (e.g. =DRILL_FSRS_STABILITY: "garbage"=) → + user-error, scheduling untouched. +- Store-then-read round-trip preserves float precision within + reasonable bounds. + +** =tests/test-org-drill-fsrs-integration.el= + +End-to-end through =smart-reschedule= with +=org-drill-spaced-repetition-algorithm= set to =fsrs=: + +- Virgin card first review (each of the six qualities). +- Returning card under FSRS. +- =days-ahead= override path (explicit interval). +- Switched-from-SM card cold-starts cleanly (no SM property loss). +- Both algorithms coexist on the same buffer (one card SM, one card + FSRS). +- =DRILL_CARD_WEIGHT= applied (weight = 2 → success interval interpolated + toward the computed value over the last interval, i.e. shorter than the + weight-1 result, matching SM/Simple8). +- =org-drill-hypothetical-next-review-date= for FSRS is read-only + (calling it does not write any property). + +** =tests/test-org-drill-fsrs-scheduling-property-ownership.el= + +The secondary-workflow regressions for property ownership: + +- =org-drill-undo-last-rating= restores =DRILL_FSRS_*= verbatim. +- =org-drill-strip-entry-data= removes =DRILL_FSRS_*= for a single + entry. +- =org-drill-strip-all-data= removes =DRILL_FSRS_*= across the scope. +- =org-drill--copy-scheduling-to-marker= migrates =DRILL_FSRS_*= to + the target. +- =org-drill-scheduling-properties= membership: assertion that the + four FSRS property names are in the list. + +** Defcustom validation tests + +A small file for the validation logic — exact-17 tuple count, +0 < retention < 1, safe-local predicate for retention. + +** Reference-vector generation The reference vectors get committed alongside the tests as a -machine-checkable expected-output table, generated once from =py-fsrs= -and pinned. The generation script lives at =tests/fixtures/fsrs-vectors.py= -so re-generation is reproducible. +machine-checkable expected-output table, generated once from +=py-fsrs= at the pinned tag. The generation script lives at +=tests/fixtures/fsrs-vectors.py= so re-generation is reproducible. +The =py-fsrs= tag is recorded in a comment at the top of the fixture +file. * Effort estimate Multi-day, plausibly spanning sessions: -- Algorithm function + helpers: 1 day. -- State round-trip + integration in =smart-reschedule=/=hypothetical=: 0.5 - day. -- Test scaffolding + reference vectors + Normal/Boundary/Error: 1 day. -- Documentation (manual entry, README option list, defcustom docstrings, - this spec ratified): 0.5 day. - -Realistic: a session to implement-and-test the algorithm + state -round-trip with reference-vector tests; a follow-up session for the -integration + docs. No optimizer. - -* Open decisions index - -Pinned in one place for the implementation gate: - -- =DECIDE:= version pin (FSRS-4.5 vs newer). Recommended: 4.5. -- =DECIDE:= the exact FSRS-4.5 default-parameter tuple. One-line lookup - against the =py-fsrs= 4.x final release. -- =DECIDE:= quality-mapping table (0–5 → 1–4). Recommended: above. -- =DECIDE:= scheduler return shape — list (SM-shape) or - =org-drill-fsrs-result= struct. Recommended: struct. -- =DECIDE:= whether to surface a "first FSRS review on this card" - message. Recommended: no. -- =DECIDE:= the exact form of the update equations after cross-checking - =py-fsrs= 4.x source. +- Pure formula functions (quality-map, retrievability, initial-state, + update, interval) + their unit tests: 0.5 day. +- Property IO (=org-drill-get-fsrs-state=, + =org-drill-store-fsrs-result=) + round-trip tests: 0.5 day. +- =cl-case= integration in =smart-reschedule= and + =hypothetical-next-review-date= + integration tests: 0.5 day. +- Property-ownership additions to =org-drill-scheduling-properties= + + the four secondary-workflow regression tests: 0.5 day. +- Reference-vector fixture generation + the reference-vector tests + + the validation tests: 0.5 day. +- Documentation (README option list, defcustom docstrings, manual + entry): 0.25 day. + +Realistic: two sessions. Session 1 ships the pure formula core, the +state round-trip, and the integration through smart-reschedule. +Session 2 ships the property-ownership secondary-workflow tests, the +reference vectors, the validation tests, and the docs. + +* Review dispositions + +Modified or rejected recommendations from prior reviews. Accepted +recommendations are woven into the body above and do not need +disposition entries — the change is the record. + +** Review 1 (2026-05-28) — modified items + +*H1 (Version mismatch).* The reviewer offered "pin FSRS v4 OR pin +FSRS-4.5 with the correct tuple." Accepted the *v4.5* side of that +choice (matches the spec's stated intent throughout) and replaced the +v4 tuple at line 80 with the reviewer's verified v4.5 tuple from the +fsrs4anki algorithm wiki. The interval and retrievability equations were +updated to the v4.5 forgetting-curve form (=DECAY=-0.5=, +=FACTOR=19/81=). Rejected the alternative (revert to v4) because +v4.5 has more published test vectors and a larger no-optimizer +delta over SM-family. + +*M2 (Stale docs paths).* Resolved by the unrelated +=docs/design/= relocation commit landed earlier this session. The +two in-spec references at lines 192 and 257 now point at +=docs/design/fsrs-spec.org=. + +Everything else from Review 1 accepted as written. + +** Review 2 (2026-05-28, Codex) — dispositions + +*B1 (readiness framing contradiction).* Accepted. Response 1 said both +"implementation can proceed" and listed unmet research prerequisites. +Reframed =Status= and =Open questions= to state "Needs research" plainly +and enumerate the three blocking prerequisites (source pin, equation +cross-check, reference vectors). + +*B2 (DRILL_CARD_WEIGHT reversed).* Accepted, and the root cause was mine: +Response 1 said FSRS multiplies the interval by weight, on my incorrect +description of how SM handles weight. The real mechanism (=org-drill.el=:1681) +divides the interval *delta* by weight, so weight 2 means more frequent +review. Craig chose to match the existing semantics exactly. Fixed the +Goals bullet, decision #7, the interval equation, and the weight tests. + +*B3 (quality mapping contradiction).* Accepted. Response 1 locked a fixed +table, claimed it respected =org-drill-failure-quality=, and tested a +custom threshold — mutually inconsistent. Craig chose to honor the +threshold for the Again boundary with a fixed Hard/Good/Easy sub-mapping. +Fixed decision #4, the algorithm mapping section, and the mapping tests. + +*M1 (DRILL_LAST_REVIEWED ownership).* Accepted. The shared reschedule +flow (=org-drill.el=:1930) already owns =DRILL_LAST_REVIEWED= for every +algorithm; =org-drill-store-fsrs-result= now writes =DRILL_FSRS_*= only. + +*M2 (algorithm symbol coverage).* Accepted. Added an explicit bullet that +=fsrs= joins the defcustom choice list (=org-drill.el=:541) and the +safe-local whitelist (=org-drill.el=:942), with a test. + +*M3 (stale todo).* Already resolved — the =todo.org= FSRS entry was +updated to "not implementation-ready as of Review 2" before this response. + +* Review and iteration history + +| Iteration | Date | Contributor | Role | What changed or was recommended | Why | +|------------+------------+---------------+-----------+----------------------------------+-----| +| Draft v0 | 2026-05-27 | Craig Jennings | Author | Initial draft with 6 DECIDE markers and recommendations baked in for each. Same shape as the stats-dashboard spec. | Establish a written design before any FSRS code lands. | +| Review 1 | 2026-05-28 | External reviewer | Reviewer | Four blocking findings (version/tuple/equation mismatch, scheduler/state result shape, property ownership across strip/undo/copy/migration, learning/relearning scope) plus two medium, plus UX/Architecture/Robustness observations and full test-strategy expansion. | An external read of the spec against the existing codebase caught the v4-vs-v4.5 drift and several missing secondary-workflow paths. | +| Response 1 | 2026-05-28 | Claude Code | Responder | All four blocking findings resolved; medium-priority items accepted; new sections added (Agreed decisions, Scheduling property ownership, Validation and malformed-state behavior); test strategy expanded to four files; v4 → v4.5 tuple and equations corrected; struct shapes locked; DRILL_CARD_WEIGHT confirmed to apply (Craig's call). | Convert the spec from "not implementation-ready" to "implementation-ready" via the spec-response workflow. Removes invented-product-behavior risk for the implementer. | +| Review 2 | 2026-05-28 | Codex | Reviewer | Marked the spec as needs-research before implementation; identified unresolved =py-fsrs= source pin/formula/vector prerequisites, =DRILL_CARD_WEIGHT= semantics reversed against current org-drill, and a quality-mapping conflict with =org-drill-failure-quality=. | Applying the updated spec-review workflow to the moved =docs/design/= spec caught contradictions introduced while preserving Review 1 decisions. | +| Response 2 | 2026-05-31 | Claude Code | Responder | Corrected =DRILL_CARD_WEIGHT= to the real SM/Simple8 delta-interpolation (Craig's call to match existing semantics); resolved the quality mapping to honor =org-drill-failure-quality= for the Again boundary with a fixed success sub-mapping (Craig's call); reframed status/open-questions to "Needs research" with the three blocking prerequisites stated honestly; fixed =DRILL_LAST_REVIEWED= ownership (M1) and the algorithm-symbol coverage (M2); flagged the update equations as paraphrased-and-unverified. | Fix the three blockers and two mediums from Review 2. The B2 reversal traced to a wrong verbal description of SM weight handling in Response 1; this response grounds every claim in the cited =org-drill.el= line. | * References - [[https://github.com/open-spaced-repetition/py-fsrs][py-fsrs]] — the canonical Python reference implementation. Tagged - v4.x releases pin FSRS-4.5; v6.x is the current line. + v4.x releases pin FSRS-4.5; v6.x is the current line. The exact + pinned tag is resolved during implementation (see =Open questions=). - [[https://github.com/open-spaced-repetition/fsrs4anki][fsrs4anki]] — the Anki integration; its wiki has user-facing notes on algorithm history and migration. +- [[https://github-wiki-see.page/m/shigeyukey/fsrs4anki/wiki/The-Algorithm][fsrs4anki Algorithm wiki]] — source of the v4.5 default-parameter + tuple and the v4.5 forgetting-curve constants. - [[https://expertium.github.io/Algorithm.html][Expertium: A technical explanation of FSRS]] (March 2026, v6-oriented) — the most accessible English-language walkthrough of the formulas and parameter roles. - [[https://help.remnote.com/en/articles/9124137-the-fsrs-spaced-repetition-algorithm][RemNote: The FSRS Spaced Repetition Algorithm]] — third-party implementer's overview, useful as a sanity check on terminology. +- =docs/design/stats-dashboard.org= — sister v0 spec, same + DECIDE-marker convention authored by Craig. |
