waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests messung-ratewahrscheinlichkeit.test.ts
| 1 | // @vitest-environment node |
| 2 | /** |
| 3 | * Messskript zu `docs/entscheidung-ratewahrscheinlichkeit.md`. |
| 4 | * |
| 5 | * Die Frage: `bewertungAusErgebnis` wertet jede vollständig richtige |
| 6 | * Auswahl-Antwort als „gut“ – auch die erratene. Wie stark verplant sich die |
| 7 | * FSRS-Terminierung dadurch, und helfen die naheliegenden Milderungen |
| 8 | * (unvollständig → „schwer“; konservativere Ersteinstufung)? |
| 9 | * |
| 10 | * ## Modell der Simulation |
| 11 | * |
| 12 | * Gerechnet wird mit den echten Funktionen aus `src/shared/fsrs.ts` und |
| 13 | * `src/shared/lernplan.ts`, nicht mit einem Nachbau. Angenommen wird: |
| 14 | * |
| 15 | * - Der Lernende lernt täglich; eine an Tag `t` fällige Frage wird an Tag `t` |
| 16 | * beantwortet. Zielquote 0,9 (kein Prüfungstermin) – die Anhebung vor dem |
| 17 | * Termin (`zielquoteFuer`) verkürzt alle Intervalle nur weiter und würde |
| 18 | * jeden hier gemessenen Fehler verkleinern, nicht vergrößern. |
| 19 | * - Auswahl-Antworten gehen wie in `bewertungAusErgebnis` ein: |
| 20 | * richtig → „gut“ (3), sonst „nochmal“ (1). |
| 21 | * - „nochmal“ ergibt Intervall 0 (`wiedervorlageBerechnen`): die Frage kommt |
| 22 | * in derselben Sitzung wieder. Weil die Lösung nach dem Bestätigen auf dem |
| 23 | * Bildschirm stand, gilt die Sofortwiederholung als richtig („gut“ bei |
| 24 | * Abstand 0, Kurzfristformel). Das ist die für das Modell **wohlwollende** |
| 25 | * Annahme: sie schenkt der geratenen Frage den kleinen Kurzfrist-Zuwachs. |
| 26 | * - Der „reine Rater“ trifft an jedem Vorlagetag unabhängig mit fester |
| 27 | * Wahrscheinlichkeit `p` und lernt nichts dazu – der schlechteste Fall. |
| 28 | * In der echten Anwendung zeigt jede Vorlage die Lösung; dieser Fall ist |
| 29 | * also eine obere Schranke, kein typischer Lernender. |
| 30 | * |
| 31 | * ## Reproduzieren |
| 32 | * |
| 33 | * Die Kennwerte, die die Entscheidung zitiert, sind unten als Erwartungen |
| 34 | * festgenagelt – ändert sich `fsrs.ts` oder `lernplan.ts`, wird diese Datei |
| 35 | * rot und die Entscheidung muss nachgeführt werden. Die vollständigen |
| 36 | * Tabellen druckt der Lauf auf Wunsch: |
| 37 | * |
| 38 | * PowerShell: $env:MESSUNG_AUSGABE='1'; npx vitest run tests/messung-ratewahrscheinlichkeit.test.ts |
| 39 | * POSIX: MESSUNG_AUSGABE=1 npx vitest run tests/messung-ratewahrscheinlichkeit.test.ts |
| 40 | */ |
| 41 | |
| 42 | import { readFileSync } from 'node:fs'; |
| 43 | import { join } from 'node:path'; |
| 44 | |
| 45 | import { describe, expect, it } from 'vitest'; |
| 46 | |
| 47 | import { |
| 48 | abrufwahrscheinlichkeit, |
| 49 | GRAD_GUT, |
| 50 | GRAD_NOCHMAL, |
| 51 | GRAD_SCHWER, |
| 52 | type FsrsGrad, |
| 53 | type Gedaechtnisstand, |
| 54 | } from '../src/shared/fsrs'; |
| 55 | import { SEKUNDEN_PRO_FRAGE_VORGABE, wiedervorlageBerechnen } from '../src/shared/lernplan'; |
| 56 | import { MINDESTABSTAND_TAGE } from '../src/shared/reife'; |
| 57 | |
| 58 | /** Betrachtungszeitraum: Tag 0 bis einschließlich Tag 60. */ |
| 59 | const HORIZONT_TAGE = 60; |
| 60 | |
| 61 | /** |
| 62 | * Läufe je Zufallsszenario. Bei 20 000 Läufen schwanken die gemessenen |
| 63 | * Anteile um weniger als einen Prozentpunkt – genau genug für jede hier |
| 64 | * gezogene Schlussfolgerung, schnell genug für den normalen Testlauf. |
| 65 | */ |
| 66 | const LAEUFE = 20000; |
| 67 | |
| 68 | /** Ausgabe der Tabellen nur auf Wunsch – der normale Testlauf bleibt still. */ |
| 69 | const AUSGABE = process.env['MESSUNG_AUSGABE'] === '1'; |
| 70 | |
| 71 | /** |
| 72 | * Eigene Zufallsquelle (mulberry32) statt `Math.random`: nur mit fester Saat |
| 73 | * sind die unten festgenagelten Kennwerte auf jedem Rechner dieselben. |
| 74 | */ |
| 75 | function zufallsquelle(saat: number): () => number { |
| 76 | let zustand = saat >>> 0; |
| 77 | return () => { |
| 78 | zustand = (zustand + 0x6d2b79f5) >>> 0; |
| 79 | let t = zustand; |
| 80 | t = Math.imul(t ^ (t >>> 15), t | 1); |
| 81 | t ^= t + Math.imul(t ^ (t >>> 7), t | 61); |
| 82 | return ((t ^ (t >>> 14)) >>> 0) / 4294967296; |
| 83 | }; |
| 84 | } |
| 85 | |
| 86 | /** Wie die erste Vorstellung eines Tages ausgeht. */ |
| 87 | interface Tagesantwort { |
| 88 | /** Grad, mit dem die Antwort ins Gedächtnismodell eingeht. */ |
| 89 | readonly grad: FsrsGrad; |
| 90 | /** Ob sie im Sinne von `bewerteAuswahl` vollständig richtig war. */ |
| 91 | readonly richtig: boolean; |
| 92 | } |
| 93 | |
| 94 | interface Lauf { |
| 95 | /** Tage, an denen die Frage vorkam. */ |
| 96 | readonly sitzungstage: number; |
| 97 | /** Beantwortungen insgesamt, Sofortwiederholungen mitgezählt. */ |
| 98 | readonly vorstellungen: number; |
| 99 | /** Größtes geplantes Intervall. */ |
| 100 | readonly maxIntervall: number; |
| 101 | readonly endStabilitaet: number; |
| 102 | /** Tage, an deren Ende der Beleg (`bestaetigt`) stand. */ |
| 103 | readonly belegteTage: number; |
| 104 | /** Mittleres Modell-R unmittelbar vor einer Wiedervorlage. */ |
| 105 | readonly mittelBehauptetesR: number; |
| 106 | /** Empirische Trefferquote der jeweils ersten Tagesvorstellung. */ |
| 107 | readonly trefferquoteErste: number; |
| 108 | /** Treffer bei der ersten Tagesvorstellung – Zähler für die Poolung. */ |
| 109 | readonly ersteTreffer: number; |
| 110 | /** Summe und Anzahl der Modell-R-Werte – Zähler für die Poolung. */ |
| 111 | readonly summeR: number; |
| 112 | readonly zaehlerR: number; |
| 113 | /** Vorlagetage samt danach geplantem Intervall – für die Tabellen. */ |
| 114 | readonly plan: readonly { readonly tag: number; readonly intervall: number }[]; |
| 115 | } |
| 116 | |
| 117 | /** |
| 118 | * Spielt eine einzelne Frage über den Horizont durch – mit exakt den Regeln |
| 119 | * des Anwendungskerns: `wiedervorlageBerechnen` für den Termin, die |
| 120 | * Belegregel aus `main/lernstand.ts` für `bestaetigt`. |
| 121 | */ |
| 122 | function simuliere(antwort: (ersteAntwort: boolean) => Tagesantwort): Lauf { |
| 123 | let stand: Gedaechtnisstand | null = null; |
| 124 | let letzterTag: number | null = null; |
| 125 | let faelligAb = 0; |
| 126 | let belegt = false; |
| 127 | |
| 128 | let sitzungstage = 0; |
| 129 | let vorstellungen = 0; |
| 130 | let maxIntervall = 0; |
| 131 | let belegteTage = 0; |
| 132 | let summeR = 0; |
| 133 | let zaehlerR = 0; |
| 134 | let ersteTreffer = 0; |
| 135 | const plan: { tag: number; intervall: number }[] = []; |
| 136 | |
| 137 | for (let tag = 0; tag <= HORIZONT_TAGE; tag += 1) { |
| 138 | if (tag >= faelligAb) { |
| 139 | const abstand = letzterTag === null ? 0 : tag - letzterTag; |
| 140 | |
| 141 | /* Die Behauptung des Modells im Moment der Vorlage – der Terminplan hat |
| 142 | das Intervall ja gerade so gewählt, dass hier rund 90 % stehen. */ |
| 143 | if (stand !== null) { |
| 144 | summeR += abrufwahrscheinlichkeit(stand.stabilitaet, abstand); |
| 145 | zaehlerR += 1; |
| 146 | } |
| 147 | |
| 148 | const a = antwort(stand === null); |
| 149 | if (a.richtig) { |
| 150 | ersteTreffer += 1; |
| 151 | } |
| 152 | |
| 153 | let vorlage = wiedervorlageBerechnen(stand, a.grad, abstand, null); |
| 154 | stand = { stabilitaet: vorlage.stabilitaet, schwierigkeit: vorlage.schwierigkeit }; |
| 155 | /* Belegregel wie in `main/lernstand.ts`: falsch nimmt den Beleg, |
| 156 | richtig nach ≥ 1 Tag setzt ihn, richtig am selben Tag lässt ihn. */ |
| 157 | belegt = a.richtig ? abstand >= MINDESTABSTAND_TAGE || belegt : false; |
| 158 | vorstellungen += 1; |
| 159 | |
| 160 | if (vorlage.intervallTage === 0) { |
| 161 | /* Sofortwiederholung in derselben Sitzung. Die Lösung stand gerade |
| 162 | auf dem Bildschirm, also sitzt sie kurzfristig: „gut“ bei Abstand 0. |
| 163 | Der Beleg bleibt weg – richtig bei Abstand 0 setzt ihn nicht. */ |
| 164 | vorlage = wiedervorlageBerechnen(stand, GRAD_GUT, 0, null); |
| 165 | stand = { stabilitaet: vorlage.stabilitaet, schwierigkeit: vorlage.schwierigkeit }; |
| 166 | vorstellungen += 1; |
| 167 | } |
| 168 | |
| 169 | sitzungstage += 1; |
| 170 | maxIntervall = Math.max(maxIntervall, vorlage.intervallTage); |
| 171 | plan.push({ tag, intervall: vorlage.intervallTage }); |
| 172 | letzterTag = tag; |
| 173 | faelligAb = tag + vorlage.intervallTage; |
| 174 | } |
| 175 | |
| 176 | if (belegt) { |
| 177 | belegteTage += 1; |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | return { |
| 182 | sitzungstage, |
| 183 | vorstellungen, |
| 184 | maxIntervall, |
| 185 | endStabilitaet: stand === null ? 0 : stand.stabilitaet, |
| 186 | belegteTage, |
| 187 | mittelBehauptetesR: zaehlerR === 0 ? Number.NaN : summeR / zaehlerR, |
| 188 | trefferquoteErste: sitzungstage === 0 ? Number.NaN : ersteTreffer / sitzungstage, |
| 189 | ersteTreffer, |
| 190 | summeR, |
| 191 | zaehlerR, |
| 192 | plan, |
| 193 | }; |
| 194 | } |
| 195 | |
| 196 | /** Kennwerte über viele Läufe eines Zufallsszenarios. */ |
| 197 | interface Sammel { |
| 198 | readonly mittelSitzungstage: number; |
| 199 | readonly mittelVorstellungen: number; |
| 200 | readonly medianEndStabilitaet: number; |
| 201 | /** Anteil der Läufe, in denen das Intervall je mindestens n Tage erreichte. */ |
| 202 | readonly anteilMax7: number; |
| 203 | readonly anteilMax14: number; |
| 204 | readonly anteilMax30: number; |
| 205 | /** Mittlerer Anteil der Tage, an denen der Beleg stand. */ |
| 206 | readonly mittelBelegtAnteil: number; |
| 207 | /** |
| 208 | * Über **alle** Vorlagen gepoolt, nicht als Mittel der Laufmittel. |
| 209 | * |
| 210 | * Der Unterschied ist kein Formalismus: Ein Glückslauf hat weniger |
| 211 | * Vorlagetage und wiegt im Mittel der Verhältnisse deshalb schwerer, als |
| 212 | * ihm zusteht. Die Entscheidung zitiert die gepoolten Werte; die |
| 213 | * Laufmittel stehen daneben, damit der Abstand sichtbar bleibt. |
| 214 | */ |
| 215 | readonly gepooltesR: number; |
| 216 | readonly gepoolteTrefferquote: number; |
| 217 | readonly mittelBehauptetesR: number; |
| 218 | readonly mittelTrefferquote: number; |
| 219 | } |
| 220 | |
| 221 | function median(werte: readonly number[]): number { |
| 222 | const sortiert = [...werte].sort((a, b) => a - b); |
| 223 | const mitte = sortiert[Math.floor(sortiert.length / 2)]; |
| 224 | if (mitte === undefined) { |
| 225 | throw new Error('Median einer leeren Liste.'); |
| 226 | } |
| 227 | return mitte; |
| 228 | } |
| 229 | |
| 230 | /** |
| 231 | * Reiner Rater mit Trefferwahrscheinlichkeit `p` je erster Tagesvorstellung. |
| 232 | * |
| 233 | * @param gradEinstieg Grad der **allerersten** richtigen Antwort – `GRAD_GUT` |
| 234 | * ist der heutige Stand, `GRAD_SCHWER` die Milderungsoption |
| 235 | * „konservativere Ersteinstufung“. |
| 236 | */ |
| 237 | function messeRater(p: number, gradEinstieg: FsrsGrad = GRAD_GUT): Sammel { |
| 238 | const zufall = zufallsquelle(20260825); |
| 239 | const tage = HORIZONT_TAGE + 1; |
| 240 | |
| 241 | let sitzungstage = 0; |
| 242 | let vorstellungen = 0; |
| 243 | let max7 = 0; |
| 244 | let max14 = 0; |
| 245 | let max30 = 0; |
| 246 | let belegt = 0; |
| 247 | let behauptet = 0; |
| 248 | let treffer = 0; |
| 249 | let summeR = 0; |
| 250 | let zaehlerR = 0; |
| 251 | let ersteTreffer = 0; |
| 252 | const endStabilitaeten: number[] = []; |
| 253 | |
| 254 | for (let lauf = 0; lauf < LAEUFE; lauf += 1) { |
| 255 | const ergebnis = simuliere((ersteAntwort) => |
| 256 | zufall() < p |
| 257 | ? { grad: ersteAntwort ? gradEinstieg : GRAD_GUT, richtig: true } |
| 258 | : { grad: GRAD_NOCHMAL, richtig: false }, |
| 259 | ); |
| 260 | sitzungstage += ergebnis.sitzungstage; |
| 261 | vorstellungen += ergebnis.vorstellungen; |
| 262 | if (ergebnis.maxIntervall >= 7) max7 += 1; |
| 263 | if (ergebnis.maxIntervall >= 14) max14 += 1; |
| 264 | if (ergebnis.maxIntervall >= 30) max30 += 1; |
| 265 | belegt += ergebnis.belegteTage / tage; |
| 266 | behauptet += ergebnis.mittelBehauptetesR; |
| 267 | treffer += ergebnis.trefferquoteErste; |
| 268 | summeR += ergebnis.summeR; |
| 269 | zaehlerR += ergebnis.zaehlerR; |
| 270 | ersteTreffer += ergebnis.ersteTreffer; |
| 271 | endStabilitaeten.push(ergebnis.endStabilitaet); |
| 272 | } |
| 273 | |
| 274 | return { |
| 275 | mittelSitzungstage: sitzungstage / LAEUFE, |
| 276 | mittelVorstellungen: vorstellungen / LAEUFE, |
| 277 | medianEndStabilitaet: median(endStabilitaeten), |
| 278 | anteilMax7: max7 / LAEUFE, |
| 279 | anteilMax14: max14 / LAEUFE, |
| 280 | anteilMax30: max30 / LAEUFE, |
| 281 | mittelBelegtAnteil: belegt / LAEUFE, |
| 282 | gepooltesR: summeR / zaehlerR, |
| 283 | gepoolteTrefferquote: ersteTreffer / sitzungstage, |
| 284 | mittelBehauptetesR: behauptet / LAEUFE, |
| 285 | mittelTrefferquote: treffer / LAEUFE, |
| 286 | }; |
| 287 | } |
| 288 | |
| 289 | // ── Die Szenarien ─────────────────────────────────────────────────────────── |
| 290 | |
| 291 | /** (a) Gewusst: jede Vorlage vollständig richtig. */ |
| 292 | const wisser = simuliere(() => ({ grad: GRAD_GUT, richtig: true })); |
| 293 | |
| 294 | /** Milderung „konservativere Ersteinstufung“ beim Wissenden. */ |
| 295 | const wisserKonservativ = simuliere((ersteAntwort) => ({ |
| 296 | grad: ersteAntwort ? GRAD_SCHWER : GRAD_GUT, |
| 297 | richtig: true, |
| 298 | })); |
| 299 | |
| 300 | /** |
| 301 | * Dauerhaft unvollständige Antwort (z. B. stets nur eine von zwei richtigen |
| 302 | * Optionen) unter der heutigen Regel: unvollständig → „nochmal“. |
| 303 | */ |
| 304 | const dauerhaftUnvollstaendigHeute = simuliere(() => ({ grad: GRAD_NOCHMAL, richtig: false })); |
| 305 | |
| 306 | /** Dieselbe Antwort unter der Milderungsoption: unvollständig → „schwer“. */ |
| 307 | const dauerhaftUnvollstaendigSchwer = simuliere(() => ({ grad: GRAD_SCHWER, richtig: false })); |
| 308 | |
| 309 | /** |
| 310 | * (b) Reine Rater. 1/3 und 1/4 als die beiden Ratewahrscheinlichkeiten des |
| 311 | * Befunds; 1/7 = 0,1429 steht für den Werkszustand mit eingeschaltetem |
| 312 | * `antwortzahlVerbergen` – die dort gemessene mittlere Ratechance ist 0,1437. |
| 313 | */ |
| 314 | const raterDrittel = messeRater(1 / 3); |
| 315 | const raterViertel = messeRater(1 / 4); |
| 316 | const raterSiebtel = messeRater(1 / 7); |
| 317 | |
| 318 | /** Milderung „konservativere Ersteinstufung“ beim häufigsten Rater-Fall. */ |
| 319 | const raterDrittelKonservativ = messeRater(1 / 3, GRAD_SCHWER); |
| 320 | |
| 321 | // ── Katalog-Erdung der Ratewahrscheinlichkeiten ───────────────────────────── |
| 322 | |
| 323 | interface KatalogRoh { |
| 324 | readonly fragen: readonly { |
| 325 | readonly typ: string; |
| 326 | readonly optionen?: readonly { readonly korrekt: boolean }[]; |
| 327 | }[]; |
| 328 | } |
| 329 | |
| 330 | const katalog = JSON.parse( |
| 331 | readFileSync(join(__dirname, '..', '..', 'content', 'katalog', 'katalog.json'), 'utf8'), |
| 332 | ) as KatalogRoh; |
| 333 | |
| 334 | const auswahlfragen = katalog.fragen.filter((f) => f.typ === 'mc' && (f.optionen?.length ?? 0) > 0); |
| 335 | |
| 336 | /** 3 Optionen, genau 1 richtige – der Fall mit Ratewahrscheinlichkeit 1/3. */ |
| 337 | const dreiOptionenEineRichtige = auswahlfragen.filter( |
| 338 | (f) => f.optionen?.length === 3 && f.optionen.filter((o) => o.korrekt).length === 1, |
| 339 | ); |
| 340 | |
| 341 | /** Fragen mit mehr als einer richtigen Option – nur sie können „unvollständig“ ausgehen. */ |
| 342 | const mitMehrerenRichtigen = auswahlfragen.filter( |
| 343 | (f) => (f.optionen ?? []).filter((o) => o.korrekt).length > 1, |
| 344 | ); |
| 345 | |
| 346 | /** |
| 347 | * Fragen, bei denen **alle** Optionen richtig sind. |
| 348 | * |
| 349 | * Sie sind der Grund, aus dem die Ratechance mit verratener Antwortzahl höher |
| 350 | * liegt als jede Einzelbetrachtung vermuten lässt: Wer weiß, dass alle drei |
| 351 | * anzukreuzen sind, trifft ohne jedes Wissen sicher. |
| 352 | */ |
| 353 | const alleOptionenRichtig = auswahlfragen.filter((f) => { |
| 354 | const optionen = f.optionen ?? []; |
| 355 | return optionen.length > 0 && optionen.every((o) => o.korrekt); |
| 356 | }); |
| 357 | |
| 358 | function binom(n: number, k: number): number { |
| 359 | let wert = 1; |
| 360 | for (let i = 0; i < k; i += 1) { |
| 361 | wert = (wert * (n - i)) / (i + 1); |
| 362 | } |
| 363 | return wert; |
| 364 | } |
| 365 | |
| 366 | /** |
| 367 | * Mittlere Trefferchance beim **Werkszustand** der Anwendung. |
| 368 | * |
| 369 | * `EINSTELLUNGEN_STANDARD.antwortzahlVerbergen` ist `false`: Die Darstellung |
| 370 | * verrät, wie viele Optionen anzukreuzen sind. Wer rät, zieht deshalb eine |
| 371 | * zufällige Teilmenge **der richtigen Größe** – das sind `1 / C(n, k)`. |
| 372 | */ |
| 373 | function chanceMitAnzahlUeber(fragen: readonly KatalogRoh['fragen'][number][]): number { |
| 374 | return ( |
| 375 | fragen.reduce((summe, f) => { |
| 376 | const optionen = f.optionen ?? []; |
| 377 | return summe + 1 / binom(optionen.length, optionen.filter((o) => o.korrekt).length); |
| 378 | }, 0) / fragen.length |
| 379 | ); |
| 380 | } |
| 381 | |
| 382 | const chanceMitAnzahl = chanceMitAnzahlUeber(auswahlfragen); |
| 383 | |
| 384 | /** |
| 385 | * Dieselbe Chance ohne die Fragen, bei denen alle Optionen richtig sind. |
| 386 | * |
| 387 | * Die Gegenprobe zur Aussage der Entscheidung, dass genau diese 28 Fragen den |
| 388 | * Schnitt über 1/3 heben: Ohne sie bleibt praktisch nichts übrig. |
| 389 | */ |
| 390 | const chanceOhneVollrichtige = chanceMitAnzahlUeber( |
| 391 | auswahlfragen.filter((f) => !alleOptionenRichtig.includes(f)), |
| 392 | ); |
| 393 | |
| 394 | /** |
| 395 | * Mittlere Trefferchance, wenn je Frage genau **eine** zufällige Option |
| 396 | * angekreuzt wird – bei Mehrfachauswahl ist die Chance dann 0. |
| 397 | */ |
| 398 | const chanceEineOption = |
| 399 | auswahlfragen.reduce((summe, f) => { |
| 400 | const optionen = f.optionen ?? []; |
| 401 | const richtige = optionen.filter((o) => o.korrekt).length; |
| 402 | return summe + (richtige === 1 ? 1 / optionen.length : 0); |
| 403 | }, 0) / auswahlfragen.length; |
| 404 | |
| 405 | /** |
| 406 | * Mittlere Trefferchance einer zufälligen nichtleeren Teilmenge – der Fall |
| 407 | * mit eingeschaltetem `antwortzahlVerbergen`. |
| 408 | */ |
| 409 | const chanceTeilmenge = |
| 410 | auswahlfragen.reduce((summe, f) => summe + 1 / (2 ** (f.optionen?.length ?? 0) - 1), 0) / |
| 411 | auswahlfragen.length; |
| 412 | |
| 413 | // ── Preis der konservativen Ersteinstufung ────────────────────────────────── |
| 414 | |
| 415 | /** Vorlagen in den ersten vier Wochen – dem Fenster, in dem sich der Preis zeigt. */ |
| 416 | function vorlagenBis(lauf: Lauf, tag: number): number { |
| 417 | return lauf.plan.filter((eintrag) => eintrag.tag < tag).length; |
| 418 | } |
| 419 | |
| 420 | /** |
| 421 | * Mehraufwand, wenn jede Auswahlfrage beim Erstkontakt „schwer“ statt „gut“ |
| 422 | * bekäme – hochgerechnet auf den Katalog mit dem Tempo-Vorgabewert aus |
| 423 | * `lernplan.ts`. Modellannahme: alle Fragen im selben Vier-Wochen-Fenster |
| 424 | * eingeführt. Die Größenordnung trägt, die Nachkommastelle nicht. |
| 425 | */ |
| 426 | const mehrvorlagen28 = |
| 427 | (vorlagenBis(wisserKonservativ, 28) - vorlagenBis(wisser, 28)) * auswahlfragen.length; |
| 428 | const mehrminuten28 = (mehrvorlagen28 * SEKUNDEN_PRO_FRAGE_VORGABE) / 60; |
| 429 | |
| 430 | // ── Ausgabe der Tabellen (nur mit MESSUNG_AUSGABE=1) ──────────────────────── |
| 431 | |
| 432 | function f2(wert: number): string { |
| 433 | return wert.toFixed(2); |
| 434 | } |
| 435 | |
| 436 | function prozent(wert: number): string { |
| 437 | return `${(wert * 100).toFixed(1)} %`; |
| 438 | } |
| 439 | |
| 440 | function planZeile(lauf: Lauf): string { |
| 441 | return lauf.plan |
| 442 | .map((eintrag) => `Tag ${String(eintrag.tag)} → +${String(eintrag.intervall)}`) |
| 443 | .join(', '); |
| 444 | } |
| 445 | |
| 446 | function sammelZeile(name: string, s: Sammel): string { |
| 447 | return ( |
| 448 | `${name}: Sitzungstage ${f2(s.mittelSitzungstage)}, Vorstellungen ${f2(s.mittelVorstellungen)}, ` + |
| 449 | `Median S(60) ${f2(s.medianEndStabilitaet)}, max. Intervall ≥7/≥14/≥30: ` + |
| 450 | `${prozent(s.anteilMax7)}/${prozent(s.anteilMax14)}/${prozent(s.anteilMax30)}, ` + |
| 451 | `belegt ${prozent(s.mittelBelegtAnteil)}, behauptetes R ${f2(s.gepooltesR)}, ` + |
| 452 | `Trefferquote ${f2(s.gepoolteTrefferquote)} ` + |
| 453 | `(Laufmittel ${f2(s.mittelBehauptetesR)} / ${f2(s.mittelTrefferquote)})` |
| 454 | ); |
| 455 | } |
| 456 | |
| 457 | if (AUSGABE) { |
| 458 | /* Direkt an stdout vorbei an der Testausgabe: Vitest zeigt aufgefangene |
| 459 | console-Ausgaben bestandener Dateien nicht an. */ |
| 460 | const schreibe = (zeile: string): void => { |
| 461 | process.stdout.write(`${zeile}\n`); |
| 462 | }; |
| 463 | schreibe(`Katalog: ${String(auswahlfragen.length)} Auswahlfragen, davon`); |
| 464 | schreibe(` 3 Optionen / 1 richtige: ${String(dreiOptionenEineRichtige.length)}`); |
| 465 | schreibe(` mehr als eine richtige: ${String(mitMehrerenRichtigen.length)}`); |
| 466 | schreibe(` alle Optionen richtig: ${String(alleOptionenRichtig.length)}`); |
| 467 | schreibe(` Ratechance, Anzahl verraten (Werkszustand): ${chanceMitAnzahl.toFixed(4)}`); |
| 468 | schreibe(` Ratechance, eine Option geraten: ${chanceEineOption.toFixed(4)}`); |
| 469 | schreibe(` Ratechance, Teilmenge geraten: ${chanceTeilmenge.toFixed(4)}`); |
| 470 | schreibe(''); |
| 471 | schreibe(`Wisser: ${planZeile(wisser)}; S(60) ${f2(wisser.endStabilitaet)}`); |
| 472 | schreibe( |
| 473 | `Wisser konservativ: ${planZeile(wisserKonservativ)}; ` + |
| 474 | `S(60) ${f2(wisserKonservativ.endStabilitaet)}`, |
| 475 | ); |
| 476 | schreibe( |
| 477 | `Dauerhaft unvollständig, heutige Regel: Sitzungstage ${String(dauerhaftUnvollstaendigHeute.sitzungstage)}, ` + |
| 478 | `Vorstellungen ${String(dauerhaftUnvollstaendigHeute.vorstellungen)}, ` + |
| 479 | `max. Intervall ${String(dauerhaftUnvollstaendigHeute.maxIntervall)}, ` + |
| 480 | `S(60) ${f2(dauerhaftUnvollstaendigHeute.endStabilitaet)}, ` + |
| 481 | `behauptetes R ${f2(dauerhaftUnvollstaendigHeute.mittelBehauptetesR)}`, |
| 482 | ); |
| 483 | schreibe( |
| 484 | `Dauerhaft unvollständig, Milderung „schwer“: ${planZeile(dauerhaftUnvollstaendigSchwer)}; ` + |
| 485 | `S(60) ${f2(dauerhaftUnvollstaendigSchwer.endStabilitaet)}, ` + |
| 486 | `behauptetes R ${f2(dauerhaftUnvollstaendigSchwer.mittelBehauptetesR)}`, |
| 487 | ); |
| 488 | schreibe(''); |
| 489 | schreibe(sammelZeile('Rater 1/3', raterDrittel)); |
| 490 | schreibe(sammelZeile('Rater 1/4', raterViertel)); |
| 491 | schreibe(sammelZeile('Rater 1/7', raterSiebtel)); |
| 492 | schreibe(sammelZeile('Rater 1/3 konservativ', raterDrittelKonservativ)); |
| 493 | schreibe(''); |
| 494 | schreibe( |
| 495 | `Preis der konservativen Ersteinstufung: ${String(mehrvorlagen28)} Mehrvorlagen ` + |
| 496 | `in 28 Tagen, rund ${mehrminuten28.toFixed(0)} Minuten`, |
| 497 | ); |
| 498 | } |
| 499 | |
| 500 | // ── Erwartungen: die in der Entscheidung zitierten Kennwerte ──────────────── |
| 501 | |
| 502 | describe('Katalog-Erdung', () => { |
| 503 | it('bestätigt die Ratewahrscheinlichkeiten aus der Entscheidung', () => { |
| 504 | expect(auswahlfragen).toHaveLength(471); |
| 505 | expect(dreiOptionenEineRichtige).toHaveLength(346); |
| 506 | expect(chanceEineOption).toBeCloseTo(0.2555, 3); |
| 507 | expect(chanceTeilmenge).toBeCloseTo(0.1437, 3); |
| 508 | }); |
| 509 | |
| 510 | it('misst die Ratechance des Werkszustands höher als 1/3', () => { |
| 511 | /* Der Werkszustand verrät die Antwortzahl. Das hebt die mittlere |
| 512 | Ratechance über den 1/3-Fall hinaus – vor allem, weil 28 Fragen alle |
| 513 | Optionen als richtig führen und mit verratener Zahl sicher zu treffen |
| 514 | sind. Die Simulationen mit 1/3 und 1/4 sind damit keine Schwarzmalerei, |
| 515 | sondern eher wohlwollend. */ |
| 516 | expect(alleOptionenRichtig).toHaveLength(28); |
| 517 | expect(chanceMitAnzahl).toBeCloseTo(0.3715, 3); |
| 518 | expect(chanceMitAnzahl).toBeGreaterThan(1 / 3); |
| 519 | /* Ohne die 28 sicheren Treffer bleibt praktisch genau 1/3 übrig – sie |
| 520 | allein heben den Schnitt. */ |
| 521 | expect(chanceOhneVollrichtige).toBeCloseTo(0.332, 3); |
| 522 | /* Der vorhandene Schalter senkt sie auf weniger als die Hälfte. */ |
| 523 | expect(chanceTeilmenge).toBeLessThan(chanceMitAnzahl / 2); |
| 524 | }); |
| 525 | |
| 526 | it('begrenzt, wie viele Fragen „unvollständig“ überhaupt ausgehen können', () => { |
| 527 | /* Nur Fragen mit mehr als einer richtigen Option. Das ist die Obergrenze |
| 528 | des Nutzens jeder Milderung an dieser Stelle. */ |
| 529 | expect(mitMehrerenRichtigen).toHaveLength(114); |
| 530 | expect(mitMehrerenRichtigen.length / auswahlfragen.length).toBeLessThan(0.25); |
| 531 | }); |
| 532 | }); |
| 533 | |
| 534 | describe('Gewusste Frage (Bezugsgröße)', () => { |
| 535 | it('kommt in 60 Tagen mit vier Vorlagen aus', () => { |
| 536 | /* Der Vorlageplan, den die Entscheidung zitiert. Er ist deterministisch: |
| 537 | er hängt nur an den FSRS-Parametern und der Zielquote 0,9. */ |
| 538 | expect(wisser.plan).toEqual([ |
| 539 | { tag: 0, intervall: 2 }, |
| 540 | { tag: 2, intervall: 11 }, |
| 541 | { tag: 13, intervall: 46 }, |
| 542 | { tag: 59, intervall: 163 }, |
| 543 | ]); |
| 544 | expect(wisser.vorstellungen).toBe(wisser.sitzungstage); |
| 545 | expect(wisser.endStabilitaet).toBeCloseTo(162.9, 1); |
| 546 | }); |
| 547 | |
| 548 | it('steht ab der zweiten Vorlage durchgehend belegt', () => { |
| 549 | /* Die Gegenprobe zur Belegregel: Tag 0 hat Abstand 0 und belegt nie, |
| 550 | ab Tag 2 steht der Beleg und fällt nicht mehr. 59 von 61 Tagen. */ |
| 551 | expect(wisser.belegteTage).toBe(59); |
| 552 | }); |
| 553 | }); |
| 554 | |
| 555 | describe('Reiner Rater unter der heutigen Regel', () => { |
| 556 | it('wird trotz „gut“-Bewertung fast täglich wieder vorgelegt', () => { |
| 557 | /* Der Kern der Messung: Raten wird zwar je Treffer wie Wissen bewertet, |
| 558 | aber die unvermeidlichen Fehltage reißen die Stabilität sofort wieder |
| 559 | ein. 53 von 61 möglichen Vorlagetagen gegenüber 4 beim Wissenden. */ |
| 560 | expect(raterDrittel.mittelSitzungstage).toBeCloseTo(53.32, 1); |
| 561 | expect(raterViertel.mittelSitzungstage).toBeCloseTo(57.56, 1); |
| 562 | expect(raterDrittel.medianEndStabilitaet).toBeCloseTo(0.21, 2); |
| 563 | expect(raterDrittel.medianEndStabilitaet).toBeLessThan(wisser.endStabilitaet / 100); |
| 564 | }); |
| 565 | |
| 566 | it('überschätzt den Abruf am Vorlagetag deutlich – das ist der Terminierungsfehler', () => { |
| 567 | /* Gepoolt über alle Vorlagen. Dass die gepoolte Trefferquote genau `p` |
| 568 | trifft, ist zugleich die Probe auf die Simulation: Der Rater lernt |
| 569 | nichts dazu, also darf keine andere Zahl herauskommen. */ |
| 570 | expect(raterDrittel.gepooltesR).toBeCloseTo(0.75, 2); |
| 571 | expect(raterDrittel.gepoolteTrefferquote).toBeCloseTo(1 / 3, 2); |
| 572 | expect(raterViertel.gepooltesR).toBeCloseTo(0.71, 2); |
| 573 | expect(raterViertel.gepoolteTrefferquote).toBeCloseTo(1 / 4, 2); |
| 574 | /* Der Fehler wächst, je schlechter geraten wird – das Modell hält an |
| 575 | seiner Zielquote fest, die Wirklichkeit nicht. */ |
| 576 | expect(raterDrittel.gepooltesR - raterDrittel.gepoolteTrefferquote).toBeCloseTo(0.42, 2); |
| 577 | expect(raterViertel.gepooltesR - raterViertel.gepoolteTrefferquote).toBeCloseTo(0.46, 2); |
| 578 | expect(raterSiebtel.gepooltesR - raterSiebtel.gepoolteTrefferquote).toBeCloseTo(0.51, 2); |
| 579 | }); |
| 580 | |
| 581 | it('kann durch Glückssträhnen zeitweise weit hinausgeplant werden', () => { |
| 582 | /* Der bleibende Schaden: eine Strähne erster Treffer schiebt die Frage |
| 583 | wochenweit hinaus, obwohl sie nicht gewusst wird. Die Anteile stehen |
| 584 | in der Entscheidung – hier festgenagelt, damit sie nicht veralten. */ |
| 585 | expect(raterDrittel.anteilMax7).toBeCloseTo(0.17, 2); |
| 586 | expect(raterDrittel.anteilMax14).toBeCloseTo(0.079, 2); |
| 587 | expect(raterDrittel.anteilMax30).toBeCloseTo(0.049, 2); |
| 588 | expect(raterSiebtel.anteilMax14).toBeLessThan(raterDrittel.anteilMax14); |
| 589 | }); |
| 590 | |
| 591 | it('setzt den Beleg nur zeitweise – die Anzeige ist gedämpft, nicht geheilt', () => { |
| 592 | expect(raterDrittel.mittelBelegtAnteil).toBeCloseTo(0.404, 2); |
| 593 | expect(raterViertel.mittelBelegtAnteil).toBeCloseTo(0.283, 2); |
| 594 | }); |
| 595 | }); |
| 596 | |
| 597 | describe('Milderung „unvollständig → schwer“', () => { |
| 598 | it('legt die dauerhaft unvollständige Antwort heute täglich vor', () => { |
| 599 | expect(dauerhaftUnvollstaendigHeute.sitzungstage).toBe(HORIZONT_TAGE + 1); |
| 600 | expect(dauerhaftUnvollstaendigHeute.vorstellungen).toBe(2 * (HORIZONT_TAGE + 1)); |
| 601 | expect(dauerhaftUnvollstaendigHeute.maxIntervall).toBe(1); |
| 602 | expect(dauerhaftUnvollstaendigHeute.belegteTage).toBe(0); |
| 603 | /* Das Modell behauptet hier selbst nur noch 56 % – es hält die Frage |
| 604 | für wackelig und behandelt sie auch so. */ |
| 605 | expect(dauerhaftUnvollstaendigHeute.mittelBehauptetesR).toBeCloseTo(0.56, 2); |
| 606 | }); |
| 607 | |
| 608 | it('würde eine prüfungsfalsche Antwort planmäßig wochenweit hinausschieben', () => { |
| 609 | /* Das Ausschlusskriterium: „schwer“ ist in FSRS ein Erfolg. Eine Antwort, |
| 610 | die in der Prüfung als falsch zählt, bekäme wachsende Intervalle und |
| 611 | am Ende ein behauptetes R an der Zielquote – bei tatsächlicher |
| 612 | Trefferquote null. */ |
| 613 | expect(dauerhaftUnvollstaendigSchwer.plan.map((eintrag) => eintrag.intervall)).toEqual([ |
| 614 | 1, 3, 7, 12, 18, 25, |
| 615 | ]); |
| 616 | expect(dauerhaftUnvollstaendigSchwer.endStabilitaet).toBeCloseTo(25.4, 1); |
| 617 | expect(dauerhaftUnvollstaendigSchwer.mittelBehauptetesR).toBeGreaterThan(0.85); |
| 618 | expect(dauerhaftUnvollstaendigSchwer.trefferquoteErste).toBe(0); |
| 619 | expect(dauerhaftUnvollstaendigSchwer.belegteTage).toBe(0); |
| 620 | }); |
| 621 | }); |
| 622 | |
| 623 | describe('Milderung „konservativere Ersteinstufung“', () => { |
| 624 | it('zieht die Wiedervorlagen des Wissenden nach vorn und drittelt seine Endstabilität', () => { |
| 625 | /* In 60 Tagen kostet die Absenkung keine zusätzliche Vorlage (je vier), |
| 626 | wohl aber in einem 28-Tage-Fenster – und die Frage wird danach früher |
| 627 | wieder fällig (Tag 73 statt Tag 222). */ |
| 628 | expect(wisserKonservativ.plan).toEqual([ |
| 629 | { tag: 0, intervall: 1 }, |
| 630 | { tag: 1, intervall: 5 }, |
| 631 | { tag: 6, intervall: 17 }, |
| 632 | { tag: 23, intervall: 50 }, |
| 633 | ]); |
| 634 | expect(wisserKonservativ.plan.filter((eintrag) => eintrag.tag < 28)).toHaveLength(4); |
| 635 | expect(wisser.plan.filter((eintrag) => eintrag.tag < 28)).toHaveLength(3); |
| 636 | expect(wisserKonservativ.endStabilitaet).toBeLessThan(wisser.endStabilitaet / 3); |
| 637 | }); |
| 638 | |
| 639 | it('dämpft nur die Glückssträhne der ersten Tage, nicht die aus dem Verlauf', () => { |
| 640 | /* Das Muster, das die Abwägung trägt: ≥ 7 Tage entsteht überwiegend aus |
| 641 | zwei bis drei Treffern direkt am Anfang – die konservative Ersteinstufung |
| 642 | halbiert diesen Anteil fast. ≥ 14 Tage entsteht überwiegend aus Strähnen |
| 643 | mitten im Verlauf, wo die Ersteinstufung längst keine Rolle mehr |
| 644 | spielt – dieser Anteil bewegt sich kaum. */ |
| 645 | expect(raterDrittelKonservativ.anteilMax7).toBeCloseTo(0.1, 2); |
| 646 | expect(raterDrittelKonservativ.anteilMax7).toBeLessThan(raterDrittel.anteilMax7 * 0.7); |
| 647 | expect(raterDrittelKonservativ.anteilMax14).toBeCloseTo(0.071, 2); |
| 648 | expect(Math.abs(raterDrittelKonservativ.anteilMax14 - raterDrittel.anteilMax14)).toBeLessThan( |
| 649 | 0.02, |
| 650 | ); |
| 651 | }); |
| 652 | |
| 653 | it('bleibt deutlich hinter dem vorhandenen Schalter zurück', () => { |
| 654 | /* Der Vergleich, der die Entscheidung trägt: Am Eingang anzusetzen wirkt |
| 655 | auf alle drei Anteile rund viermal so stark wie am Modell. `p` = 1/7 |
| 656 | steht für den Zustand mit verborgener Antwortzahl (gemessene mittlere |
| 657 | Ratechance dort: 0,1437). */ |
| 658 | const minderung = (vorher: number, nachher: number): number => 1 - nachher / vorher; |
| 659 | |
| 660 | expect(minderung(raterDrittel.anteilMax7, raterDrittelKonservativ.anteilMax7)).toBeCloseTo( |
| 661 | 0.41, |
| 662 | 2, |
| 663 | ); |
| 664 | expect(minderung(raterDrittel.anteilMax7, raterSiebtel.anteilMax7)).toBeGreaterThan(0.85); |
| 665 | expect(minderung(raterDrittel.anteilMax14, raterSiebtel.anteilMax14)).toBeGreaterThan(0.85); |
| 666 | expect(minderung(raterDrittel.anteilMax30, raterSiebtel.anteilMax30)).toBeGreaterThan(0.85); |
| 667 | expect(raterSiebtel.mittelBelegtAnteil).toBeCloseTo(0.151, 2); |
| 668 | }); |
| 669 | |
| 670 | it('kostet den Wissenden je Auswahlfrage eine Vorlage im ersten Monat', () => { |
| 671 | /* Der Preis, gegen den der Gewinn zu halten ist: eine Vorlage mehr je |
| 672 | Frage, über den Katalog rund drei Stunden. Er fällt bei **jeder** Frage |
| 673 | an, der Gewinn nur bei den geratenen. */ |
| 674 | expect(mehrvorlagen28).toBe(471); |
| 675 | expect(mehrminuten28).toBeCloseTo(196, 0); |
| 676 | }); |
| 677 | }); |