waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 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 | } |