// @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);
  });
});
