waffensachkunde

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

/ app src shared fsrs.ts

11,7 KB Rohdatei
app/src/shared/fsrs.ts — 306 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 import type { Bewertung } from './lernstand';
45
46 /** Bewertung einer Antwort – entspricht den FSRS-Graden 1 bis 4. */
47 export type FsrsGrad = 1 | 2 | 3 | 4;
48
49 export const GRAD_NOCHMAL: FsrsGrad = 1;
50 export const GRAD_SCHWER: FsrsGrad = 2;
51 export const GRAD_GUT: FsrsGrad = 3;
52 export const GRAD_LEICHT: FsrsGrad = 4;
53
54 /**
55 * Die 21 Vorgabeparameter von FSRS-6.
56 *
57 * Sie stammen aus der Auswertung mehrerer Millionen echter Wiederholungen und
58 * gelten für den Durchschnitt. FSRS kann sie aus der eigenen Historie
59 * nachtrainieren; das setzt einige hundert Wiederholungen voraus und ist hier
60 * noch nicht umgesetzt. Bis dahin sind die Vorgabewerte die beste verfügbare
61 * Schätzung – und deutlich besser als ein festes Verdopplungsschema.
62 */
63 export const FSRS_PARAMETER: readonly number[] = Object.freeze([
64 /* 0 */ 0.212, // Anfangsstabilität nach „nochmal“
65 /* 1 */ 1.2931, // Anfangsstabilität nach „schwer“
66 /* 2 */ 2.3065, // Anfangsstabilität nach „gut“
67 /* 3 */ 8.2956, // Anfangsstabilität nach „leicht“
68 /* 4 */ 6.4133, // Anfangsschwierigkeit: Achsenabschnitt
69 /* 5 */ 0.8334, // Anfangsschwierigkeit: Steigung über den Grad
70 /* 6 */ 3.0194, // Schwierigkeitsänderung je Grad
71 /* 7 */ 0.001, // Rückzug zur Mitte
72 /* 8 */ 1.8722, // Stabilitätszuwachs: Grundfaktor
73 /* 9 */ 0.1666, // Stabilitätszuwachs: Dämpfung durch bisherige Stabilität
74 /* 10 */ 0.796, // Stabilitätszuwachs: Belohnung für späte Wiederholung
75 /* 11 */ 1.4835, // Stabilität nach Vergessen: Grundfaktor
76 /* 12 */ 0.0614, // Stabilität nach Vergessen: Dämpfung durch Schwierigkeit
77 /* 13 */ 0.2629, // Stabilität nach Vergessen: Einfluss der alten Stabilität
78 /* 14 */ 1.6483, // Stabilität nach Vergessen: Einfluss des Abrufs
79 /* 15 */ 0.6014, // Abschlag für „schwer“
80 /* 16 */ 1.8729, // Zuschlag für „leicht“
81 /* 17 */ 0.5425, // Wiederholung am selben Tag: Grundfaktor
82 /* 18 */ 0.0912, // Wiederholung am selben Tag: Verschiebung
83 /* 19 */ 0.0658, // Wiederholung am selben Tag: Dämpfung
84 /* 20 */ 0.1542, // Abfall der Vergessenskurve
85 ]);
86
87 /** Untergrenze der Stabilität. Verhindert Division durch null. */
88 export const MIN_STABILITAET = 0.001;
89
90 const MIN_SCHWIERIGKEIT = 1;
91 const MAX_SCHWIERIGKEIT = 10;
92
93 /** Gedächtnisstand einer einzelnen Frage bei einer Person. */
94 /**
95 * Übersetzt die Selbsteinschätzung in einen FSRS-Grad.
96 *
97 * Die vier Stufen der Oberfläche entsprechen eins zu eins denen, mit denen
98 * FSRS trainiert wurde. Die Zuordnung steht trotzdem ausgeschrieben, damit
99 * sie nicht von der zufälligen Reihenfolge in `BEWERTUNGEN` abhängt.
100 *
101 * Sie stand bis 0.27.0 in `main/lernstand.ts` und ist hierher gewandert, als
102 * `shared/reifeverlauf.ts` denselben Schritt nachrechnen musste. Zweimal
103 * dieselbe Zuordnung wäre die zweite Wahrheit gewesen, an der ein Verlauf
104 * und eine Ampel auseinanderlaufen, ohne dass es jemand merkt.
105 */
106 export const FSRS_GRAD: Readonly<Record<Bewertung, FsrsGrad>> = Object.freeze({
107 nochmal: 1,
108 schwer: 2,
109 gut: 3,
110 leicht: 4,
111 });
112
113 export interface Gedaechtnisstand {
114 /** Stabilität in Tagen – nach dieser Zeit liegt der Abruf bei 90 %. */
115 readonly stabilitaet: number;
116 /** Schwierigkeit zwischen 1 und 10. */
117 readonly schwierigkeit: number;
118 }
119
120 function p(index: number): number {
121 const wert = FSRS_PARAMETER[index];
122 /* Kann nur eintreten, wenn jemand FSRS_PARAMETER kürzt – dann ist ein
123 klarer Fehler besser als stilles NaN, das sich bis in die Termine zieht. */
124 if (wert === undefined) {
125 throw new Error(`FSRS: Parameter ${String(index)} fehlt.`);
126 }
127 return wert;
128 }
129
130 function begrenzen(wert: number, min: number, max: number): number {
131 return Math.min(Math.max(wert, min), max);
132 }
133
134 /** Abfall der Vergessenskurve. Negativ, weil die Wahrscheinlichkeit fällt. */
135 const ABFALL = -p(20);
136
137 /**
138 * Streckfaktor der Vergessenskurve.
139 *
140 * So gewählt, dass `R(S, S) = 0,9` gilt – nach genau einer Stabilitätsdauer
141 * liegt der Abruf definitionsgemäß bei 90 %. Das ist keine freie Konstante,
142 * sondern folgt zwingend aus {@link ABFALL}.
143 */
144 const STRECKUNG = Math.pow(0.9, 1 / ABFALL) - 1;
145
146 /**
147 * Abrufwahrscheinlichkeit `R` nach `tage` Tagen ohne Wiederholung.
148 *
149 * Die Vergessenskurve ist eine Potenzfunktion, keine Exponentialfunktion:
150 * Vergessen verlangsamt sich mit der Zeit, statt gleichmäßig weiterzulaufen.
151 *
152 * @returns Wert zwischen 0 und 1; 0 für eine nie beantwortete Frage.
153 */
154 export function abrufwahrscheinlichkeit(stabilitaet: number, tage: number): number {
155 if (!Number.isFinite(stabilitaet) || stabilitaet <= 0) {
156 return 0;
157 }
158 const vergangen = Number.isFinite(tage) ? Math.max(0, tage) : 0;
159 return Math.pow(1 + (STRECKUNG * vergangen) / stabilitaet, ABFALL);
160 }
161
162 /**
163 * Abstand in Tagen, nach dem die Abrufwahrscheinlichkeit auf `zielquote`
164 * gefallen ist – die Umkehrung von {@link abrufwahrscheinlichkeit}.
165 *
166 * Bei einer Zielquote von 0,9 ist das Ergebnis genau die Stabilität.
167 */
168 export function intervallTage(stabilitaet: number, zielquote: number): number {
169 if (!Number.isFinite(stabilitaet) || stabilitaet <= 0) {
170 return 0;
171 }
172 const quote = begrenzen(zielquote, 0.5, 0.999);
173 return (stabilitaet / STRECKUNG) * (Math.pow(quote, 1 / ABFALL) - 1);
174 }
175
176 /**
177 * Anfangsschwierigkeit. Bewusst ohne Begrenzung, weil der ungekappte Wert für
178 * „leicht“ als Zielpunkt des Rückzugs zur Mitte gebraucht wird.
179 */
180 function ersteSchwierigkeit(grad: FsrsGrad): number {
181 return p(4) - Math.exp(p(5) * (grad - 1)) + 1;
182 }
183
184 /** Gedächtnisstand nach der allerersten Beantwortung einer Frage. */
185 export function ersterStand(grad: FsrsGrad): Gedaechtnisstand {
186 return {
187 stabilitaet: Math.max(p(grad - 1), MIN_STABILITAET),
188 schwierigkeit: begrenzen(ersteSchwierigkeit(grad), MIN_SCHWIERIGKEIT, MAX_SCHWIERIGKEIT),
189 };
190 }
191
192 /**
193 * Neue Schwierigkeit nach einer Antwort.
194 *
195 * Zwei Mechanismen greifen ineinander:
196 *
197 * 1. **Lineare Dämpfung** – je schwerer eine Frage schon ist, desto weniger
198 * verschiebt eine weitere Antwort sie noch. Ohne das würden ein paar
199 * Fehlversuche jede Frage dauerhaft an den Anschlag drücken.
200 * 2. **Rückzug zur Mitte** – die Schwierigkeit driftet langsam zu dem Wert
201 * zurück, den eine mühelos beantwortete Frage hätte. Das verhindert, dass
202 * ein schlechter Tag eine Frage für immer als schwer abstempelt.
203 */
204 export function naechsteSchwierigkeit(schwierigkeit: number, grad: FsrsGrad): number {
205 const aenderung = -(p(6) * (grad - 3));
206 const gedaempft = schwierigkeit + ((10 - schwierigkeit) * aenderung) / 9;
207 const zurueckgezogen = p(7) * ersteSchwierigkeit(GRAD_LEICHT) + (1 - p(7)) * gedaempft;
208 return begrenzen(zurueckgezogen, MIN_SCHWIERIGKEIT, MAX_SCHWIERIGKEIT);
209 }
210
211 /**
212 * Stabilität nach einer richtigen Antwort.
213 *
214 * Der Zuwachs ist umso größer, je *unwahrscheinlicher* der Treffer war – wer
215 * eine Frage kurz vor dem Vergessen noch hinbekommt, festigt sie am stärksten.
216 * Genau darauf zielt verteiltes Lernen.
217 */
218 function stabilitaetNachTreffer(
219 schwierigkeit: number,
220 stabilitaet: number,
221 abruf: number,
222 grad: FsrsGrad,
223 ): number {
224 const abschlagSchwer = grad === GRAD_SCHWER ? p(15) : 1;
225 const zuschlagLeicht = grad === GRAD_LEICHT ? p(16) : 1;
226
227 return (
228 stabilitaet *
229 (1 +
230 Math.exp(p(8)) *
231 (11 - schwierigkeit) *
232 Math.pow(stabilitaet, -p(9)) *
233 (Math.exp((1 - abruf) * p(10)) - 1) *
234 abschlagSchwer *
235 zuschlagLeicht)
236 );
237 }
238
239 /**
240 * Stabilität nach einer falschen Antwort.
241 *
242 * Der Fortschritt wird zurückgesetzt, aber nicht gelöscht: Wer eine Frage
243 * schon einmal konnte, lernt sie beim zweiten Mal schneller wieder. Die
244 * Obergrenze stellt sicher, dass ein Fehler die Stabilität niemals erhöht.
245 */
246 function stabilitaetNachFehler(schwierigkeit: number, stabilitaet: number, abruf: number): number {
247 const langfristig =
248 p(11) *
249 Math.pow(schwierigkeit, -p(12)) *
250 (Math.pow(stabilitaet + 1, p(13)) - 1) *
251 Math.exp((1 - abruf) * p(14));
252
253 const obergrenze = stabilitaet / Math.exp(p(17) * p(18));
254
255 return Math.min(langfristig, obergrenze);
256 }
257
258 /**
259 * Stabilität bei einer Wiederholung am selben Tag.
260 *
261 * Ohne verstrichene Zeit sagt die Vergessenskurve nichts aus – deshalb eine
262 * eigene Formel. „schwer“, „gut“ und „leicht“ dürfen die Stabilität dabei nie
263 * senken; eine nicht-falsche Antwort soll nicht bestrafen.
264 */
265 function stabilitaetAmSelbenTag(stabilitaet: number, grad: FsrsGrad): number {
266 const zuwachs = Math.exp(p(17) * (grad - 3 + p(18))) * Math.pow(stabilitaet, -p(19));
267 const wirksam = grad >= GRAD_SCHWER ? Math.max(zuwachs, 1) : zuwachs;
268 return Math.max(stabilitaet * wirksam, MIN_STABILITAET);
269 }
270
271 /**
272 * Schreibt den Gedächtnisstand nach einer Antwort fort.
273 *
274 * @param stand Bisheriger Stand, oder `null` bei der ersten Antwort.
275 * @param grad Selbsteinschätzung von 1 (nochmal) bis 4 (leicht).
276 * @param abstandTage Tage seit der letzten Antwort auf diese Frage.
277 */
278 export function naechsterStand(
279 stand: Gedaechtnisstand | null,
280 grad: FsrsGrad,
281 abstandTage: number,
282 ): Gedaechtnisstand {
283 if (stand === null) {
284 return ersterStand(grad);
285 }
286
287 const abstand = Number.isFinite(abstandTage) ? Math.max(0, abstandTage) : 0;
288 const schwierigkeit = naechsteSchwierigkeit(stand.schwierigkeit, grad);
289
290 /* Unter einem Tag Abstand ist die Vergessenskurve kein brauchbarer Maßstab –
291 dann greift die Kurzfristformel. */
292 if (abstand < 1) {
293 return {
294 stabilitaet: stabilitaetAmSelbenTag(stand.stabilitaet, grad),
295 schwierigkeit,
296 };
297 }
298
299 const abruf = abrufwahrscheinlichkeit(stand.stabilitaet, abstand);
300 const stabilitaet =
301 grad === GRAD_NOCHMAL
302 ? stabilitaetNachFehler(stand.schwierigkeit, stand.stabilitaet, abruf)
303 : stabilitaetNachTreffer(stand.schwierigkeit, stand.stabilitaet, abruf, grad);
304
305 return { stabilitaet: Math.max(stabilitaet, MIN_STABILITAET), schwierigkeit };
306 }