/** * Einordnung eines Simulationslaufs gegenüber den früheren – reine Funktionen. * * Bis Fassung 0.22.0 stand jeder Lauf für sich: Die Auswertung nannte die * Zahlen des Tages, die Verlaufstabelle listete die alten, und den Vergleich * musste der Lernende im Kopf anstellen. Genau daran hängt aber die Frage, * die vor einer Prüfung zählt – geht es aufwärts, und wo hakt es immer wieder? * * **Was hier nicht geschieht.** Es wird nichts vorhergesagt. Jede Zahl ist * gemessene Vergangenheit; die abgelehnte Bestehenswahrscheinlichkeit bleibt * abgelehnt (siehe `docs/entscheidung-prognose.md`). Und nichts davon wirkt * auf den Lernstand zurück: Simulationsergebnisse justieren den Reifegrad * nicht nach (`docs/entscheidung-reifegrad.md`, Abschnitt 8). * * **Warum die Vorbehalte mitkommen.** `quote` zählt unbeantwortete Fragen wie * falsch beantwortete. Ein Lauf, in dem die Zeit ablief, fällt dadurch tief – * nicht weil das Wissen einbrach, sondern weil der Bogen nicht fertig wurde. * Ein Vergleichssatz, der das verschweigt, ist schlimmer als keiner. Deshalb * liefert {@link laufVergleichen} die Einschränkungen als eigene Sätze mit, * und die Oberfläche gibt sie unverändert aus. */ import type { Pruefungsverlauf, Zeitmodus } from './pruefung'; /** * Ab so vielen Fragen im Bereich ist eine Bereichsquote überhaupt eine * Auskunft. Bei zwei Fragen liegt man mit einem Fehler bei 50 Prozent – das * ist Zufall und kein Befund. */ const MINDEST_FRAGEN = 3; /** * So viele Läufe fließen in die Suche nach wiederkehrenden Schwächen ein. * * Nicht alle: Wer im Frühjahr begann, hat den Anfang längst hinter sich, und * ein Bereich, der damals wackelte, ist keine „wiederkehrende Schwäche“ mehr. */ const BETRACHTETE_LAEUFE = 10; /** So oft muss ein Bereich unter der Grenze gelegen haben – zweimal ist das Mindeste. */ const MINDESTENS_UNTER_GRENZE = 2; /** Mehr wären keine Schwerpunkte mehr, sondern eine zweite Themenanalyse. */ const HOECHSTENS_BEREICHE = 5; /** Unterhalb dieser Abweichung ist ein Dauerunterschied Rauschen, kein Befund. */ const DAUER_SCHWELLE_MS = 30_000; const MINUTE_MS = 60_000; /** Die Kennzahlen des eben abgeschlossenen Laufs, wie der Vergleich sie braucht. */ export interface Laufkennzahlen { /** ID des Prüfungsprofils, z. B. „dsb“. */ readonly profilId: string; readonly zeitmodus: Zeitmodus; readonly gesamt: number; readonly quote: number; readonly dauerMs: number; readonly unbeantwortet: number; readonly zeitAbgelaufen: boolean; /** * Kennung der gespeicherten Verlaufszeile, falls der Lauf gespeichert wurde. * * Der Verlauf enthält den eben beendeten Lauf bereits – er wird vor dem * Laden geschrieben. Ohne diesen Ausschluss vergliche die Anwendung ihn mit * sich selbst und meldete jedem Prüfling ein makelloses „gleichauf“. */ readonly laufId?: number; } export interface Laufvergleich { /** Der Lauf, mit dem verglichen wurde. */ readonly frueher: Pruefungsverlauf; /** Unterschied der Trefferquote in Prozentpunkten; positiv heißt besser. */ readonly punkte: number; /** Unterschied der Dauer in Minuten; positiv heißt schneller. `null` bei Gleichstand. */ readonly minuten: number | null; /** Der fertige Satz. Die Oberfläche formuliert nichts nach. */ readonly satz: string; /** Was am Vergleich nicht stimmt, in fertigen Sätzen. Leer, wenn nichts dagegen spricht. */ readonly vorbehalte: readonly string[]; } function anzahl(wert: number, eins: string, mehrere: string): string { return `${String(wert)} ${wert === 1 ? eins : mehrere}`; } /** * Sucht den jüngsten vergleichbaren Lauf und ordnet den aktuellen ein. * * Vergleichbar heißt: **dasselbe Prüfungsprofil und dieselbe Zeitstufe.** Ein * Bogen ohne Uhr und einer unter Zeitdruck sind zwei verschiedene Aufgaben; * sie nebeneinanderzustellen hieße, den Nachteilsausgleich als Fortschritt zu * verbuchen. Läufe ohne vermerkte Zeitstufe (vor Schema-Version 4) scheiden * damit aus – bei ihnen ist nicht bekannt, was zutrifft. * * `datumText` wird hereingereicht, weil das Datumsformat der Oberfläche * gehört: `Intl` in einer Vertragsdatei wäre eine Abhängigkeit, die hier * nichts zu suchen hat. * * @returns `null`, wenn es keinen vergleichbaren Lauf gibt – dann sagt die * Anwendung nichts, statt etwas Schiefes zu sagen. */ /** * Wie weit zwei Läufe allein durch die Ziehung auseinanderfallen. * * **Warum es diese Zahl braucht.** Ein Bogen zieht 75 bis 100 Fragen aus 575. * Zwei Läufe messen deshalb nie denselben Bestand, sondern zwei Stichproben * daraus. Auch wer zwischen den Läufen nichts gelernt und nichts vergessen * hat, bekommt zwei verschiedene Quoten. Ohne diese Zahl liest sich jeder * Unterschied als Fortschritt oder Rückschritt – auch der, der keiner ist. * * **Die Rechnung.** Zurückgegeben wird die Standardabweichung des * Unterschieds zweier unabhängiger Bögen, in Prozentpunkten: * `sqrt(p·(1−p)/n₁ + p·(1−p)/n₂)`, mit `p` als gemeinsamer Quote beider * Läufe. * * **Warum die binomiale Formel hier trägt, obwohl der Reifegrad sie * ausschlägt.** `docs/entscheidung-reifegrad.md` Abschnitt 5 lehnt eine * Bestehenswahrscheinlichkeit ab, weil die Trefferchancen je Frage * zweigipflig verteilt sind – ungesehene bei null, gefestigte bei 0,9. Das * ist richtig und betrifft eine **Prognose** über künftige Bögen. Hier geht * es um die bereits gezogene Stichprobe, und dort hebt sich die * Zweigipfligkeit auf: Eine zufällig gezogene Frage zu beantworten ist selbst * ein Bernoulli-Versuch mit der mittleren Trefferchance. Am 01.09.2026 mit * 200 000 Läufen gegen die in der Notiz beschriebene Verteilung nachgerechnet * – bei 85, 70 und 50 Prozent gefestigter Fragen lag die gemessene Streuung * jedes Mal **unter** der binomialen. Die Endlichkeitskorrektur * `(N−n)/(N−1)` bliebe zusätzlich draußen; sie machte die Zahl kleiner. Diese * Schätzung ist also die vorsichtige Seite, und das ist die richtige Seite. */ export function streuungPunkte(gesamtA: number, quoteA: number, gesamtB: number, quoteB: number) { const p = (quoteA * gesamtA + quoteB * gesamtB) / (gesamtA + gesamtB); return Math.sqrt((p * (1 - p)) / gesamtA + (p * (1 - p)) / gesamtB) * 100; } export function laufVergleichen( aktuell: Laufkennzahlen, verlauf: readonly Pruefungsverlauf[], datumText: (iso: string) => string, ): Laufvergleich | null { const frueher = verlauf.find( (eintrag) => eintrag.id !== aktuell.laufId && eintrag.profilId === aktuell.profilId && eintrag.zeitmodus === aktuell.zeitmodus, ); if (frueher === undefined) { return null; } const punkte = Math.round((aktuell.quote - frueher.quote) * 100); const dauerDelta = frueher.dauerMs - aktuell.dauerMs; /* Aufgerundet auf mindestens eine Minute: Oberhalb der Schwelle etwas als „0 Minuten schneller“ auszuweisen wäre ein Widerspruch in sich. */ const minuten = Math.abs(dauerDelta) < DAUER_SCHWELLE_MS ? null : Math.sign(dauerDelta) * Math.max(1, Math.round(Math.abs(dauerDelta) / MINUTE_MS)); const quotenteil = punkte === 0 ? 'gleichauf in der Trefferquote' : `${anzahl(Math.abs(punkte), 'Prozentpunkt', 'Prozentpunkte')} ${punkte > 0 ? 'besser' : 'schlechter'}`; const dauerteil = minuten === null ? 'bei praktisch gleicher Bearbeitungsdauer' : `${anzahl(Math.abs(minuten), 'Minute', 'Minuten')} ${minuten > 0 ? 'schneller' : 'langsamer'}`; const vorbehalte: string[] = []; if (aktuell.unbeantwortet > 0) { vorbehalte.push( `Diesmal blieben ${anzahl(aktuell.unbeantwortet, 'Frage', 'Fragen')} unbeantwortet` + `${aktuell.zeitAbgelaufen ? ', weil die Zeit ablief' : ''}.`, ); } if (frueher.unbeantwortet === null) { vorbehalte.push( 'Zu jenem Lauf wurde nicht festgehalten, wie viele Fragen unbeantwortet blieben – ' + 'er stammt aus einer früheren Programmfassung.', ); } else if (frueher.unbeantwortet > 0) { vorbehalte.push( `Damals blieben ${anzahl(frueher.unbeantwortet, 'Frage', 'Fragen')} unbeantwortet` + `${frueher.zeitAbgelaufen === true ? ', weil die Zeit ablief' : ''}.`, ); } /* Der Satz, um den es geht: Ohne ihn liest sich ein abgebrochener Lauf wie ein Wissenseinbruch. Er steht genau dann da, wenn er zutrifft. */ if (aktuell.unbeantwortet > 0 || (frueher.unbeantwortet ?? 0) > 0) { vorbehalte.push( 'Unbeantwortete Fragen zählen in der Trefferquote wie falsch beantwortete. ' + 'Ein Lauf, in dem die Zeit ablief, sieht deshalb nach einem Einbruch aus, ' + 'auch wenn nur der Bogen nicht fertig wurde.', ); } if (frueher.gesamt !== aktuell.gesamt) { vorbehalte.push( `Der Bogen hatte damals ${anzahl(frueher.gesamt, 'Frage', 'Fragen')}, dieser ` + `${anzahl(aktuell.gesamt, 'Frage', 'Fragen')}.`, ); } /* Der Unterschied liegt unter dem, was allein die Ziehung erzeugt. Ohne diesen Satz behauptet der Vergleich einen Fortschritt, den er nicht gemessen hat – und der Prüfling richtet sein Lernen danach aus. */ const streuung = streuungPunkte(aktuell.gesamt, aktuell.quote, frueher.gesamt, frueher.quote); if (punkte !== 0 && Math.abs(punkte) <= streuung) { const fragen = Math.round((Math.abs(punkte) / 100) * aktuell.gesamt); vorbehalte.push( `Der Unterschied von ${anzahl(Math.abs(punkte), 'Prozentpunkt', 'Prozentpunkten')} ` + `entspricht ${anzahl(fragen, 'Frage', 'Fragen')}. Zwei Bögen aus demselben Bestand ` + `fallen schon durch die Ziehung um rund ` + `${anzahl(Math.round(streuung), 'Prozentpunkt', 'Prozentpunkte')} auseinander, auch ` + `bei unverändertem Wissensstand – dieser Unterschied liegt darunter und trägt für ` + `sich genommen keine Aussage.`, ); } return { frueher, punkte, minuten, satz: `Gegenüber Ihrer letzten Simulation mit demselben Profil und derselben Zeitvorgabe ` + `(${datumText(frueher.zeitpunkt)}): ${quotenteil}, ${dauerteil}.`, vorbehalte, }; } /** Ein Bereich, der in mehreren Läufen unter der Bestehensgrenze lag. */ export interface Schwachstelle { /** Abschnitts- oder Kapitel-ID, z. B. „I.4“. */ readonly bereich: string; readonly titel: string; /** Läufe, in denen der Bereich mit genügend Fragen vorkam. */ readonly laeufe: number; /** Davon die, in denen er unter der Bestehensgrenze lag. */ readonly unterGrenze: number; /** Fragen dieses Bereichs über die gezählten Läufe hinweg. */ readonly gesamt: number; readonly richtig: number; } export interface Schwaechenbefund { /** Läufe, die überhaupt Bereichsergebnisse mitbringen – die Grundlage der Aussage. */ readonly grundlage: number; readonly bereiche: readonly Schwachstelle[]; } interface Sammler { titel: string; laeufe: number; unterGrenze: number; gesamt: number; richtig: number; } /** * Bereiche, die über mehrere Simulationen hinweg unter der Bestehensgrenze * lagen. * * **Warum an der Bestehensgrenze gemessen wird.** Eine eigene Grenze je * Bereich gibt es nicht; die Prüfungsordnungen kennen nur eine Gesamtgrenze * und – bei manchen Trägern – Zusatzbedingungen für einzelne Bereiche. Die * Gesamtgrenze des jeweiligen Laufs ist deshalb der einzige Maßstab, der * nicht erfunden ist. Die Oberfläche sagt das dazu. * * **Warum zweimal das Mindeste ist.** Einmal daneben ist ein Tag, zweimal ist * ein Muster. Und Bereiche mit weniger als {@link MINDEST_FRAGEN} Fragen im * Bogen bleiben ganz außen vor: Bei zwei Fragen entscheidet ein Fehler über * 50 Prozentpunkte. * * Läufe ohne gespeicherte Bereichsergebnisse (vor Schema-Version 10) zählen * nicht mit – auch nicht als Grundlage. Sie fehlen der Aussage, und `grundlage` * sagt der Oberfläche, wie viele Läufe wirklich dahinterstehen. */ export function wiederkehrendeSchwaechen(verlauf: readonly Pruefungsverlauf[]): Schwaechenbefund { const brauchbar = verlauf .filter((eintrag) => eintrag.bereiche !== null && eintrag.bestehensQuote !== null) .slice(0, BETRACHTETE_LAEUFE); const sammler = new Map(); for (const lauf of brauchbar) { const grenze = lauf.bestehensQuote ?? 0; for (const bereich of lauf.bereiche ?? []) { if (bereich.gesamt < MINDEST_FRAGEN) { continue; } let eintrag = sammler.get(bereich.bereich); if (eintrag === undefined) { /* Der Titel des jüngsten Laufs gewinnt: Die Liste ist neueste zuerst, und benennt der Katalog einen Abschnitt um, ist die neue Fassung die richtige. */ eintrag = { titel: bereich.titel, laeufe: 0, unterGrenze: 0, gesamt: 0, richtig: 0 }; sammler.set(bereich.bereich, eintrag); } eintrag.laeufe += 1; eintrag.gesamt += bereich.gesamt; eintrag.richtig += bereich.richtig; if (bereich.richtig / bereich.gesamt < grenze) { eintrag.unterGrenze += 1; } } } const bereiche = [...sammler.entries()] .filter(([, werte]) => werte.unterGrenze >= MINDESTENS_UNTER_GRENZE) .map(([bereich, werte]) => ({ bereich, ...werte })) /* Schwächster zuerst, bei Gleichstand der häufiger auffällige; die Bereichs-ID bricht den Rest, damit die Reihenfolge feststeht. */ .sort( (a, b) => a.richtig / a.gesamt - b.richtig / b.gesamt || b.unterGrenze - a.unterGrenze || a.bereich.localeCompare(b.bereich, 'de'), ) .slice(0, HOECHSTENS_BEREICHE); return { grundlage: brauchbar.length, bereiche }; } /** * Die Schwellen, an denen die Oberfläche ihre Beschriftung ausrichtet. * * Ausdrücklich nicht doppelt gepflegt: Stünde „mindestens drei Fragen“ als * Text im Bauteil, liefe es beim nächsten Wert still auseinander. */ export const VERGLEICH_GRENZEN = Object.freeze({ mindestFragen: MINDEST_FRAGEN, betrachteteLaeufe: BETRACHTETE_LAEUFE, mindestensUnterGrenze: MINDESTENS_UNTER_GRENZE, hoechstensBereiche: HOECHSTENS_BEREICHE, });