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>
5 KiB
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 (gradeReview → scheduler.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, dannclamp(.,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 1024–1273). 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 vianext_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 wennenable_fuzzaktiviert 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, dieselbemigrateParameters-Logik anwenden.