waffensachkunde

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

/ app tests messung-ratewahrscheinlichkeit.test.ts

28,3 KB Rohdatei
app/tests/messung-ratewahrscheinlichkeit.test.ts — 677 Zeilen
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 });