waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app tests fsrs.test.ts

9,3 KB Rohdatei
app/tests/fsrs.test.ts — 249 Zeilen
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 });