/** * FSRS-6 – das Gedächtnismodell hinter der Wiedervorlage. * * FSRS (Free Spaced Repetition Scheduler) beschreibt den Gedächtnisstand einer * Frage durch zwei Zahlen: * * - **Stabilität** `S` (in Tagen): die Zeitspanne, nach der die * Abrufwahrscheinlichkeit auf 90 % gefallen ist. Je größer, desto fester * sitzt der Stoff. * - **Schwierigkeit** `D` (1 bis 10): wie mühsam sich die Frage merken lässt. * Sie wächst bei Fehlern und sinkt bei mühelosen Treffern. * * Daraus folgt die **Abrufwahrscheinlichkeit** `R`: die geschätzte Chance, die * Frage jetzt richtig zu beantworten. Sie ist der eigentliche Nutzen des * Modells – aus ihr lassen sich sowohl der nächste Termin als auch eine * ehrliche Prognose für den Prüfungstag ableiten. * * ## Herkunft und Genauigkeit * * Die Formeln und Vorgabeparameter sind aus der Referenzimplementierung * py-fsrs (open-spaced-repetition, MIT) übernommen und in * `tests/fsrs.test.ts` gegen deren Kennwerte abgesichert. Bewusst nachgebaut * statt als Abhängigkeit eingebunden: Das Modell ist der didaktische Kern der * Anwendung und soll ohne Fremdcode nachvollziehbar bleiben. Jede Formel steht * deshalb einzeln und benannt hier. * * FSRS selbst ist ein Algorithmus, kein Programm – seine Verwendung ist an * keine Lizenz gebunden. Die Herkunft ist trotzdem im Bereich „Über diese * Software“ ausgewiesen. * * ## Was hier NICHT steht * * FSRS plant für dauerhaftes Behalten ohne Stichtag. Diese Anwendung lernt auf * einen Prüfungstermin hin. Die Anpassung daran – steigende Zielquote, * ausdrücklich **ohne** Deckelung am Termin – steht getrennt in * `lernplan.ts`, damit erkennbar bleibt, was Referenzverfahren ist und was * eigene Entscheidung. * * Dieser Satz nannte bis Fassung 0.16.0 eine „Deckelung am Termin“, die es * nie gab und die `lernplan.ts` zwei Dateien weiter ausdrücklich verneint. * Warum es sie nicht gibt, steht in `docs/entscheidung-lernphasen.md`. */ import type { Bewertung } from './lernstand'; /** Bewertung einer Antwort – entspricht den FSRS-Graden 1 bis 4. */ export type FsrsGrad = 1 | 2 | 3 | 4; export const GRAD_NOCHMAL: FsrsGrad = 1; export const GRAD_SCHWER: FsrsGrad = 2; export const GRAD_GUT: FsrsGrad = 3; export const GRAD_LEICHT: FsrsGrad = 4; /** * Die 21 Vorgabeparameter von FSRS-6. * * Sie stammen aus der Auswertung mehrerer Millionen echter Wiederholungen und * gelten für den Durchschnitt. FSRS kann sie aus der eigenen Historie * nachtrainieren; das setzt einige hundert Wiederholungen voraus und ist hier * noch nicht umgesetzt. Bis dahin sind die Vorgabewerte die beste verfügbare * Schätzung – und deutlich besser als ein festes Verdopplungsschema. */ export const FSRS_PARAMETER: readonly number[] = Object.freeze([ /* 0 */ 0.212, // Anfangsstabilität nach „nochmal“ /* 1 */ 1.2931, // Anfangsstabilität nach „schwer“ /* 2 */ 2.3065, // Anfangsstabilität nach „gut“ /* 3 */ 8.2956, // Anfangsstabilität nach „leicht“ /* 4 */ 6.4133, // Anfangsschwierigkeit: Achsenabschnitt /* 5 */ 0.8334, // Anfangsschwierigkeit: Steigung über den Grad /* 6 */ 3.0194, // Schwierigkeitsänderung je Grad /* 7 */ 0.001, // Rückzug zur Mitte /* 8 */ 1.8722, // Stabilitätszuwachs: Grundfaktor /* 9 */ 0.1666, // Stabilitätszuwachs: Dämpfung durch bisherige Stabilität /* 10 */ 0.796, // Stabilitätszuwachs: Belohnung für späte Wiederholung /* 11 */ 1.4835, // Stabilität nach Vergessen: Grundfaktor /* 12 */ 0.0614, // Stabilität nach Vergessen: Dämpfung durch Schwierigkeit /* 13 */ 0.2629, // Stabilität nach Vergessen: Einfluss der alten Stabilität /* 14 */ 1.6483, // Stabilität nach Vergessen: Einfluss des Abrufs /* 15 */ 0.6014, // Abschlag für „schwer“ /* 16 */ 1.8729, // Zuschlag für „leicht“ /* 17 */ 0.5425, // Wiederholung am selben Tag: Grundfaktor /* 18 */ 0.0912, // Wiederholung am selben Tag: Verschiebung /* 19 */ 0.0658, // Wiederholung am selben Tag: Dämpfung /* 20 */ 0.1542, // Abfall der Vergessenskurve ]); /** Untergrenze der Stabilität. Verhindert Division durch null. */ export const MIN_STABILITAET = 0.001; const MIN_SCHWIERIGKEIT = 1; const MAX_SCHWIERIGKEIT = 10; /** Gedächtnisstand einer einzelnen Frage bei einer Person. */ /** * Übersetzt die Selbsteinschätzung in einen FSRS-Grad. * * Die vier Stufen der Oberfläche entsprechen eins zu eins denen, mit denen * FSRS trainiert wurde. Die Zuordnung steht trotzdem ausgeschrieben, damit * sie nicht von der zufälligen Reihenfolge in `BEWERTUNGEN` abhängt. * * Sie stand bis 0.27.0 in `main/lernstand.ts` und ist hierher gewandert, als * `shared/reifeverlauf.ts` denselben Schritt nachrechnen musste. Zweimal * dieselbe Zuordnung wäre die zweite Wahrheit gewesen, an der ein Verlauf * und eine Ampel auseinanderlaufen, ohne dass es jemand merkt. */ export const FSRS_GRAD: Readonly> = Object.freeze({ nochmal: 1, schwer: 2, gut: 3, leicht: 4, }); export interface Gedaechtnisstand { /** Stabilität in Tagen – nach dieser Zeit liegt der Abruf bei 90 %. */ readonly stabilitaet: number; /** Schwierigkeit zwischen 1 und 10. */ readonly schwierigkeit: number; } function p(index: number): number { const wert = FSRS_PARAMETER[index]; /* Kann nur eintreten, wenn jemand FSRS_PARAMETER kürzt – dann ist ein klarer Fehler besser als stilles NaN, das sich bis in die Termine zieht. */ if (wert === undefined) { throw new Error(`FSRS: Parameter ${String(index)} fehlt.`); } return wert; } function begrenzen(wert: number, min: number, max: number): number { return Math.min(Math.max(wert, min), max); } /** Abfall der Vergessenskurve. Negativ, weil die Wahrscheinlichkeit fällt. */ const ABFALL = -p(20); /** * Streckfaktor der Vergessenskurve. * * So gewählt, dass `R(S, S) = 0,9` gilt – nach genau einer Stabilitätsdauer * liegt der Abruf definitionsgemäß bei 90 %. Das ist keine freie Konstante, * sondern folgt zwingend aus {@link ABFALL}. */ const STRECKUNG = Math.pow(0.9, 1 / ABFALL) - 1; /** * Abrufwahrscheinlichkeit `R` nach `tage` Tagen ohne Wiederholung. * * Die Vergessenskurve ist eine Potenzfunktion, keine Exponentialfunktion: * Vergessen verlangsamt sich mit der Zeit, statt gleichmäßig weiterzulaufen. * * @returns Wert zwischen 0 und 1; 0 für eine nie beantwortete Frage. */ export function abrufwahrscheinlichkeit(stabilitaet: number, tage: number): number { if (!Number.isFinite(stabilitaet) || stabilitaet <= 0) { return 0; } const vergangen = Number.isFinite(tage) ? Math.max(0, tage) : 0; return Math.pow(1 + (STRECKUNG * vergangen) / stabilitaet, ABFALL); } /** * Abstand in Tagen, nach dem die Abrufwahrscheinlichkeit auf `zielquote` * gefallen ist – die Umkehrung von {@link abrufwahrscheinlichkeit}. * * Bei einer Zielquote von 0,9 ist das Ergebnis genau die Stabilität. */ export function intervallTage(stabilitaet: number, zielquote: number): number { if (!Number.isFinite(stabilitaet) || stabilitaet <= 0) { return 0; } const quote = begrenzen(zielquote, 0.5, 0.999); return (stabilitaet / STRECKUNG) * (Math.pow(quote, 1 / ABFALL) - 1); } /** * Anfangsschwierigkeit. Bewusst ohne Begrenzung, weil der ungekappte Wert für * „leicht“ als Zielpunkt des Rückzugs zur Mitte gebraucht wird. */ function ersteSchwierigkeit(grad: FsrsGrad): number { return p(4) - Math.exp(p(5) * (grad - 1)) + 1; } /** Gedächtnisstand nach der allerersten Beantwortung einer Frage. */ export function ersterStand(grad: FsrsGrad): Gedaechtnisstand { return { stabilitaet: Math.max(p(grad - 1), MIN_STABILITAET), schwierigkeit: begrenzen(ersteSchwierigkeit(grad), MIN_SCHWIERIGKEIT, MAX_SCHWIERIGKEIT), }; } /** * Neue Schwierigkeit nach einer Antwort. * * Zwei Mechanismen greifen ineinander: * * 1. **Lineare Dämpfung** – je schwerer eine Frage schon ist, desto weniger * verschiebt eine weitere Antwort sie noch. Ohne das würden ein paar * Fehlversuche jede Frage dauerhaft an den Anschlag drücken. * 2. **Rückzug zur Mitte** – die Schwierigkeit driftet langsam zu dem Wert * zurück, den eine mühelos beantwortete Frage hätte. Das verhindert, dass * ein schlechter Tag eine Frage für immer als schwer abstempelt. */ export function naechsteSchwierigkeit(schwierigkeit: number, grad: FsrsGrad): number { const aenderung = -(p(6) * (grad - 3)); const gedaempft = schwierigkeit + ((10 - schwierigkeit) * aenderung) / 9; const zurueckgezogen = p(7) * ersteSchwierigkeit(GRAD_LEICHT) + (1 - p(7)) * gedaempft; return begrenzen(zurueckgezogen, MIN_SCHWIERIGKEIT, MAX_SCHWIERIGKEIT); } /** * Stabilität nach einer richtigen Antwort. * * Der Zuwachs ist umso größer, je *unwahrscheinlicher* der Treffer war – wer * eine Frage kurz vor dem Vergessen noch hinbekommt, festigt sie am stärksten. * Genau darauf zielt verteiltes Lernen. */ function stabilitaetNachTreffer( schwierigkeit: number, stabilitaet: number, abruf: number, grad: FsrsGrad, ): number { const abschlagSchwer = grad === GRAD_SCHWER ? p(15) : 1; const zuschlagLeicht = grad === GRAD_LEICHT ? p(16) : 1; return ( stabilitaet * (1 + Math.exp(p(8)) * (11 - schwierigkeit) * Math.pow(stabilitaet, -p(9)) * (Math.exp((1 - abruf) * p(10)) - 1) * abschlagSchwer * zuschlagLeicht) ); } /** * Stabilität nach einer falschen Antwort. * * Der Fortschritt wird zurückgesetzt, aber nicht gelöscht: Wer eine Frage * schon einmal konnte, lernt sie beim zweiten Mal schneller wieder. Die * Obergrenze stellt sicher, dass ein Fehler die Stabilität niemals erhöht. */ function stabilitaetNachFehler(schwierigkeit: number, stabilitaet: number, abruf: number): number { const langfristig = p(11) * Math.pow(schwierigkeit, -p(12)) * (Math.pow(stabilitaet + 1, p(13)) - 1) * Math.exp((1 - abruf) * p(14)); const obergrenze = stabilitaet / Math.exp(p(17) * p(18)); return Math.min(langfristig, obergrenze); } /** * Stabilität bei einer Wiederholung am selben Tag. * * Ohne verstrichene Zeit sagt die Vergessenskurve nichts aus – deshalb eine * eigene Formel. „schwer“, „gut“ und „leicht“ dürfen die Stabilität dabei nie * senken; eine nicht-falsche Antwort soll nicht bestrafen. */ function stabilitaetAmSelbenTag(stabilitaet: number, grad: FsrsGrad): number { const zuwachs = Math.exp(p(17) * (grad - 3 + p(18))) * Math.pow(stabilitaet, -p(19)); const wirksam = grad >= GRAD_SCHWER ? Math.max(zuwachs, 1) : zuwachs; return Math.max(stabilitaet * wirksam, MIN_STABILITAET); } /** * Schreibt den Gedächtnisstand nach einer Antwort fort. * * @param stand Bisheriger Stand, oder `null` bei der ersten Antwort. * @param grad Selbsteinschätzung von 1 (nochmal) bis 4 (leicht). * @param abstandTage Tage seit der letzten Antwort auf diese Frage. */ export function naechsterStand( stand: Gedaechtnisstand | null, grad: FsrsGrad, abstandTage: number, ): Gedaechtnisstand { if (stand === null) { return ersterStand(grad); } const abstand = Number.isFinite(abstandTage) ? Math.max(0, abstandTage) : 0; const schwierigkeit = naechsteSchwierigkeit(stand.schwierigkeit, grad); /* Unter einem Tag Abstand ist die Vergessenskurve kein brauchbarer Maßstab – dann greift die Kurzfristformel. */ if (abstand < 1) { return { stabilitaet: stabilitaetAmSelbenTag(stand.stabilitaet, grad), schwierigkeit, }; } const abruf = abrufwahrscheinlichkeit(stand.stabilitaet, abstand); const stabilitaet = grad === GRAD_NOCHMAL ? stabilitaetNachFehler(stand.schwierigkeit, stand.stabilitaet, abruf) : stabilitaetNachTreffer(stand.schwierigkeit, stand.stabilitaet, abruf, grad); return { stabilitaet: Math.max(stabilitaet, MIN_STABILITAET), schwierigkeit }; }