/** * Prüft die FSRS-6-Umsetzung gegen die Referenzimplementierung. * * Die Sollwerte in `fixtures/fsrs-referenz.json` sind **nicht** mit dem hier * geprüften Code entstanden, sondern mit `data-pipeline/fsrs_referenz.py` – * einer wörtlichen Übertragung von `py-fsrs/fsrs/scheduler.py` nach Python. * Damit sind es zwei unabhängige Wege zum selben Ergebnis; ein Tippfehler in * einer Formel fällt auf, statt sich still in die Lernpläne fortzusetzen. * * Wenn diese Datei rot wird, ist zuerst zu klären, ob sich die Referenz * geändert hat. Die Sollwerte dürfen nur neu erzeugt, nie von Hand angepasst * werden: * * python data-pipeline/fsrs_referenz.py > app/tests/fixtures/fsrs-referenz.json */ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { abrufwahrscheinlichkeit, ersterStand, FSRS_PARAMETER, intervallTage, naechsterStand, type FsrsGrad, type Gedaechtnisstand, } from '../src/shared/fsrs'; interface Referenz { readonly konstanten: { readonly ABFALL: number; readonly STRECKUNG: number }; readonly erstStand: Record; readonly kurve: readonly { readonly s: number; readonly t: number; readonly r: number }[]; readonly intervall: readonly { readonly s: number; readonly ziel: number; readonly tage: number; }[]; readonly folgen: readonly { readonly name: string; readonly schritte: readonly (Gedaechtnisstand & { grad: number; abstand: number })[]; }[]; } const referenz = JSON.parse( readFileSync(join(__dirname, 'fixtures', 'fsrs-referenz.json'), 'utf8'), ) as Referenz; /** * Toleranz für den Gleitkommavergleich. * * Beide Seiten rechnen mit IEEE-754-Doubles, aber die Rechenreihenfolge ist * nicht wörtlich identisch. Zwölf Stellen liegen weit unter jeder Auswirkung * auf einen Termin – ein Intervall wird ohnehin auf Tage gerundet – und weit * über dem, was ein echter Formelfehler verursachen würde. */ const STELLEN = 12; function gradVon(wert: number): FsrsGrad { if (wert !== 1 && wert !== 2 && wert !== 3 && wert !== 4) { throw new Error(`Ungültiger Grad in der Referenz: ${String(wert)}`); } return wert; } describe('FSRS-Parameter', () => { it('umfasst genau die 21 Werte von FSRS-6', () => { expect(FSRS_PARAMETER).toHaveLength(21); expect(FSRS_PARAMETER.every((w) => Number.isFinite(w))).toBe(true); }); it('ist gegen Änderungen zur Laufzeit gesichert', () => { expect(Object.isFrozen(FSRS_PARAMETER)).toBe(true); }); }); describe('Vergessenskurve', () => { it('trifft die Referenzwerte', () => { expect(referenz.kurve.length).toBeGreaterThan(0); for (const fall of referenz.kurve) { expect(abrufwahrscheinlichkeit(fall.s, fall.t)).toBeCloseTo(fall.r, STELLEN); } }); it('liefert nach genau einer Stabilitätsdauer 90 Prozent', () => { // Die Definition der Stabilität. Gilt sie nicht, ist der Streckfaktor falsch. for (const s of [0.212, 1, 2.3065, 10, 137.5]) { expect(abrufwahrscheinlichkeit(s, s)).toBeCloseTo(0.9, STELLEN); } }); it('fällt streng monoton und bleibt zwischen 0 und 1', () => { let vorher = abrufwahrscheinlichkeit(10, 0); expect(vorher).toBe(1); for (const t of [1, 5, 10, 50, 500, 5000]) { const jetzt = abrufwahrscheinlichkeit(10, t); expect(jetzt).toBeLessThan(vorher); expect(jetzt).toBeGreaterThan(0); vorher = jetzt; } }); it('wertet eine nie beantwortete Frage als nicht abrufbar', () => { // 0 statt NaN: Die Prognose summiert über alle Fragen des Katalogs, auch // über nie gesehene. Ein NaN würde die gesamte Summe unbrauchbar machen. expect(abrufwahrscheinlichkeit(0, 5)).toBe(0); expect(abrufwahrscheinlichkeit(Number.NaN, 5)).toBe(0); expect(abrufwahrscheinlichkeit(-1, 5)).toBe(0); }); it('behandelt eine negative Zeitspanne wie den Zeitpunkt selbst', () => { expect(abrufwahrscheinlichkeit(10, -3)).toBe(1); }); }); describe('Intervall', () => { it('trifft die Referenzwerte', () => { for (const fall of referenz.intervall) { expect(intervallTage(fall.s, fall.ziel)).toBeCloseTo(fall.tage, STELLEN); } }); it('entspricht bei 90 Prozent Zielquote genau der Stabilität', () => { for (const s of [1, 2.3065, 42, 365]) { expect(intervallTage(s, 0.9)).toBeCloseTo(s, STELLEN); } }); it('ist die Umkehrung der Vergessenskurve', () => { for (const ziel of [0.7, 0.8, 0.9, 0.95, 0.99]) { const tage = intervallTage(20, ziel); expect(abrufwahrscheinlichkeit(20, tage)).toBeCloseTo(ziel, STELLEN); } }); it('verkürzt sich, wenn eine höhere Zielquote verlangt wird', () => { // Die Grundlage der Prüfungsvorbereitung: Je sicherer der Stoff sitzen // soll, desto häufiger muss wiederholt werden. expect(intervallTage(30, 0.97)).toBeLessThan(intervallTage(30, 0.9)); expect(intervallTage(30, 0.9)).toBeLessThan(intervallTage(30, 0.8)); }); }); describe('Erste Beantwortung', () => { it('trifft die Referenzwerte für alle vier Bewertungen', () => { for (const [grad, soll] of Object.entries(referenz.erstStand)) { const ist = ersterStand(gradVon(Number(grad))); expect(ist.stabilitaet).toBeCloseTo(soll.stabilitaet, STELLEN); expect(ist.schwierigkeit).toBeCloseTo(soll.schwierigkeit, STELLEN); } }); it('ordnet bessere Bewertungen höherer Stabilität und geringerer Schwierigkeit zu', () => { const staende = ([1, 2, 3, 4] as const).map((g) => ersterStand(g)); for (let i = 1; i < staende.length; i += 1) { const vorher = staende[i - 1]; const jetzt = staende[i]; if (vorher === undefined || jetzt === undefined) { throw new Error('Unerwartet leerer Stand.'); } expect(jetzt.stabilitaet).toBeGreaterThan(vorher.stabilitaet); expect(jetzt.schwierigkeit).toBeLessThan(vorher.schwierigkeit); } }); }); describe('Fortschreibung über mehrere Wiederholungen', () => { /* Der eigentliche Test: Fehler in einer einzelnen Formel schaukeln sich über mehrere Schritte auf und werden dadurch sichtbar, auch wenn der erste Schritt noch zufällig passt. */ for (const folge of referenz.folgen) { it(`folgt der Referenz: ${folge.name}`, () => { let stand: Gedaechtnisstand | null = null; for (const schritt of folge.schritte) { stand = naechsterStand(stand, gradVon(schritt.grad), schritt.abstand); expect(stand.stabilitaet).toBeCloseTo(schritt.stabilitaet, STELLEN); expect(stand.schwierigkeit).toBeCloseTo(schritt.schwierigkeit, STELLEN); } }); } it('deckt alle vier Bewertungen und beide Zeitfälle ab', () => { // Sonst könnte die Referenzdatei schrumpfen, ohne dass es auffällt. const schritte = referenz.folgen.flatMap((f) => f.schritte); expect(new Set(schritte.map((s) => s.grad))).toEqual(new Set([1, 2, 3, 4])); expect(schritte.some((s) => s.abstand === 0)).toBe(true); expect(schritte.some((s) => s.abstand >= 1)).toBe(true); }); }); describe('Zusagen des Modells', () => { it('senkt die Stabilität bei einem Fehler niemals auf null', () => { let stand = ersterStand(3); for (let i = 0; i < 50; i += 1) { stand = naechsterStand(stand, 1, 1); expect(stand.stabilitaet).toBeGreaterThan(0); expect(Number.isFinite(stand.stabilitaet)).toBe(true); } }); it('erhöht die Stabilität bei einem Fehler nie', () => { // Sonst würde sich Falschantworten lohnen. for (const abstand of [1, 3, 10, 100]) { const vorher = { stabilitaet: 40, schwierigkeit: 5 }; expect(naechsterStand(vorher, 1, abstand).stabilitaet).toBeLessThan(vorher.stabilitaet); } }); it('senkt die Stabilität bei „gut“ am selben Tag nie', () => { for (const s of [0.2, 1, 5, 50, 300]) { const vorher = { stabilitaet: s, schwierigkeit: 5 }; expect(naechsterStand(vorher, 3, 0).stabilitaet).toBeGreaterThanOrEqual(s); expect(naechsterStand(vorher, 4, 0).stabilitaet).toBeGreaterThanOrEqual(s); } }); it('belohnt eine späte Wiederholung stärker als eine frühe', () => { // Der didaktische Kern: Abrufen kurz vor dem Vergessen festigt am meisten. const vorher = { stabilitaet: 10, schwierigkeit: 5 }; const frueh = naechsterStand(vorher, 3, 2).stabilitaet; const spaet = naechsterStand(vorher, 3, 9).stabilitaet; expect(spaet).toBeGreaterThan(frueh); }); it('hält die Schwierigkeit stets zwischen 1 und 10', () => { let stand = ersterStand(1); for (const grad of [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] as const) { stand = naechsterStand(stand, grad, 1); expect(stand.schwierigkeit).toBeLessThanOrEqual(10); expect(stand.schwierigkeit).toBeGreaterThanOrEqual(1); } for (const grad of [4, 4, 4, 4, 4, 4, 4, 4, 4, 4] as const) { stand = naechsterStand(stand, grad, 30); expect(stand.schwierigkeit).toBeLessThanOrEqual(10); expect(stand.schwierigkeit).toBeGreaterThanOrEqual(1); } }); it('verkraftet unsinnige Zeitangaben, ohne NaN zu erzeugen', () => { const vorher = { stabilitaet: 10, schwierigkeit: 5 }; for (const abstand of [Number.NaN, Number.POSITIVE_INFINITY, -5]) { const stand = naechsterStand(vorher, 3, abstand); expect(Number.isFinite(stand.stabilitaet)).toBe(true); expect(Number.isFinite(stand.schwierigkeit)).toBe(true); } }); });