wordeck-native/FSRS_PORT.md
Till JS d3311ba6db chore(fsrs): Groundwork für Event-Sync-Migration — FSRS-6-Spec + Golden-Fixtures
Vorbereitung der wordeck-native-Migration auf ManaEventSync (E-4.4b).
FSRS_PORT.md: exakter ts-fsrs-v5.3.2/FSRS-6-Algorithmus (21 Gewichte +
alle Formeln + State-Machine) für den paritätskritischen Pure-Swift-Port.
Tests/UnitTests/Fixtures/fsrs_golden.json: echte ts-fsrs-Referenz-Vektoren
(9 Rating-Sequenzen, enable_fuzz:false) als Golden-Fixtures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:01:53 +02:00

5 KiB
Raw Blame History

FSRS-6 Pure-Swift-Port — Spec (für wordeck-native Event-Sync-Migration)

Quelle: ts-fsrs@5.3.2 (meldet sich als „v5.3.2 using FSRS-6.0"). Wordeck-Web berechnet die Karten-Planung client-seitig über @wordeck/domain (gradeReviewscheduler.next(card, reviewedAt, rating)) und emittet ReviewGraded mit den fertigen Feldern. Native muss denselben Algorithmus byte-paritätisch nachbauen (Server-Grade-Endpoint ist tot/410). Falsch = still verfälschte Wiederholungs-Pläne.

Golden-Fixtures: Tests/UnitTests/Fixtures/fsrs_golden.json — echte ts-fsrs-Outputs (9 Rating-Sequenzen, enable_fuzz:false, base 2026-05-08T10:00Z, „review-when-due"). Der Swift-Port muss diese reproduzieren (Toleranz: Doubles auf 8 Nachkommastellen wie roundTo(.,8)).

Parameter (FSRS-6, 21 Gewichte)

w = [0.212, 1.2931, 2.3065, 8.2956, 6.4133, 0.8334, 3.0194, 0.001,
     1.8722, 0.1666, 0.796, 1.4835, 0.0614, 0.2629, 1.6483, 0.6014,
     1.8729, 0.5425, 0.0912, 0.0658, 0.1542]

Konstanten: request_retention=0.9, maximum_interval=36500, enable_fuzz=false (Default + wordeck-Tests), enable_short_term=true, learning_steps=["1m","10m"], relearning_steps=["10m"], S_MIN=0.001, S_MAX=36500, INIT_S_MAX=100.

DECAY = -w[20] = -0.1542. FACTOR = exp(ln(0.9)/DECAY) - 1 ≈ 0.98147 (via computeDecayFactor, roundTo(.,8)). Achtung Vorzeichen: im Code ist decay = -w[20] (negativ), forgetting_curve nutzt pow(1+factor*t/S, decay).

Formeln (alle Ergebnisse roundTo(.,8))

  • Retrievability: R(t,S) = (1 + FACTOR·t/S)^DECAY
  • Interval-Modifier: IM = (request_retention^(1/DECAY) - 1) / FACTOR
  • next_interval: clamp(max(1, round(S·IM)), 1, maximum_interval) (+ Fuzz, hier aus)
  • init_stability(g): max(w[g-1], 0.1) (g: Again=1,Hard=2,Good=3,Easy=4)
  • init_difficulty(g): w[4] - exp((g-1)·w[5]) + 1, dann clamp(.,1,10)
  • linear_damping(Δd, D): Δd·(10-D)/9
  • mean_reversion(init, cur): w[7]·init + (1-w[7])·cur
  • next_difficulty(D,g): Δd=-w[6]·(g-3); nd=D+linear_damping(Δd,D); clamp(mean_reversion(init_difficulty(Easy), nd), 1, 10)
  • next_recall_stability(D,S,R,g): S·(1 + exp(w[8])·(11-D)·S^(-w[9])·(exp((1-R)·w[10])-1)·hard·easy), hard=w[15] if g==Hard else 1, easy=w[16] if g==Easy else 1, clamp [S_MIN,36500]
  • next_forget_stability(D,S,R): w[11]·D^(-w[12])·((S+1)^w[13]-1)·exp((1-R)·w[14]), clamp [S_MIN,36500]
  • next_short_term_stability(S,g): sinc = S^(-w[19])·exp(w[17]·(g-3+w[18])); if g>=Hard: sinc=max(sinc,1); clamp(S·sinc, S_MIN, 36500)

next_state (DSR-Memory-Update)

if D==0 && S==0:                  // neue Karte
    D = clamp(init_difficulty(g),1,10); S = init_stability(g)
elif t==0 && enable_short_term:   // gleicher Tag
    S = next_short_term_stability(S,g); D = next_difficulty(D,g)
elif g==Again:                    // vergessen
    s_forget = next_forget_stability(D,S,R)
    s_min = S / exp(w[17]·w[18])   // (w17,w18 nur wenn short_term)
    S = clamp(s_min, S_MIN, s_forget); D = next_difficulty(D,g)
else:                             // Hard/Good/Easy
    S = next_recall_stability(D,S,R,g); D = next_difficulty(D,g)

R = forgetting_curve(t, S) falls nicht übergeben (t=elapsed_days).

BasicScheduler-State-Machine (learning_steps)

Das ist der fiddly Teil — exakte Quelle: index.mjs BasicScheduler (newState/learningState/reviewState, ~Zeilen 10241273). Kernregeln:

  • New + Again/Hard/Good → Learning, due nach learning_steps[i] (1m/10m), scheduled_days=0; Good am letzten Step → Review (graduate, next_interval). Easy → Review sofort.
  • Learning/Relearning + Good am letzten Step → Review (graduate); Again → zurück auf Step 0; Hard → halten/Step; Easy → graduate.
  • Review + Again → Relearning (lapses++, relearning_steps), Hard/Good/Easy → Review, due via next_interval.
  • reps++ pro Grade; elapsed_days = days(now - last_review); last_review = now.

Beim Port: gegen fsrs_golden.json testen — die 9 Sequenzen decken new→ alle Ratings, Multi-Step-Learning, Lapse→Relearning, Graduation ab.

Datenmigration (Cache → Events)

CachedDueReview trägt die FSRS-Felder schon (stability/difficulty/ elapsed_days/scheduled_days/learning_steps/reps/lapses/state/last_review/ due). → pro Reihe ReviewInitialized + (falls reps>0) ein synthetisches ReviewGraded mit genau diesen Feldern (kein Re-Compute nötig — wir übernehmen den Server-Stand 1:1). PendingGrade nach Sync-Init via echtem gradeReview replayen (FSRS-Port nötig).

Offene Punkte

  • Fuzz (deterministisch, Alea-PRNG, seed {time}_{reps}_{D·S}): erst nötig wenn enable_fuzz aktiviert wird — wordeck nutzt es nicht. Port kann Fuzz vorerst weglassen, muss aber denselben Default (aus) halten.
  • 19-vs-21-Gewichte: wordeck-Settings erlauben 19er-w (FSRS-5); ts-fsrs migriert intern auf 21. Falls eine Deck-Setting 19 Werte liefert, dieselbe migrateParameters-Logik anwenden.