waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | /** |
| 2 | * Prüft die FSRS-6-Umsetzung gegen die Referenzimplementierung. |
| 3 | * |
| 4 | * Die Sollwerte in `fixtures/fsrs-referenz.json` sind **nicht** mit dem hier |
| 5 | * geprüften Code entstanden, sondern mit `data-pipeline/fsrs_referenz.py` – |
| 6 | * einer wörtlichen Übertragung von `py-fsrs/fsrs/scheduler.py` nach Python. |
| 7 | * Damit sind es zwei unabhängige Wege zum selben Ergebnis; ein Tippfehler in |
| 8 | * einer Formel fällt auf, statt sich still in die Lernpläne fortzusetzen. |
| 9 | * |
| 10 | * Wenn diese Datei rot wird, ist zuerst zu klären, ob sich die Referenz |
| 11 | * geändert hat. Die Sollwerte dürfen nur neu erzeugt, nie von Hand angepasst |
| 12 | * werden: |
| 13 | * |
| 14 | * python data-pipeline/fsrs_referenz.py > app/tests/fixtures/fsrs-referenz.json |
| 15 | */ |
| 16 | |
| 17 | import { readFileSync } from 'node:fs'; |
| 18 | import { join } from 'node:path'; |
| 19 | |
| 20 | import { describe, expect, it } from 'vitest'; |
| 21 | |
| 22 | import { |
| 23 | abrufwahrscheinlichkeit, |
| 24 | ersterStand, |
| 25 | FSRS_PARAMETER, |
| 26 | intervallTage, |
| 27 | naechsterStand, |
| 28 | type FsrsGrad, |
| 29 | type Gedaechtnisstand, |
| 30 | } from '../src/shared/fsrs'; |
| 31 | |
| 32 | interface Referenz { |
| 33 | readonly konstanten: { readonly ABFALL: number; readonly STRECKUNG: number }; |
| 34 | readonly erstStand: Record<string, Gedaechtnisstand>; |
| 35 | readonly kurve: readonly { readonly s: number; readonly t: number; readonly r: number }[]; |
| 36 | readonly intervall: readonly { |
| 37 | readonly s: number; |
| 38 | readonly ziel: number; |
| 39 | readonly tage: number; |
| 40 | }[]; |
| 41 | readonly folgen: readonly { |
| 42 | readonly name: string; |
| 43 | readonly schritte: readonly (Gedaechtnisstand & { grad: number; abstand: number })[]; |
| 44 | }[]; |
| 45 | } |
| 46 | |
| 47 | const referenz = JSON.parse( |
| 48 | readFileSync(join(__dirname, 'fixtures', 'fsrs-referenz.json'), 'utf8'), |
| 49 | ) as Referenz; |
| 50 | |
| 51 | /** |
| 52 | * Toleranz für den Gleitkommavergleich. |
| 53 | * |
| 54 | * Beide Seiten rechnen mit IEEE-754-Doubles, aber die Rechenreihenfolge ist |
| 55 | * nicht wörtlich identisch. Zwölf Stellen liegen weit unter jeder Auswirkung |
| 56 | * auf einen Termin – ein Intervall wird ohnehin auf Tage gerundet – und weit |
| 57 | * über dem, was ein echter Formelfehler verursachen würde. |
| 58 | */ |
| 59 | const STELLEN = 12; |
| 60 | |
| 61 | function gradVon(wert: number): FsrsGrad { |
| 62 | if (wert !== 1 && wert !== 2 && wert !== 3 && wert !== 4) { |
| 63 | throw new Error(`Ungültiger Grad in der Referenz: ${String(wert)}`); |
| 64 | } |
| 65 | return wert; |
| 66 | } |
| 67 | |
| 68 | describe('FSRS-Parameter', () => { |
| 69 | it('umfasst genau die 21 Werte von FSRS-6', () => { |
| 70 | expect(FSRS_PARAMETER).toHaveLength(21); |
| 71 | expect(FSRS_PARAMETER.every((w) => Number.isFinite(w))).toBe(true); |
| 72 | }); |
| 73 | |
| 74 | it('ist gegen Änderungen zur Laufzeit gesichert', () => { |
| 75 | expect(Object.isFrozen(FSRS_PARAMETER)).toBe(true); |
| 76 | }); |
| 77 | }); |
| 78 | |
| 79 | describe('Vergessenskurve', () => { |
| 80 | it('trifft die Referenzwerte', () => { |
| 81 | expect(referenz.kurve.length).toBeGreaterThan(0); |
| 82 | for (const fall of referenz.kurve) { |
| 83 | expect(abrufwahrscheinlichkeit(fall.s, fall.t)).toBeCloseTo(fall.r, STELLEN); |
| 84 | } |
| 85 | }); |
| 86 | |
| 87 | it('liefert nach genau einer Stabilitätsdauer 90 Prozent', () => { |
| 88 | // Die Definition der Stabilität. Gilt sie nicht, ist der Streckfaktor falsch. |
| 89 | for (const s of [0.212, 1, 2.3065, 10, 137.5]) { |
| 90 | expect(abrufwahrscheinlichkeit(s, s)).toBeCloseTo(0.9, STELLEN); |
| 91 | } |
| 92 | }); |
| 93 | |
| 94 | it('fällt streng monoton und bleibt zwischen 0 und 1', () => { |
| 95 | let vorher = abrufwahrscheinlichkeit(10, 0); |
| 96 | expect(vorher).toBe(1); |
| 97 | for (const t of [1, 5, 10, 50, 500, 5000]) { |
| 98 | const jetzt = abrufwahrscheinlichkeit(10, t); |
| 99 | expect(jetzt).toBeLessThan(vorher); |
| 100 | expect(jetzt).toBeGreaterThan(0); |
| 101 | vorher = jetzt; |
| 102 | } |
| 103 | }); |
| 104 | |
| 105 | it('wertet eine nie beantwortete Frage als nicht abrufbar', () => { |
| 106 | // 0 statt NaN: Die Prognose summiert über alle Fragen des Katalogs, auch |
| 107 | // über nie gesehene. Ein NaN würde die gesamte Summe unbrauchbar machen. |
| 108 | expect(abrufwahrscheinlichkeit(0, 5)).toBe(0); |
| 109 | expect(abrufwahrscheinlichkeit(Number.NaN, 5)).toBe(0); |
| 110 | expect(abrufwahrscheinlichkeit(-1, 5)).toBe(0); |
| 111 | }); |
| 112 | |
| 113 | it('behandelt eine negative Zeitspanne wie den Zeitpunkt selbst', () => { |
| 114 | expect(abrufwahrscheinlichkeit(10, -3)).toBe(1); |
| 115 | }); |
| 116 | }); |
| 117 | |
| 118 | describe('Intervall', () => { |
| 119 | it('trifft die Referenzwerte', () => { |
| 120 | for (const fall of referenz.intervall) { |
| 121 | expect(intervallTage(fall.s, fall.ziel)).toBeCloseTo(fall.tage, STELLEN); |
| 122 | } |
| 123 | }); |
| 124 | |
| 125 | it('entspricht bei 90 Prozent Zielquote genau der Stabilität', () => { |
| 126 | for (const s of [1, 2.3065, 42, 365]) { |
| 127 | expect(intervallTage(s, 0.9)).toBeCloseTo(s, STELLEN); |
| 128 | } |
| 129 | }); |
| 130 | |
| 131 | it('ist die Umkehrung der Vergessenskurve', () => { |
| 132 | for (const ziel of [0.7, 0.8, 0.9, 0.95, 0.99]) { |
| 133 | const tage = intervallTage(20, ziel); |
| 134 | expect(abrufwahrscheinlichkeit(20, tage)).toBeCloseTo(ziel, STELLEN); |
| 135 | } |
| 136 | }); |
| 137 | |
| 138 | it('verkürzt sich, wenn eine höhere Zielquote verlangt wird', () => { |
| 139 | // Die Grundlage der Prüfungsvorbereitung: Je sicherer der Stoff sitzen |
| 140 | // soll, desto häufiger muss wiederholt werden. |
| 141 | expect(intervallTage(30, 0.97)).toBeLessThan(intervallTage(30, 0.9)); |
| 142 | expect(intervallTage(30, 0.9)).toBeLessThan(intervallTage(30, 0.8)); |
| 143 | }); |
| 144 | }); |
| 145 | |
| 146 | describe('Erste Beantwortung', () => { |
| 147 | it('trifft die Referenzwerte für alle vier Bewertungen', () => { |
| 148 | for (const [grad, soll] of Object.entries(referenz.erstStand)) { |
| 149 | const ist = ersterStand(gradVon(Number(grad))); |
| 150 | expect(ist.stabilitaet).toBeCloseTo(soll.stabilitaet, STELLEN); |
| 151 | expect(ist.schwierigkeit).toBeCloseTo(soll.schwierigkeit, STELLEN); |
| 152 | } |
| 153 | }); |
| 154 | |
| 155 | it('ordnet bessere Bewertungen höherer Stabilität und geringerer Schwierigkeit zu', () => { |
| 156 | const staende = ([1, 2, 3, 4] as const).map((g) => ersterStand(g)); |
| 157 | for (let i = 1; i < staende.length; i += 1) { |
| 158 | const vorher = staende[i - 1]; |
| 159 | const jetzt = staende[i]; |
| 160 | if (vorher === undefined || jetzt === undefined) { |
| 161 | throw new Error('Unerwartet leerer Stand.'); |
| 162 | } |
| 163 | expect(jetzt.stabilitaet).toBeGreaterThan(vorher.stabilitaet); |
| 164 | expect(jetzt.schwierigkeit).toBeLessThan(vorher.schwierigkeit); |
| 165 | } |
| 166 | }); |
| 167 | }); |
| 168 | |
| 169 | describe('Fortschreibung über mehrere Wiederholungen', () => { |
| 170 | /* Der eigentliche Test: Fehler in einer einzelnen Formel schaukeln sich über |
| 171 | mehrere Schritte auf und werden dadurch sichtbar, auch wenn der erste |
| 172 | Schritt noch zufällig passt. */ |
| 173 | for (const folge of referenz.folgen) { |
| 174 | it(`folgt der Referenz: ${folge.name}`, () => { |
| 175 | let stand: Gedaechtnisstand | null = null; |
| 176 | for (const schritt of folge.schritte) { |
| 177 | stand = naechsterStand(stand, gradVon(schritt.grad), schritt.abstand); |
| 178 | expect(stand.stabilitaet).toBeCloseTo(schritt.stabilitaet, STELLEN); |
| 179 | expect(stand.schwierigkeit).toBeCloseTo(schritt.schwierigkeit, STELLEN); |
| 180 | } |
| 181 | }); |
| 182 | } |
| 183 | |
| 184 | it('deckt alle vier Bewertungen und beide Zeitfälle ab', () => { |
| 185 | // Sonst könnte die Referenzdatei schrumpfen, ohne dass es auffällt. |
| 186 | const schritte = referenz.folgen.flatMap((f) => f.schritte); |
| 187 | expect(new Set(schritte.map((s) => s.grad))).toEqual(new Set([1, 2, 3, 4])); |
| 188 | expect(schritte.some((s) => s.abstand === 0)).toBe(true); |
| 189 | expect(schritte.some((s) => s.abstand >= 1)).toBe(true); |
| 190 | }); |
| 191 | }); |
| 192 | |
| 193 | describe('Zusagen des Modells', () => { |
| 194 | it('senkt die Stabilität bei einem Fehler niemals auf null', () => { |
| 195 | let stand = ersterStand(3); |
| 196 | for (let i = 0; i < 50; i += 1) { |
| 197 | stand = naechsterStand(stand, 1, 1); |
| 198 | expect(stand.stabilitaet).toBeGreaterThan(0); |
| 199 | expect(Number.isFinite(stand.stabilitaet)).toBe(true); |
| 200 | } |
| 201 | }); |
| 202 | |
| 203 | it('erhöht die Stabilität bei einem Fehler nie', () => { |
| 204 | // Sonst würde sich Falschantworten lohnen. |
| 205 | for (const abstand of [1, 3, 10, 100]) { |
| 206 | const vorher = { stabilitaet: 40, schwierigkeit: 5 }; |
| 207 | expect(naechsterStand(vorher, 1, abstand).stabilitaet).toBeLessThan(vorher.stabilitaet); |
| 208 | } |
| 209 | }); |
| 210 | |
| 211 | it('senkt die Stabilität bei „gut“ am selben Tag nie', () => { |
| 212 | for (const s of [0.2, 1, 5, 50, 300]) { |
| 213 | const vorher = { stabilitaet: s, schwierigkeit: 5 }; |
| 214 | expect(naechsterStand(vorher, 3, 0).stabilitaet).toBeGreaterThanOrEqual(s); |
| 215 | expect(naechsterStand(vorher, 4, 0).stabilitaet).toBeGreaterThanOrEqual(s); |
| 216 | } |
| 217 | }); |
| 218 | |
| 219 | it('belohnt eine späte Wiederholung stärker als eine frühe', () => { |
| 220 | // Der didaktische Kern: Abrufen kurz vor dem Vergessen festigt am meisten. |
| 221 | const vorher = { stabilitaet: 10, schwierigkeit: 5 }; |
| 222 | const frueh = naechsterStand(vorher, 3, 2).stabilitaet; |
| 223 | const spaet = naechsterStand(vorher, 3, 9).stabilitaet; |
| 224 | expect(spaet).toBeGreaterThan(frueh); |
| 225 | }); |
| 226 | |
| 227 | it('hält die Schwierigkeit stets zwischen 1 und 10', () => { |
| 228 | let stand = ersterStand(1); |
| 229 | for (const grad of [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] as const) { |
| 230 | stand = naechsterStand(stand, grad, 1); |
| 231 | expect(stand.schwierigkeit).toBeLessThanOrEqual(10); |
| 232 | expect(stand.schwierigkeit).toBeGreaterThanOrEqual(1); |
| 233 | } |
| 234 | for (const grad of [4, 4, 4, 4, 4, 4, 4, 4, 4, 4] as const) { |
| 235 | stand = naechsterStand(stand, grad, 30); |
| 236 | expect(stand.schwierigkeit).toBeLessThanOrEqual(10); |
| 237 | expect(stand.schwierigkeit).toBeGreaterThanOrEqual(1); |
| 238 | } |
| 239 | }); |
| 240 | |
| 241 | it('verkraftet unsinnige Zeitangaben, ohne NaN zu erzeugen', () => { |
| 242 | const vorher = { stabilitaet: 10, schwierigkeit: 5 }; |
| 243 | for (const abstand of [Number.NaN, Number.POSITIVE_INFINITY, -5]) { |
| 244 | const stand = naechsterStand(vorher, 3, abstand); |
| 245 | expect(Number.isFinite(stand.stabilitaet)).toBe(true); |
| 246 | expect(Number.isFinite(stand.schwierigkeit)).toBe(true); |
| 247 | } |
| 248 | }); |
| 249 | }); |