waffensachkunde

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

/ app src shared fsrs.ts

11,0 KB Rohdatei
app/src/shared/fsrs.ts — 285 Zeilen
1 /**
2 * FSRS-6 – das Gedächtnismodell hinter der Wiedervorlage.
3 *
4 * FSRS (Free Spaced Repetition Scheduler) beschreibt den Gedächtnisstand einer
5 * Frage durch zwei Zahlen:
6 *
7 * - **Stabilität** `S` (in Tagen): die Zeitspanne, nach der die
8 * Abrufwahrscheinlichkeit auf 90 % gefallen ist. Je größer, desto fester
9 * sitzt der Stoff.
10 * - **Schwierigkeit** `D` (1 bis 10): wie mühsam sich die Frage merken lässt.
11 * Sie wächst bei Fehlern und sinkt bei mühelosen Treffern.
12 *
13 * Daraus folgt die **Abrufwahrscheinlichkeit** `R`: die geschätzte Chance, die
14 * Frage jetzt richtig zu beantworten. Sie ist der eigentliche Nutzen des
15 * Modells – aus ihr lassen sich sowohl der nächste Termin als auch eine
16 * ehrliche Prognose für den Prüfungstag ableiten.
17 *
18 * ## Herkunft und Genauigkeit
19 *
20 * Die Formeln und Vorgabeparameter sind aus der Referenzimplementierung
21 * py-fsrs (open-spaced-repetition, MIT) übernommen und in
22 * `tests/fsrs.test.ts` gegen deren Kennwerte abgesichert. Bewusst nachgebaut
23 * statt als Abhängigkeit eingebunden: Das Modell ist der didaktische Kern der
24 * Anwendung und soll ohne Fremdcode nachvollziehbar bleiben. Jede Formel steht
25 * deshalb einzeln und benannt hier.
26 *
27 * FSRS selbst ist ein Algorithmus, kein Programm – seine Verwendung ist an
28 * keine Lizenz gebunden. Die Herkunft ist trotzdem im Bereich „Über diese
29 * Software“ ausgewiesen.
30 *
31 * ## Was hier NICHT steht
32 *
33 * FSRS plant für dauerhaftes Behalten ohne Stichtag. Diese Anwendung lernt auf
34 * einen Prüfungstermin hin. Die Anpassung daran – steigende Zielquote,
35 * ausdrücklich **ohne** Deckelung am Termin – steht getrennt in
36 * `lernplan.ts`, damit erkennbar bleibt, was Referenzverfahren ist und was
37 * eigene Entscheidung.
38 *
39 * Dieser Satz nannte bis Fassung 0.16.0 eine „Deckelung am Termin“, die es
40 * nie gab und die `lernplan.ts` zwei Dateien weiter ausdrücklich verneint.
41 * Warum es sie nicht gibt, steht in `docs/entscheidung-lernphasen.md`.
42 */
43
44 /** Bewertung einer Antwort – entspricht den FSRS-Graden 1 bis 4. */
45 export type FsrsGrad = 1 | 2 | 3 | 4;
46
47 export const GRAD_NOCHMAL: FsrsGrad = 1;
48 export const GRAD_SCHWER: FsrsGrad = 2;
49 export const GRAD_GUT: FsrsGrad = 3;
50 export const GRAD_LEICHT: FsrsGrad = 4;
51
52 /**
53 * Die 21 Vorgabeparameter von FSRS-6.
54 *
55 * Sie stammen aus der Auswertung mehrerer Millionen echter Wiederholungen und
56 * gelten für den Durchschnitt. FSRS kann sie aus der eigenen Historie
57 * nachtrainieren; das setzt einige hundert Wiederholungen voraus und ist hier
58 * noch nicht umgesetzt. Bis dahin sind die Vorgabewerte die beste verfügbare
59 * Schätzung – und deutlich besser als ein festes Verdopplungsschema.
60 */
61 export const FSRS_PARAMETER: readonly number[] = Object.freeze([
62 /* 0 */ 0.212, // Anfangsstabilität nach „nochmal“
63 /* 1 */ 1.2931, // Anfangsstabilität nach „schwer“
64 /* 2 */ 2.3065, // Anfangsstabilität nach „gut“
65 /* 3 */ 8.2956, // Anfangsstabilität nach „leicht“
66 /* 4 */ 6.4133, // Anfangsschwierigkeit: Achsenabschnitt
67 /* 5 */ 0.8334, // Anfangsschwierigkeit: Steigung über den Grad
68 /* 6 */ 3.0194, // Schwierigkeitsänderung je Grad
69 /* 7 */ 0.001, // Rückzug zur Mitte
70 /* 8 */ 1.8722, // Stabilitätszuwachs: Grundfaktor
71 /* 9 */ 0.1666, // Stabilitätszuwachs: Dämpfung durch bisherige Stabilität
72 /* 10 */ 0.796, // Stabilitätszuwachs: Belohnung für späte Wiederholung
73 /* 11 */ 1.4835, // Stabilität nach Vergessen: Grundfaktor
74 /* 12 */ 0.0614, // Stabilität nach Vergessen: Dämpfung durch Schwierigkeit
75 /* 13 */ 0.2629, // Stabilität nach Vergessen: Einfluss der alten Stabilität
76 /* 14 */ 1.6483, // Stabilität nach Vergessen: Einfluss des Abrufs
77 /* 15 */ 0.6014, // Abschlag für „schwer“
78 /* 16 */ 1.8729, // Zuschlag für „leicht“
79 /* 17 */ 0.5425, // Wiederholung am selben Tag: Grundfaktor
80 /* 18 */ 0.0912, // Wiederholung am selben Tag: Verschiebung
81 /* 19 */ 0.0658, // Wiederholung am selben Tag: Dämpfung
82 /* 20 */ 0.1542, // Abfall der Vergessenskurve
83 ]);
84
85 /** Untergrenze der Stabilität. Verhindert Division durch null. */
86 export const MIN_STABILITAET = 0.001;
87
88 const MIN_SCHWIERIGKEIT = 1;
89 const MAX_SCHWIERIGKEIT = 10;
90
91 /** Gedächtnisstand einer einzelnen Frage bei einer Person. */
92 export interface Gedaechtnisstand {
93 /** Stabilität in Tagen – nach dieser Zeit liegt der Abruf bei 90 %. */
94 readonly stabilitaet: number;
95 /** Schwierigkeit zwischen 1 und 10. */
96 readonly schwierigkeit: number;
97 }
98
99 function p(index: number): number {
100 const wert = FSRS_PARAMETER[index];
101 /* Kann nur eintreten, wenn jemand FSRS_PARAMETER kürzt – dann ist ein
102 klarer Fehler besser als stilles NaN, das sich bis in die Termine zieht. */
103 if (wert === undefined) {
104 throw new Error(`FSRS: Parameter ${String(index)} fehlt.`);
105 }
106 return wert;
107 }
108
109 function begrenzen(wert: number, min: number, max: number): number {
110 return Math.min(Math.max(wert, min), max);
111 }
112
113 /** Abfall der Vergessenskurve. Negativ, weil die Wahrscheinlichkeit fällt. */
114 const ABFALL = -p(20);
115
116 /**
117 * Streckfaktor der Vergessenskurve.
118 *
119 * So gewählt, dass `R(S, S) = 0,9` gilt – nach genau einer Stabilitätsdauer
120 * liegt der Abruf definitionsgemäß bei 90 %. Das ist keine freie Konstante,
121 * sondern folgt zwingend aus {@link ABFALL}.
122 */
123 const STRECKUNG = Math.pow(0.9, 1 / ABFALL) - 1;
124
125 /**
126 * Abrufwahrscheinlichkeit `R` nach `tage` Tagen ohne Wiederholung.
127 *
128 * Die Vergessenskurve ist eine Potenzfunktion, keine Exponentialfunktion:
129 * Vergessen verlangsamt sich mit der Zeit, statt gleichmäßig weiterzulaufen.
130 *
131 * @returns Wert zwischen 0 und 1; 0 für eine nie beantwortete Frage.
132 */
133 export function abrufwahrscheinlichkeit(stabilitaet: number, tage: number): number {
134 if (!Number.isFinite(stabilitaet) || stabilitaet <= 0) {
135 return 0;
136 }
137 const vergangen = Number.isFinite(tage) ? Math.max(0, tage) : 0;
138 return Math.pow(1 + (STRECKUNG * vergangen) / stabilitaet, ABFALL);
139 }
140
141 /**
142 * Abstand in Tagen, nach dem die Abrufwahrscheinlichkeit auf `zielquote`
143 * gefallen ist – die Umkehrung von {@link abrufwahrscheinlichkeit}.
144 *
145 * Bei einer Zielquote von 0,9 ist das Ergebnis genau die Stabilität.
146 */
147 export function intervallTage(stabilitaet: number, zielquote: number): number {
148 if (!Number.isFinite(stabilitaet) || stabilitaet <= 0) {
149 return 0;
150 }
151 const quote = begrenzen(zielquote, 0.5, 0.999);
152 return (stabilitaet / STRECKUNG) * (Math.pow(quote, 1 / ABFALL) - 1);
153 }
154
155 /**
156 * Anfangsschwierigkeit. Bewusst ohne Begrenzung, weil der ungekappte Wert für
157 * „leicht“ als Zielpunkt des Rückzugs zur Mitte gebraucht wird.
158 */
159 function ersteSchwierigkeit(grad: FsrsGrad): number {
160 return p(4) - Math.exp(p(5) * (grad - 1)) + 1;
161 }
162
163 /** Gedächtnisstand nach der allerersten Beantwortung einer Frage. */
164 export function ersterStand(grad: FsrsGrad): Gedaechtnisstand {
165 return {
166 stabilitaet: Math.max(p(grad - 1), MIN_STABILITAET),
167 schwierigkeit: begrenzen(ersteSchwierigkeit(grad), MIN_SCHWIERIGKEIT, MAX_SCHWIERIGKEIT),
168 };
169 }
170
171 /**
172 * Neue Schwierigkeit nach einer Antwort.
173 *
174 * Zwei Mechanismen greifen ineinander:
175 *
176 * 1. **Lineare Dämpfung** – je schwerer eine Frage schon ist, desto weniger
177 * verschiebt eine weitere Antwort sie noch. Ohne das würden ein paar
178 * Fehlversuche jede Frage dauerhaft an den Anschlag drücken.
179 * 2. **Rückzug zur Mitte** – die Schwierigkeit driftet langsam zu dem Wert
180 * zurück, den eine mühelos beantwortete Frage hätte. Das verhindert, dass
181 * ein schlechter Tag eine Frage für immer als schwer abstempelt.
182 */
183 export function naechsteSchwierigkeit(schwierigkeit: number, grad: FsrsGrad): number {
184 const aenderung = -(p(6) * (grad - 3));
185 const gedaempft = schwierigkeit + ((10 - schwierigkeit) * aenderung) / 9;
186 const zurueckgezogen = p(7) * ersteSchwierigkeit(GRAD_LEICHT) + (1 - p(7)) * gedaempft;
187 return begrenzen(zurueckgezogen, MIN_SCHWIERIGKEIT, MAX_SCHWIERIGKEIT);
188 }
189
190 /**
191 * Stabilität nach einer richtigen Antwort.
192 *
193 * Der Zuwachs ist umso größer, je *unwahrscheinlicher* der Treffer war – wer
194 * eine Frage kurz vor dem Vergessen noch hinbekommt, festigt sie am stärksten.
195 * Genau darauf zielt verteiltes Lernen.
196 */
197 function stabilitaetNachTreffer(
198 schwierigkeit: number,
199 stabilitaet: number,
200 abruf: number,
201 grad: FsrsGrad,
202 ): number {
203 const abschlagSchwer = grad === GRAD_SCHWER ? p(15) : 1;
204 const zuschlagLeicht = grad === GRAD_LEICHT ? p(16) : 1;
205
206 return (
207 stabilitaet *
208 (1 +
209 Math.exp(p(8)) *
210 (11 - schwierigkeit) *
211 Math.pow(stabilitaet, -p(9)) *
212 (Math.exp((1 - abruf) * p(10)) - 1) *
213 abschlagSchwer *
214 zuschlagLeicht)
215 );
216 }
217
218 /**
219 * Stabilität nach einer falschen Antwort.
220 *
221 * Der Fortschritt wird zurückgesetzt, aber nicht gelöscht: Wer eine Frage
222 * schon einmal konnte, lernt sie beim zweiten Mal schneller wieder. Die
223 * Obergrenze stellt sicher, dass ein Fehler die Stabilität niemals erhöht.
224 */
225 function stabilitaetNachFehler(schwierigkeit: number, stabilitaet: number, abruf: number): number {
226 const langfristig =
227 p(11) *
228 Math.pow(schwierigkeit, -p(12)) *
229 (Math.pow(stabilitaet + 1, p(13)) - 1) *
230 Math.exp((1 - abruf) * p(14));
231
232 const obergrenze = stabilitaet / Math.exp(p(17) * p(18));
233
234 return Math.min(langfristig, obergrenze);
235 }
236
237 /**
238 * Stabilität bei einer Wiederholung am selben Tag.
239 *
240 * Ohne verstrichene Zeit sagt die Vergessenskurve nichts aus – deshalb eine
241 * eigene Formel. „schwer“, „gut“ und „leicht“ dürfen die Stabilität dabei nie
242 * senken; eine nicht-falsche Antwort soll nicht bestrafen.
243 */
244 function stabilitaetAmSelbenTag(stabilitaet: number, grad: FsrsGrad): number {
245 const zuwachs = Math.exp(p(17) * (grad - 3 + p(18))) * Math.pow(stabilitaet, -p(19));
246 const wirksam = grad >= GRAD_SCHWER ? Math.max(zuwachs, 1) : zuwachs;
247 return Math.max(stabilitaet * wirksam, MIN_STABILITAET);
248 }
249
250 /**
251 * Schreibt den Gedächtnisstand nach einer Antwort fort.
252 *
253 * @param stand Bisheriger Stand, oder `null` bei der ersten Antwort.
254 * @param grad Selbsteinschätzung von 1 (nochmal) bis 4 (leicht).
255 * @param abstandTage Tage seit der letzten Antwort auf diese Frage.
256 */
257 export function naechsterStand(
258 stand: Gedaechtnisstand | null,
259 grad: FsrsGrad,
260 abstandTage: number,
261 ): Gedaechtnisstand {
262 if (stand === null) {
263 return ersterStand(grad);
264 }
265
266 const abstand = Number.isFinite(abstandTage) ? Math.max(0, abstandTage) : 0;
267 const schwierigkeit = naechsteSchwierigkeit(stand.schwierigkeit, grad);
268
269 /* Unter einem Tag Abstand ist die Vergessenskurve kein brauchbarer Maßstab –
270 dann greift die Kurzfristformel. */
271 if (abstand < 1) {
272 return {
273 stabilitaet: stabilitaetAmSelbenTag(stand.stabilitaet, grad),
274 schwierigkeit,
275 };
276 }
277
278 const abruf = abrufwahrscheinlichkeit(stand.stabilitaet, abstand);
279 const stabilitaet =
280 grad === GRAD_NOCHMAL
281 ? stabilitaetNachFehler(stand.schwierigkeit, stand.stabilitaet, abruf)
282 : stabilitaetNachTreffer(stand.schwierigkeit, stand.stabilitaet, abruf, grad);
283
284 return { stabilitaet: Math.max(stabilitaet, MIN_STABILITAET), schwierigkeit };
285 }