// @vitest-environment node /** * Messskript zu `docs/entscheidung-ratewahrscheinlichkeit.md`. * * Die Frage: `bewertungAusErgebnis` wertet jede vollständig richtige * Auswahl-Antwort als „gut“ – auch die erratene. Wie stark verplant sich die * FSRS-Terminierung dadurch, und helfen die naheliegenden Milderungen * (unvollständig → „schwer“; konservativere Ersteinstufung)? * * ## Modell der Simulation * * Gerechnet wird mit den echten Funktionen aus `src/shared/fsrs.ts` und * `src/shared/lernplan.ts`, nicht mit einem Nachbau. Angenommen wird: * * - Der Lernende lernt täglich; eine an Tag `t` fällige Frage wird an Tag `t` * beantwortet. Zielquote 0,9 (kein Prüfungstermin) – die Anhebung vor dem * Termin (`zielquoteFuer`) verkürzt alle Intervalle nur weiter und würde * jeden hier gemessenen Fehler verkleinern, nicht vergrößern. * - Auswahl-Antworten gehen wie in `bewertungAusErgebnis` ein: * richtig → „gut“ (3), sonst „nochmal“ (1). * - „nochmal“ ergibt Intervall 0 (`wiedervorlageBerechnen`): die Frage kommt * in derselben Sitzung wieder. Weil die Lösung nach dem Bestätigen auf dem * Bildschirm stand, gilt die Sofortwiederholung als richtig („gut“ bei * Abstand 0, Kurzfristformel). Das ist die für das Modell **wohlwollende** * Annahme: sie schenkt der geratenen Frage den kleinen Kurzfrist-Zuwachs. * - Der „reine Rater“ trifft an jedem Vorlagetag unabhängig mit fester * Wahrscheinlichkeit `p` und lernt nichts dazu – der schlechteste Fall. * In der echten Anwendung zeigt jede Vorlage die Lösung; dieser Fall ist * also eine obere Schranke, kein typischer Lernender. * * ## Reproduzieren * * Die Kennwerte, die die Entscheidung zitiert, sind unten als Erwartungen * festgenagelt – ändert sich `fsrs.ts` oder `lernplan.ts`, wird diese Datei * rot und die Entscheidung muss nachgeführt werden. Die vollständigen * Tabellen druckt der Lauf auf Wunsch: * * PowerShell: $env:MESSUNG_AUSGABE='1'; npx vitest run tests/messung-ratewahrscheinlichkeit.test.ts * POSIX: MESSUNG_AUSGABE=1 npx vitest run tests/messung-ratewahrscheinlichkeit.test.ts */ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { abrufwahrscheinlichkeit, GRAD_GUT, GRAD_NOCHMAL, GRAD_SCHWER, type FsrsGrad, type Gedaechtnisstand, } from '../src/shared/fsrs'; import { SEKUNDEN_PRO_FRAGE_VORGABE, wiedervorlageBerechnen } from '../src/shared/lernplan'; import { MINDESTABSTAND_TAGE } from '../src/shared/reife'; /** Betrachtungszeitraum: Tag 0 bis einschließlich Tag 60. */ const HORIZONT_TAGE = 60; /** * Läufe je Zufallsszenario. Bei 20 000 Läufen schwanken die gemessenen * Anteile um weniger als einen Prozentpunkt – genau genug für jede hier * gezogene Schlussfolgerung, schnell genug für den normalen Testlauf. */ const LAEUFE = 20000; /** Ausgabe der Tabellen nur auf Wunsch – der normale Testlauf bleibt still. */ const AUSGABE = process.env['MESSUNG_AUSGABE'] === '1'; /** * Eigene Zufallsquelle (mulberry32) statt `Math.random`: nur mit fester Saat * sind die unten festgenagelten Kennwerte auf jedem Rechner dieselben. */ function zufallsquelle(saat: number): () => number { let zustand = saat >>> 0; return () => { zustand = (zustand + 0x6d2b79f5) >>> 0; let t = zustand; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /** Wie die erste Vorstellung eines Tages ausgeht. */ interface Tagesantwort { /** Grad, mit dem die Antwort ins Gedächtnismodell eingeht. */ readonly grad: FsrsGrad; /** Ob sie im Sinne von `bewerteAuswahl` vollständig richtig war. */ readonly richtig: boolean; } interface Lauf { /** Tage, an denen die Frage vorkam. */ readonly sitzungstage: number; /** Beantwortungen insgesamt, Sofortwiederholungen mitgezählt. */ readonly vorstellungen: number; /** Größtes geplantes Intervall. */ readonly maxIntervall: number; readonly endStabilitaet: number; /** Tage, an deren Ende der Beleg (`bestaetigt`) stand. */ readonly belegteTage: number; /** Mittleres Modell-R unmittelbar vor einer Wiedervorlage. */ readonly mittelBehauptetesR: number; /** Empirische Trefferquote der jeweils ersten Tagesvorstellung. */ readonly trefferquoteErste: number; /** Treffer bei der ersten Tagesvorstellung – Zähler für die Poolung. */ readonly ersteTreffer: number; /** Summe und Anzahl der Modell-R-Werte – Zähler für die Poolung. */ readonly summeR: number; readonly zaehlerR: number; /** Vorlagetage samt danach geplantem Intervall – für die Tabellen. */ readonly plan: readonly { readonly tag: number; readonly intervall: number }[]; } /** * Spielt eine einzelne Frage über den Horizont durch – mit exakt den Regeln * des Anwendungskerns: `wiedervorlageBerechnen` für den Termin, die * Belegregel aus `main/lernstand.ts` für `bestaetigt`. */ function simuliere(antwort: (ersteAntwort: boolean) => Tagesantwort): Lauf { let stand: Gedaechtnisstand | null = null; let letzterTag: number | null = null; let faelligAb = 0; let belegt = false; let sitzungstage = 0; let vorstellungen = 0; let maxIntervall = 0; let belegteTage = 0; let summeR = 0; let zaehlerR = 0; let ersteTreffer = 0; const plan: { tag: number; intervall: number }[] = []; for (let tag = 0; tag <= HORIZONT_TAGE; tag += 1) { if (tag >= faelligAb) { const abstand = letzterTag === null ? 0 : tag - letzterTag; /* Die Behauptung des Modells im Moment der Vorlage – der Terminplan hat das Intervall ja gerade so gewählt, dass hier rund 90 % stehen. */ if (stand !== null) { summeR += abrufwahrscheinlichkeit(stand.stabilitaet, abstand); zaehlerR += 1; } const a = antwort(stand === null); if (a.richtig) { ersteTreffer += 1; } let vorlage = wiedervorlageBerechnen(stand, a.grad, abstand, null); stand = { stabilitaet: vorlage.stabilitaet, schwierigkeit: vorlage.schwierigkeit }; /* Belegregel wie in `main/lernstand.ts`: falsch nimmt den Beleg, richtig nach ≥ 1 Tag setzt ihn, richtig am selben Tag lässt ihn. */ belegt = a.richtig ? abstand >= MINDESTABSTAND_TAGE || belegt : false; vorstellungen += 1; if (vorlage.intervallTage === 0) { /* Sofortwiederholung in derselben Sitzung. Die Lösung stand gerade auf dem Bildschirm, also sitzt sie kurzfristig: „gut“ bei Abstand 0. Der Beleg bleibt weg – richtig bei Abstand 0 setzt ihn nicht. */ vorlage = wiedervorlageBerechnen(stand, GRAD_GUT, 0, null); stand = { stabilitaet: vorlage.stabilitaet, schwierigkeit: vorlage.schwierigkeit }; vorstellungen += 1; } sitzungstage += 1; maxIntervall = Math.max(maxIntervall, vorlage.intervallTage); plan.push({ tag, intervall: vorlage.intervallTage }); letzterTag = tag; faelligAb = tag + vorlage.intervallTage; } if (belegt) { belegteTage += 1; } } return { sitzungstage, vorstellungen, maxIntervall, endStabilitaet: stand === null ? 0 : stand.stabilitaet, belegteTage, mittelBehauptetesR: zaehlerR === 0 ? Number.NaN : summeR / zaehlerR, trefferquoteErste: sitzungstage === 0 ? Number.NaN : ersteTreffer / sitzungstage, ersteTreffer, summeR, zaehlerR, plan, }; } /** Kennwerte über viele Läufe eines Zufallsszenarios. */ interface Sammel { readonly mittelSitzungstage: number; readonly mittelVorstellungen: number; readonly medianEndStabilitaet: number; /** Anteil der Läufe, in denen das Intervall je mindestens n Tage erreichte. */ readonly anteilMax7: number; readonly anteilMax14: number; readonly anteilMax30: number; /** Mittlerer Anteil der Tage, an denen der Beleg stand. */ readonly mittelBelegtAnteil: number; /** * Über **alle** Vorlagen gepoolt, nicht als Mittel der Laufmittel. * * Der Unterschied ist kein Formalismus: Ein Glückslauf hat weniger * Vorlagetage und wiegt im Mittel der Verhältnisse deshalb schwerer, als * ihm zusteht. Die Entscheidung zitiert die gepoolten Werte; die * Laufmittel stehen daneben, damit der Abstand sichtbar bleibt. */ readonly gepooltesR: number; readonly gepoolteTrefferquote: number; readonly mittelBehauptetesR: number; readonly mittelTrefferquote: number; } function median(werte: readonly number[]): number { const sortiert = [...werte].sort((a, b) => a - b); const mitte = sortiert[Math.floor(sortiert.length / 2)]; if (mitte === undefined) { throw new Error('Median einer leeren Liste.'); } return mitte; } /** * Reiner Rater mit Trefferwahrscheinlichkeit `p` je erster Tagesvorstellung. * * @param gradEinstieg Grad der **allerersten** richtigen Antwort – `GRAD_GUT` * ist der heutige Stand, `GRAD_SCHWER` die Milderungsoption * „konservativere Ersteinstufung“. */ function messeRater(p: number, gradEinstieg: FsrsGrad = GRAD_GUT): Sammel { const zufall = zufallsquelle(20260825); const tage = HORIZONT_TAGE + 1; let sitzungstage = 0; let vorstellungen = 0; let max7 = 0; let max14 = 0; let max30 = 0; let belegt = 0; let behauptet = 0; let treffer = 0; let summeR = 0; let zaehlerR = 0; let ersteTreffer = 0; const endStabilitaeten: number[] = []; for (let lauf = 0; lauf < LAEUFE; lauf += 1) { const ergebnis = simuliere((ersteAntwort) => zufall() < p ? { grad: ersteAntwort ? gradEinstieg : GRAD_GUT, richtig: true } : { grad: GRAD_NOCHMAL, richtig: false }, ); sitzungstage += ergebnis.sitzungstage; vorstellungen += ergebnis.vorstellungen; if (ergebnis.maxIntervall >= 7) max7 += 1; if (ergebnis.maxIntervall >= 14) max14 += 1; if (ergebnis.maxIntervall >= 30) max30 += 1; belegt += ergebnis.belegteTage / tage; behauptet += ergebnis.mittelBehauptetesR; treffer += ergebnis.trefferquoteErste; summeR += ergebnis.summeR; zaehlerR += ergebnis.zaehlerR; ersteTreffer += ergebnis.ersteTreffer; endStabilitaeten.push(ergebnis.endStabilitaet); } return { mittelSitzungstage: sitzungstage / LAEUFE, mittelVorstellungen: vorstellungen / LAEUFE, medianEndStabilitaet: median(endStabilitaeten), anteilMax7: max7 / LAEUFE, anteilMax14: max14 / LAEUFE, anteilMax30: max30 / LAEUFE, mittelBelegtAnteil: belegt / LAEUFE, gepooltesR: summeR / zaehlerR, gepoolteTrefferquote: ersteTreffer / sitzungstage, mittelBehauptetesR: behauptet / LAEUFE, mittelTrefferquote: treffer / LAEUFE, }; } // ── Die Szenarien ─────────────────────────────────────────────────────────── /** (a) Gewusst: jede Vorlage vollständig richtig. */ const wisser = simuliere(() => ({ grad: GRAD_GUT, richtig: true })); /** Milderung „konservativere Ersteinstufung“ beim Wissenden. */ const wisserKonservativ = simuliere((ersteAntwort) => ({ grad: ersteAntwort ? GRAD_SCHWER : GRAD_GUT, richtig: true, })); /** * Dauerhaft unvollständige Antwort (z. B. stets nur eine von zwei richtigen * Optionen) unter der heutigen Regel: unvollständig → „nochmal“. */ const dauerhaftUnvollstaendigHeute = simuliere(() => ({ grad: GRAD_NOCHMAL, richtig: false })); /** Dieselbe Antwort unter der Milderungsoption: unvollständig → „schwer“. */ const dauerhaftUnvollstaendigSchwer = simuliere(() => ({ grad: GRAD_SCHWER, richtig: false })); /** * (b) Reine Rater. 1/3 und 1/4 als die beiden Ratewahrscheinlichkeiten des * Befunds; 1/7 = 0,1429 steht für den Werkszustand mit eingeschaltetem * `antwortzahlVerbergen` – die dort gemessene mittlere Ratechance ist 0,1437. */ const raterDrittel = messeRater(1 / 3); const raterViertel = messeRater(1 / 4); const raterSiebtel = messeRater(1 / 7); /** Milderung „konservativere Ersteinstufung“ beim häufigsten Rater-Fall. */ const raterDrittelKonservativ = messeRater(1 / 3, GRAD_SCHWER); // ── Katalog-Erdung der Ratewahrscheinlichkeiten ───────────────────────────── interface KatalogRoh { readonly fragen: readonly { readonly typ: string; readonly optionen?: readonly { readonly korrekt: boolean }[]; }[]; } const katalog = JSON.parse( readFileSync(join(__dirname, '..', '..', 'content', 'katalog', 'katalog.json'), 'utf8'), ) as KatalogRoh; const auswahlfragen = katalog.fragen.filter((f) => f.typ === 'mc' && (f.optionen?.length ?? 0) > 0); /** 3 Optionen, genau 1 richtige – der Fall mit Ratewahrscheinlichkeit 1/3. */ const dreiOptionenEineRichtige = auswahlfragen.filter( (f) => f.optionen?.length === 3 && f.optionen.filter((o) => o.korrekt).length === 1, ); /** Fragen mit mehr als einer richtigen Option – nur sie können „unvollständig“ ausgehen. */ const mitMehrerenRichtigen = auswahlfragen.filter( (f) => (f.optionen ?? []).filter((o) => o.korrekt).length > 1, ); /** * Fragen, bei denen **alle** Optionen richtig sind. * * Sie sind der Grund, aus dem die Ratechance mit verratener Antwortzahl höher * liegt als jede Einzelbetrachtung vermuten lässt: Wer weiß, dass alle drei * anzukreuzen sind, trifft ohne jedes Wissen sicher. */ const alleOptionenRichtig = auswahlfragen.filter((f) => { const optionen = f.optionen ?? []; return optionen.length > 0 && optionen.every((o) => o.korrekt); }); function binom(n: number, k: number): number { let wert = 1; for (let i = 0; i < k; i += 1) { wert = (wert * (n - i)) / (i + 1); } return wert; } /** * Mittlere Trefferchance beim **Werkszustand** der Anwendung. * * `EINSTELLUNGEN_STANDARD.antwortzahlVerbergen` ist `false`: Die Darstellung * verrät, wie viele Optionen anzukreuzen sind. Wer rät, zieht deshalb eine * zufällige Teilmenge **der richtigen Größe** – das sind `1 / C(n, k)`. */ function chanceMitAnzahlUeber(fragen: readonly KatalogRoh['fragen'][number][]): number { return ( fragen.reduce((summe, f) => { const optionen = f.optionen ?? []; return summe + 1 / binom(optionen.length, optionen.filter((o) => o.korrekt).length); }, 0) / fragen.length ); } const chanceMitAnzahl = chanceMitAnzahlUeber(auswahlfragen); /** * Dieselbe Chance ohne die Fragen, bei denen alle Optionen richtig sind. * * Die Gegenprobe zur Aussage der Entscheidung, dass genau diese 28 Fragen den * Schnitt über 1/3 heben: Ohne sie bleibt praktisch nichts übrig. */ const chanceOhneVollrichtige = chanceMitAnzahlUeber( auswahlfragen.filter((f) => !alleOptionenRichtig.includes(f)), ); /** * Mittlere Trefferchance, wenn je Frage genau **eine** zufällige Option * angekreuzt wird – bei Mehrfachauswahl ist die Chance dann 0. */ const chanceEineOption = auswahlfragen.reduce((summe, f) => { const optionen = f.optionen ?? []; const richtige = optionen.filter((o) => o.korrekt).length; return summe + (richtige === 1 ? 1 / optionen.length : 0); }, 0) / auswahlfragen.length; /** * Mittlere Trefferchance einer zufälligen nichtleeren Teilmenge – der Fall * mit eingeschaltetem `antwortzahlVerbergen`. */ const chanceTeilmenge = auswahlfragen.reduce((summe, f) => summe + 1 / (2 ** (f.optionen?.length ?? 0) - 1), 0) / auswahlfragen.length; // ── Preis der konservativen Ersteinstufung ────────────────────────────────── /** Vorlagen in den ersten vier Wochen – dem Fenster, in dem sich der Preis zeigt. */ function vorlagenBis(lauf: Lauf, tag: number): number { return lauf.plan.filter((eintrag) => eintrag.tag < tag).length; } /** * Mehraufwand, wenn jede Auswahlfrage beim Erstkontakt „schwer“ statt „gut“ * bekäme – hochgerechnet auf den Katalog mit dem Tempo-Vorgabewert aus * `lernplan.ts`. Modellannahme: alle Fragen im selben Vier-Wochen-Fenster * eingeführt. Die Größenordnung trägt, die Nachkommastelle nicht. */ const mehrvorlagen28 = (vorlagenBis(wisserKonservativ, 28) - vorlagenBis(wisser, 28)) * auswahlfragen.length; const mehrminuten28 = (mehrvorlagen28 * SEKUNDEN_PRO_FRAGE_VORGABE) / 60; // ── Ausgabe der Tabellen (nur mit MESSUNG_AUSGABE=1) ──────────────────────── function f2(wert: number): string { return wert.toFixed(2); } function prozent(wert: number): string { return `${(wert * 100).toFixed(1)} %`; } function planZeile(lauf: Lauf): string { return lauf.plan .map((eintrag) => `Tag ${String(eintrag.tag)} → +${String(eintrag.intervall)}`) .join(', '); } function sammelZeile(name: string, s: Sammel): string { return ( `${name}: Sitzungstage ${f2(s.mittelSitzungstage)}, Vorstellungen ${f2(s.mittelVorstellungen)}, ` + `Median S(60) ${f2(s.medianEndStabilitaet)}, max. Intervall ≥7/≥14/≥30: ` + `${prozent(s.anteilMax7)}/${prozent(s.anteilMax14)}/${prozent(s.anteilMax30)}, ` + `belegt ${prozent(s.mittelBelegtAnteil)}, behauptetes R ${f2(s.gepooltesR)}, ` + `Trefferquote ${f2(s.gepoolteTrefferquote)} ` + `(Laufmittel ${f2(s.mittelBehauptetesR)} / ${f2(s.mittelTrefferquote)})` ); } if (AUSGABE) { /* Direkt an stdout vorbei an der Testausgabe: Vitest zeigt aufgefangene console-Ausgaben bestandener Dateien nicht an. */ const schreibe = (zeile: string): void => { process.stdout.write(`${zeile}\n`); }; schreibe(`Katalog: ${String(auswahlfragen.length)} Auswahlfragen, davon`); schreibe(` 3 Optionen / 1 richtige: ${String(dreiOptionenEineRichtige.length)}`); schreibe(` mehr als eine richtige: ${String(mitMehrerenRichtigen.length)}`); schreibe(` alle Optionen richtig: ${String(alleOptionenRichtig.length)}`); schreibe(` Ratechance, Anzahl verraten (Werkszustand): ${chanceMitAnzahl.toFixed(4)}`); schreibe(` Ratechance, eine Option geraten: ${chanceEineOption.toFixed(4)}`); schreibe(` Ratechance, Teilmenge geraten: ${chanceTeilmenge.toFixed(4)}`); schreibe(''); schreibe(`Wisser: ${planZeile(wisser)}; S(60) ${f2(wisser.endStabilitaet)}`); schreibe( `Wisser konservativ: ${planZeile(wisserKonservativ)}; ` + `S(60) ${f2(wisserKonservativ.endStabilitaet)}`, ); schreibe( `Dauerhaft unvollständig, heutige Regel: Sitzungstage ${String(dauerhaftUnvollstaendigHeute.sitzungstage)}, ` + `Vorstellungen ${String(dauerhaftUnvollstaendigHeute.vorstellungen)}, ` + `max. Intervall ${String(dauerhaftUnvollstaendigHeute.maxIntervall)}, ` + `S(60) ${f2(dauerhaftUnvollstaendigHeute.endStabilitaet)}, ` + `behauptetes R ${f2(dauerhaftUnvollstaendigHeute.mittelBehauptetesR)}`, ); schreibe( `Dauerhaft unvollständig, Milderung „schwer“: ${planZeile(dauerhaftUnvollstaendigSchwer)}; ` + `S(60) ${f2(dauerhaftUnvollstaendigSchwer.endStabilitaet)}, ` + `behauptetes R ${f2(dauerhaftUnvollstaendigSchwer.mittelBehauptetesR)}`, ); schreibe(''); schreibe(sammelZeile('Rater 1/3', raterDrittel)); schreibe(sammelZeile('Rater 1/4', raterViertel)); schreibe(sammelZeile('Rater 1/7', raterSiebtel)); schreibe(sammelZeile('Rater 1/3 konservativ', raterDrittelKonservativ)); schreibe(''); schreibe( `Preis der konservativen Ersteinstufung: ${String(mehrvorlagen28)} Mehrvorlagen ` + `in 28 Tagen, rund ${mehrminuten28.toFixed(0)} Minuten`, ); } // ── Erwartungen: die in der Entscheidung zitierten Kennwerte ──────────────── describe('Katalog-Erdung', () => { it('bestätigt die Ratewahrscheinlichkeiten aus der Entscheidung', () => { expect(auswahlfragen).toHaveLength(471); expect(dreiOptionenEineRichtige).toHaveLength(346); expect(chanceEineOption).toBeCloseTo(0.2555, 3); expect(chanceTeilmenge).toBeCloseTo(0.1437, 3); }); it('misst die Ratechance des Werkszustands höher als 1/3', () => { /* Der Werkszustand verrät die Antwortzahl. Das hebt die mittlere Ratechance über den 1/3-Fall hinaus – vor allem, weil 28 Fragen alle Optionen als richtig führen und mit verratener Zahl sicher zu treffen sind. Die Simulationen mit 1/3 und 1/4 sind damit keine Schwarzmalerei, sondern eher wohlwollend. */ expect(alleOptionenRichtig).toHaveLength(28); expect(chanceMitAnzahl).toBeCloseTo(0.3715, 3); expect(chanceMitAnzahl).toBeGreaterThan(1 / 3); /* Ohne die 28 sicheren Treffer bleibt praktisch genau 1/3 übrig – sie allein heben den Schnitt. */ expect(chanceOhneVollrichtige).toBeCloseTo(0.332, 3); /* Der vorhandene Schalter senkt sie auf weniger als die Hälfte. */ expect(chanceTeilmenge).toBeLessThan(chanceMitAnzahl / 2); }); it('begrenzt, wie viele Fragen „unvollständig“ überhaupt ausgehen können', () => { /* Nur Fragen mit mehr als einer richtigen Option. Das ist die Obergrenze des Nutzens jeder Milderung an dieser Stelle. */ expect(mitMehrerenRichtigen).toHaveLength(114); expect(mitMehrerenRichtigen.length / auswahlfragen.length).toBeLessThan(0.25); }); }); describe('Gewusste Frage (Bezugsgröße)', () => { it('kommt in 60 Tagen mit vier Vorlagen aus', () => { /* Der Vorlageplan, den die Entscheidung zitiert. Er ist deterministisch: er hängt nur an den FSRS-Parametern und der Zielquote 0,9. */ expect(wisser.plan).toEqual([ { tag: 0, intervall: 2 }, { tag: 2, intervall: 11 }, { tag: 13, intervall: 46 }, { tag: 59, intervall: 163 }, ]); expect(wisser.vorstellungen).toBe(wisser.sitzungstage); expect(wisser.endStabilitaet).toBeCloseTo(162.9, 1); }); it('steht ab der zweiten Vorlage durchgehend belegt', () => { /* Die Gegenprobe zur Belegregel: Tag 0 hat Abstand 0 und belegt nie, ab Tag 2 steht der Beleg und fällt nicht mehr. 59 von 61 Tagen. */ expect(wisser.belegteTage).toBe(59); }); }); describe('Reiner Rater unter der heutigen Regel', () => { it('wird trotz „gut“-Bewertung fast täglich wieder vorgelegt', () => { /* Der Kern der Messung: Raten wird zwar je Treffer wie Wissen bewertet, aber die unvermeidlichen Fehltage reißen die Stabilität sofort wieder ein. 53 von 61 möglichen Vorlagetagen gegenüber 4 beim Wissenden. */ expect(raterDrittel.mittelSitzungstage).toBeCloseTo(53.32, 1); expect(raterViertel.mittelSitzungstage).toBeCloseTo(57.56, 1); expect(raterDrittel.medianEndStabilitaet).toBeCloseTo(0.21, 2); expect(raterDrittel.medianEndStabilitaet).toBeLessThan(wisser.endStabilitaet / 100); }); it('überschätzt den Abruf am Vorlagetag deutlich – das ist der Terminierungsfehler', () => { /* Gepoolt über alle Vorlagen. Dass die gepoolte Trefferquote genau `p` trifft, ist zugleich die Probe auf die Simulation: Der Rater lernt nichts dazu, also darf keine andere Zahl herauskommen. */ expect(raterDrittel.gepooltesR).toBeCloseTo(0.75, 2); expect(raterDrittel.gepoolteTrefferquote).toBeCloseTo(1 / 3, 2); expect(raterViertel.gepooltesR).toBeCloseTo(0.71, 2); expect(raterViertel.gepoolteTrefferquote).toBeCloseTo(1 / 4, 2); /* Der Fehler wächst, je schlechter geraten wird – das Modell hält an seiner Zielquote fest, die Wirklichkeit nicht. */ expect(raterDrittel.gepooltesR - raterDrittel.gepoolteTrefferquote).toBeCloseTo(0.42, 2); expect(raterViertel.gepooltesR - raterViertel.gepoolteTrefferquote).toBeCloseTo(0.46, 2); expect(raterSiebtel.gepooltesR - raterSiebtel.gepoolteTrefferquote).toBeCloseTo(0.51, 2); }); it('kann durch Glückssträhnen zeitweise weit hinausgeplant werden', () => { /* Der bleibende Schaden: eine Strähne erster Treffer schiebt die Frage wochenweit hinaus, obwohl sie nicht gewusst wird. Die Anteile stehen in der Entscheidung – hier festgenagelt, damit sie nicht veralten. */ expect(raterDrittel.anteilMax7).toBeCloseTo(0.17, 2); expect(raterDrittel.anteilMax14).toBeCloseTo(0.079, 2); expect(raterDrittel.anteilMax30).toBeCloseTo(0.049, 2); expect(raterSiebtel.anteilMax14).toBeLessThan(raterDrittel.anteilMax14); }); it('setzt den Beleg nur zeitweise – die Anzeige ist gedämpft, nicht geheilt', () => { expect(raterDrittel.mittelBelegtAnteil).toBeCloseTo(0.404, 2); expect(raterViertel.mittelBelegtAnteil).toBeCloseTo(0.283, 2); }); }); describe('Milderung „unvollständig → schwer“', () => { it('legt die dauerhaft unvollständige Antwort heute täglich vor', () => { expect(dauerhaftUnvollstaendigHeute.sitzungstage).toBe(HORIZONT_TAGE + 1); expect(dauerhaftUnvollstaendigHeute.vorstellungen).toBe(2 * (HORIZONT_TAGE + 1)); expect(dauerhaftUnvollstaendigHeute.maxIntervall).toBe(1); expect(dauerhaftUnvollstaendigHeute.belegteTage).toBe(0); /* Das Modell behauptet hier selbst nur noch 56 % – es hält die Frage für wackelig und behandelt sie auch so. */ expect(dauerhaftUnvollstaendigHeute.mittelBehauptetesR).toBeCloseTo(0.56, 2); }); it('würde eine prüfungsfalsche Antwort planmäßig wochenweit hinausschieben', () => { /* Das Ausschlusskriterium: „schwer“ ist in FSRS ein Erfolg. Eine Antwort, die in der Prüfung als falsch zählt, bekäme wachsende Intervalle und am Ende ein behauptetes R an der Zielquote – bei tatsächlicher Trefferquote null. */ expect(dauerhaftUnvollstaendigSchwer.plan.map((eintrag) => eintrag.intervall)).toEqual([ 1, 3, 7, 12, 18, 25, ]); expect(dauerhaftUnvollstaendigSchwer.endStabilitaet).toBeCloseTo(25.4, 1); expect(dauerhaftUnvollstaendigSchwer.mittelBehauptetesR).toBeGreaterThan(0.85); expect(dauerhaftUnvollstaendigSchwer.trefferquoteErste).toBe(0); expect(dauerhaftUnvollstaendigSchwer.belegteTage).toBe(0); }); }); describe('Milderung „konservativere Ersteinstufung“', () => { it('zieht die Wiedervorlagen des Wissenden nach vorn und drittelt seine Endstabilität', () => { /* In 60 Tagen kostet die Absenkung keine zusätzliche Vorlage (je vier), wohl aber in einem 28-Tage-Fenster – und die Frage wird danach früher wieder fällig (Tag 73 statt Tag 222). */ expect(wisserKonservativ.plan).toEqual([ { tag: 0, intervall: 1 }, { tag: 1, intervall: 5 }, { tag: 6, intervall: 17 }, { tag: 23, intervall: 50 }, ]); expect(wisserKonservativ.plan.filter((eintrag) => eintrag.tag < 28)).toHaveLength(4); expect(wisser.plan.filter((eintrag) => eintrag.tag < 28)).toHaveLength(3); expect(wisserKonservativ.endStabilitaet).toBeLessThan(wisser.endStabilitaet / 3); }); it('dämpft nur die Glückssträhne der ersten Tage, nicht die aus dem Verlauf', () => { /* Das Muster, das die Abwägung trägt: ≥ 7 Tage entsteht überwiegend aus zwei bis drei Treffern direkt am Anfang – die konservative Ersteinstufung halbiert diesen Anteil fast. ≥ 14 Tage entsteht überwiegend aus Strähnen mitten im Verlauf, wo die Ersteinstufung längst keine Rolle mehr spielt – dieser Anteil bewegt sich kaum. */ expect(raterDrittelKonservativ.anteilMax7).toBeCloseTo(0.1, 2); expect(raterDrittelKonservativ.anteilMax7).toBeLessThan(raterDrittel.anteilMax7 * 0.7); expect(raterDrittelKonservativ.anteilMax14).toBeCloseTo(0.071, 2); expect(Math.abs(raterDrittelKonservativ.anteilMax14 - raterDrittel.anteilMax14)).toBeLessThan( 0.02, ); }); it('bleibt deutlich hinter dem vorhandenen Schalter zurück', () => { /* Der Vergleich, der die Entscheidung trägt: Am Eingang anzusetzen wirkt auf alle drei Anteile rund viermal so stark wie am Modell. `p` = 1/7 steht für den Zustand mit verborgener Antwortzahl (gemessene mittlere Ratechance dort: 0,1437). */ const minderung = (vorher: number, nachher: number): number => 1 - nachher / vorher; expect(minderung(raterDrittel.anteilMax7, raterDrittelKonservativ.anteilMax7)).toBeCloseTo( 0.41, 2, ); expect(minderung(raterDrittel.anteilMax7, raterSiebtel.anteilMax7)).toBeGreaterThan(0.85); expect(minderung(raterDrittel.anteilMax14, raterSiebtel.anteilMax14)).toBeGreaterThan(0.85); expect(minderung(raterDrittel.anteilMax30, raterSiebtel.anteilMax30)).toBeGreaterThan(0.85); expect(raterSiebtel.mittelBelegtAnteil).toBeCloseTo(0.151, 2); }); it('kostet den Wissenden je Auswahlfrage eine Vorlage im ersten Monat', () => { /* Der Preis, gegen den der Gewinn zu halten ist: eine Vorlage mehr je Frage, über den Katalog rund drei Stunden. Er fällt bei **jeder** Frage an, der Gewinn nur bei den geratenen. */ expect(mehrvorlagen28).toBe(471); expect(mehrminuten28).toBeCloseTo(196, 0); }); });