waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app src shared verlaufsvergleich.ts
| 1 | /** |
| 2 | * Einordnung eines Simulationslaufs gegenüber den früheren – reine Funktionen. |
| 3 | * |
| 4 | * Bis Fassung 0.22.0 stand jeder Lauf für sich: Die Auswertung nannte die |
| 5 | * Zahlen des Tages, die Verlaufstabelle listete die alten, und den Vergleich |
| 6 | * musste der Lernende im Kopf anstellen. Genau daran hängt aber die Frage, |
| 7 | * die vor einer Prüfung zählt – geht es aufwärts, und wo hakt es immer wieder? |
| 8 | * |
| 9 | * **Was hier nicht geschieht.** Es wird nichts vorhergesagt. Jede Zahl ist |
| 10 | * gemessene Vergangenheit; die abgelehnte Bestehenswahrscheinlichkeit bleibt |
| 11 | * abgelehnt (siehe `docs/entscheidung-prognose.md`). Und nichts davon wirkt |
| 12 | * auf den Lernstand zurück: Simulationsergebnisse justieren den Reifegrad |
| 13 | * nicht nach (`docs/entscheidung-reifegrad.md`, Abschnitt 8). |
| 14 | * |
| 15 | * **Warum die Vorbehalte mitkommen.** `quote` zählt unbeantwortete Fragen wie |
| 16 | * falsch beantwortete. Ein Lauf, in dem die Zeit ablief, fällt dadurch tief – |
| 17 | * nicht weil das Wissen einbrach, sondern weil der Bogen nicht fertig wurde. |
| 18 | * Ein Vergleichssatz, der das verschweigt, ist schlimmer als keiner. Deshalb |
| 19 | * liefert {@link laufVergleichen} die Einschränkungen als eigene Sätze mit, |
| 20 | * und die Oberfläche gibt sie unverändert aus. |
| 21 | */ |
| 22 | |
| 23 | import type { Pruefungsverlauf, Zeitmodus } from './pruefung'; |
| 24 | |
| 25 | /** |
| 26 | * Ab so vielen Fragen im Bereich ist eine Bereichsquote überhaupt eine |
| 27 | * Auskunft. Bei zwei Fragen liegt man mit einem Fehler bei 50 Prozent – das |
| 28 | * ist Zufall und kein Befund. |
| 29 | */ |
| 30 | const MINDEST_FRAGEN = 3; |
| 31 | |
| 32 | /** |
| 33 | * So viele Läufe fließen in die Suche nach wiederkehrenden Schwächen ein. |
| 34 | * |
| 35 | * Nicht alle: Wer im Frühjahr begann, hat den Anfang längst hinter sich, und |
| 36 | * ein Bereich, der damals wackelte, ist keine „wiederkehrende Schwäche“ mehr. |
| 37 | */ |
| 38 | const BETRACHTETE_LAEUFE = 10; |
| 39 | |
| 40 | /** So oft muss ein Bereich unter der Grenze gelegen haben – zweimal ist das Mindeste. */ |
| 41 | const MINDESTENS_UNTER_GRENZE = 2; |
| 42 | |
| 43 | /** Mehr wären keine Schwerpunkte mehr, sondern eine zweite Themenanalyse. */ |
| 44 | const HOECHSTENS_BEREICHE = 5; |
| 45 | |
| 46 | /** Unterhalb dieser Abweichung ist ein Dauerunterschied Rauschen, kein Befund. */ |
| 47 | const DAUER_SCHWELLE_MS = 30_000; |
| 48 | |
| 49 | const MINUTE_MS = 60_000; |
| 50 | |
| 51 | /** Die Kennzahlen des eben abgeschlossenen Laufs, wie der Vergleich sie braucht. */ |
| 52 | export interface Laufkennzahlen { |
| 53 | /** ID des Prüfungsprofils, z. B. „dsb“. */ |
| 54 | readonly profilId: string; |
| 55 | readonly zeitmodus: Zeitmodus; |
| 56 | readonly gesamt: number; |
| 57 | readonly quote: number; |
| 58 | readonly dauerMs: number; |
| 59 | readonly unbeantwortet: number; |
| 60 | readonly zeitAbgelaufen: boolean; |
| 61 | /** |
| 62 | * Kennung der gespeicherten Verlaufszeile, falls der Lauf gespeichert wurde. |
| 63 | * |
| 64 | * Der Verlauf enthält den eben beendeten Lauf bereits – er wird vor dem |
| 65 | * Laden geschrieben. Ohne diesen Ausschluss vergliche die Anwendung ihn mit |
| 66 | * sich selbst und meldete jedem Prüfling ein makelloses „gleichauf“. |
| 67 | */ |
| 68 | readonly laufId?: number; |
| 69 | } |
| 70 | |
| 71 | export interface Laufvergleich { |
| 72 | /** Der Lauf, mit dem verglichen wurde. */ |
| 73 | readonly frueher: Pruefungsverlauf; |
| 74 | /** Unterschied der Trefferquote in Prozentpunkten; positiv heißt besser. */ |
| 75 | readonly punkte: number; |
| 76 | /** Unterschied der Dauer in Minuten; positiv heißt schneller. `null` bei Gleichstand. */ |
| 77 | readonly minuten: number | null; |
| 78 | /** Der fertige Satz. Die Oberfläche formuliert nichts nach. */ |
| 79 | readonly satz: string; |
| 80 | /** Was am Vergleich nicht stimmt, in fertigen Sätzen. Leer, wenn nichts dagegen spricht. */ |
| 81 | readonly vorbehalte: readonly string[]; |
| 82 | } |
| 83 | |
| 84 | function anzahl(wert: number, eins: string, mehrere: string): string { |
| 85 | return `${String(wert)} ${wert === 1 ? eins : mehrere}`; |
| 86 | } |
| 87 | |
| 88 | /** |
| 89 | * Sucht den jüngsten vergleichbaren Lauf und ordnet den aktuellen ein. |
| 90 | * |
| 91 | * Vergleichbar heißt: **dasselbe Prüfungsprofil und dieselbe Zeitstufe.** Ein |
| 92 | * Bogen ohne Uhr und einer unter Zeitdruck sind zwei verschiedene Aufgaben; |
| 93 | * sie nebeneinanderzustellen hieße, den Nachteilsausgleich als Fortschritt zu |
| 94 | * verbuchen. Läufe ohne vermerkte Zeitstufe (vor Schema-Version 4) scheiden |
| 95 | * damit aus – bei ihnen ist nicht bekannt, was zutrifft. |
| 96 | * |
| 97 | * `datumText` wird hereingereicht, weil das Datumsformat der Oberfläche |
| 98 | * gehört: `Intl` in einer Vertragsdatei wäre eine Abhängigkeit, die hier |
| 99 | * nichts zu suchen hat. |
| 100 | * |
| 101 | * @returns `null`, wenn es keinen vergleichbaren Lauf gibt – dann sagt die |
| 102 | * Anwendung nichts, statt etwas Schiefes zu sagen. |
| 103 | */ |
| 104 | /** |
| 105 | * Wie weit zwei Läufe allein durch die Ziehung auseinanderfallen. |
| 106 | * |
| 107 | * **Warum es diese Zahl braucht.** Ein Bogen zieht 75 bis 100 Fragen aus 575. |
| 108 | * Zwei Läufe messen deshalb nie denselben Bestand, sondern zwei Stichproben |
| 109 | * daraus. Auch wer zwischen den Läufen nichts gelernt und nichts vergessen |
| 110 | * hat, bekommt zwei verschiedene Quoten. Ohne diese Zahl liest sich jeder |
| 111 | * Unterschied als Fortschritt oder Rückschritt – auch der, der keiner ist. |
| 112 | * |
| 113 | * **Die Rechnung.** Zurückgegeben wird die Standardabweichung des |
| 114 | * Unterschieds zweier unabhängiger Bögen, in Prozentpunkten: |
| 115 | * `sqrt(p·(1−p)/n₁ + p·(1−p)/n₂)`, mit `p` als gemeinsamer Quote beider |
| 116 | * Läufe. |
| 117 | * |
| 118 | * **Warum die binomiale Formel hier trägt, obwohl der Reifegrad sie |
| 119 | * ausschlägt.** `docs/entscheidung-reifegrad.md` Abschnitt 5 lehnt eine |
| 120 | * Bestehenswahrscheinlichkeit ab, weil die Trefferchancen je Frage |
| 121 | * zweigipflig verteilt sind – ungesehene bei null, gefestigte bei 0,9. Das |
| 122 | * ist richtig und betrifft eine **Prognose** über künftige Bögen. Hier geht |
| 123 | * es um die bereits gezogene Stichprobe, und dort hebt sich die |
| 124 | * Zweigipfligkeit auf: Eine zufällig gezogene Frage zu beantworten ist selbst |
| 125 | * ein Bernoulli-Versuch mit der mittleren Trefferchance. Am 01.09.2026 mit |
| 126 | * 200 000 Läufen gegen die in der Notiz beschriebene Verteilung nachgerechnet |
| 127 | * – bei 85, 70 und 50 Prozent gefestigter Fragen lag die gemessene Streuung |
| 128 | * jedes Mal **unter** der binomialen. Die Endlichkeitskorrektur |
| 129 | * `(N−n)/(N−1)` bliebe zusätzlich draußen; sie machte die Zahl kleiner. Diese |
| 130 | * Schätzung ist also die vorsichtige Seite, und das ist die richtige Seite. |
| 131 | */ |
| 132 | export function streuungPunkte(gesamtA: number, quoteA: number, gesamtB: number, quoteB: number) { |
| 133 | const p = (quoteA * gesamtA + quoteB * gesamtB) / (gesamtA + gesamtB); |
| 134 | return Math.sqrt((p * (1 - p)) / gesamtA + (p * (1 - p)) / gesamtB) * 100; |
| 135 | } |
| 136 | |
| 137 | export function laufVergleichen( |
| 138 | aktuell: Laufkennzahlen, |
| 139 | verlauf: readonly Pruefungsverlauf[], |
| 140 | datumText: (iso: string) => string, |
| 141 | ): Laufvergleich | null { |
| 142 | const frueher = verlauf.find( |
| 143 | (eintrag) => |
| 144 | eintrag.id !== aktuell.laufId && |
| 145 | eintrag.profilId === aktuell.profilId && |
| 146 | eintrag.zeitmodus === aktuell.zeitmodus, |
| 147 | ); |
| 148 | if (frueher === undefined) { |
| 149 | return null; |
| 150 | } |
| 151 | |
| 152 | const punkte = Math.round((aktuell.quote - frueher.quote) * 100); |
| 153 | const dauerDelta = frueher.dauerMs - aktuell.dauerMs; |
| 154 | /* Aufgerundet auf mindestens eine Minute: Oberhalb der Schwelle etwas als |
| 155 | „0 Minuten schneller“ auszuweisen wäre ein Widerspruch in sich. */ |
| 156 | const minuten = |
| 157 | Math.abs(dauerDelta) < DAUER_SCHWELLE_MS |
| 158 | ? null |
| 159 | : Math.sign(dauerDelta) * Math.max(1, Math.round(Math.abs(dauerDelta) / MINUTE_MS)); |
| 160 | |
| 161 | const quotenteil = |
| 162 | punkte === 0 |
| 163 | ? 'gleichauf in der Trefferquote' |
| 164 | : `${anzahl(Math.abs(punkte), 'Prozentpunkt', 'Prozentpunkte')} ${punkte > 0 ? 'besser' : 'schlechter'}`; |
| 165 | const dauerteil = |
| 166 | minuten === null |
| 167 | ? 'bei praktisch gleicher Bearbeitungsdauer' |
| 168 | : `${anzahl(Math.abs(minuten), 'Minute', 'Minuten')} ${minuten > 0 ? 'schneller' : 'langsamer'}`; |
| 169 | |
| 170 | const vorbehalte: string[] = []; |
| 171 | if (aktuell.unbeantwortet > 0) { |
| 172 | vorbehalte.push( |
| 173 | `Diesmal blieben ${anzahl(aktuell.unbeantwortet, 'Frage', 'Fragen')} unbeantwortet` + |
| 174 | `${aktuell.zeitAbgelaufen ? ', weil die Zeit ablief' : ''}.`, |
| 175 | ); |
| 176 | } |
| 177 | if (frueher.unbeantwortet === null) { |
| 178 | vorbehalte.push( |
| 179 | 'Zu jenem Lauf wurde nicht festgehalten, wie viele Fragen unbeantwortet blieben – ' + |
| 180 | 'er stammt aus einer früheren Programmfassung.', |
| 181 | ); |
| 182 | } else if (frueher.unbeantwortet > 0) { |
| 183 | vorbehalte.push( |
| 184 | `Damals blieben ${anzahl(frueher.unbeantwortet, 'Frage', 'Fragen')} unbeantwortet` + |
| 185 | `${frueher.zeitAbgelaufen === true ? ', weil die Zeit ablief' : ''}.`, |
| 186 | ); |
| 187 | } |
| 188 | /* Der Satz, um den es geht: Ohne ihn liest sich ein abgebrochener Lauf wie |
| 189 | ein Wissenseinbruch. Er steht genau dann da, wenn er zutrifft. */ |
| 190 | if (aktuell.unbeantwortet > 0 || (frueher.unbeantwortet ?? 0) > 0) { |
| 191 | vorbehalte.push( |
| 192 | 'Unbeantwortete Fragen zählen in der Trefferquote wie falsch beantwortete. ' + |
| 193 | 'Ein Lauf, in dem die Zeit ablief, sieht deshalb nach einem Einbruch aus, ' + |
| 194 | 'auch wenn nur der Bogen nicht fertig wurde.', |
| 195 | ); |
| 196 | } |
| 197 | if (frueher.gesamt !== aktuell.gesamt) { |
| 198 | vorbehalte.push( |
| 199 | `Der Bogen hatte damals ${anzahl(frueher.gesamt, 'Frage', 'Fragen')}, dieser ` + |
| 200 | `${anzahl(aktuell.gesamt, 'Frage', 'Fragen')}.`, |
| 201 | ); |
| 202 | } |
| 203 | /* Der Unterschied liegt unter dem, was allein die Ziehung erzeugt. Ohne |
| 204 | diesen Satz behauptet der Vergleich einen Fortschritt, den er nicht |
| 205 | gemessen hat – und der Prüfling richtet sein Lernen danach aus. */ |
| 206 | const streuung = streuungPunkte(aktuell.gesamt, aktuell.quote, frueher.gesamt, frueher.quote); |
| 207 | if (punkte !== 0 && Math.abs(punkte) <= streuung) { |
| 208 | const fragen = Math.round((Math.abs(punkte) / 100) * aktuell.gesamt); |
| 209 | vorbehalte.push( |
| 210 | `Der Unterschied von ${anzahl(Math.abs(punkte), 'Prozentpunkt', 'Prozentpunkten')} ` + |
| 211 | `entspricht ${anzahl(fragen, 'Frage', 'Fragen')}. Zwei Bögen aus demselben Bestand ` + |
| 212 | `fallen schon durch die Ziehung um rund ` + |
| 213 | `${anzahl(Math.round(streuung), 'Prozentpunkt', 'Prozentpunkte')} auseinander, auch ` + |
| 214 | `bei unverändertem Wissensstand – dieser Unterschied liegt darunter und trägt für ` + |
| 215 | `sich genommen keine Aussage.`, |
| 216 | ); |
| 217 | } |
| 218 | |
| 219 | return { |
| 220 | frueher, |
| 221 | punkte, |
| 222 | minuten, |
| 223 | satz: |
| 224 | `Gegenüber Ihrer letzten Simulation mit demselben Profil und derselben Zeitvorgabe ` + |
| 225 | `(${datumText(frueher.zeitpunkt)}): ${quotenteil}, ${dauerteil}.`, |
| 226 | vorbehalte, |
| 227 | }; |
| 228 | } |
| 229 | |
| 230 | /** Ein Bereich, der in mehreren Läufen unter der Bestehensgrenze lag. */ |
| 231 | export interface Schwachstelle { |
| 232 | /** Abschnitts- oder Kapitel-ID, z. B. „I.4“. */ |
| 233 | readonly bereich: string; |
| 234 | readonly titel: string; |
| 235 | /** Läufe, in denen der Bereich mit genügend Fragen vorkam. */ |
| 236 | readonly laeufe: number; |
| 237 | /** Davon die, in denen er unter der Bestehensgrenze lag. */ |
| 238 | readonly unterGrenze: number; |
| 239 | /** Fragen dieses Bereichs über die gezählten Läufe hinweg. */ |
| 240 | readonly gesamt: number; |
| 241 | readonly richtig: number; |
| 242 | } |
| 243 | |
| 244 | export interface Schwaechenbefund { |
| 245 | /** Läufe, die überhaupt Bereichsergebnisse mitbringen – die Grundlage der Aussage. */ |
| 246 | readonly grundlage: number; |
| 247 | readonly bereiche: readonly Schwachstelle[]; |
| 248 | } |
| 249 | |
| 250 | interface Sammler { |
| 251 | titel: string; |
| 252 | laeufe: number; |
| 253 | unterGrenze: number; |
| 254 | gesamt: number; |
| 255 | richtig: number; |
| 256 | } |
| 257 | |
| 258 | /** |
| 259 | * Bereiche, die über mehrere Simulationen hinweg unter der Bestehensgrenze |
| 260 | * lagen. |
| 261 | * |
| 262 | * **Warum an der Bestehensgrenze gemessen wird.** Eine eigene Grenze je |
| 263 | * Bereich gibt es nicht; die Prüfungsordnungen kennen nur eine Gesamtgrenze |
| 264 | * und – bei manchen Trägern – Zusatzbedingungen für einzelne Bereiche. Die |
| 265 | * Gesamtgrenze des jeweiligen Laufs ist deshalb der einzige Maßstab, der |
| 266 | * nicht erfunden ist. Die Oberfläche sagt das dazu. |
| 267 | * |
| 268 | * **Warum zweimal das Mindeste ist.** Einmal daneben ist ein Tag, zweimal ist |
| 269 | * ein Muster. Und Bereiche mit weniger als {@link MINDEST_FRAGEN} Fragen im |
| 270 | * Bogen bleiben ganz außen vor: Bei zwei Fragen entscheidet ein Fehler über |
| 271 | * 50 Prozentpunkte. |
| 272 | * |
| 273 | * Läufe ohne gespeicherte Bereichsergebnisse (vor Schema-Version 10) zählen |
| 274 | * nicht mit – auch nicht als Grundlage. Sie fehlen der Aussage, und `grundlage` |
| 275 | * sagt der Oberfläche, wie viele Läufe wirklich dahinterstehen. |
| 276 | */ |
| 277 | export function wiederkehrendeSchwaechen(verlauf: readonly Pruefungsverlauf[]): Schwaechenbefund { |
| 278 | const brauchbar = verlauf |
| 279 | .filter((eintrag) => eintrag.bereiche !== null && eintrag.bestehensQuote !== null) |
| 280 | .slice(0, BETRACHTETE_LAEUFE); |
| 281 | |
| 282 | const sammler = new Map<string, Sammler>(); |
| 283 | |
| 284 | for (const lauf of brauchbar) { |
| 285 | const grenze = lauf.bestehensQuote ?? 0; |
| 286 | for (const bereich of lauf.bereiche ?? []) { |
| 287 | if (bereich.gesamt < MINDEST_FRAGEN) { |
| 288 | continue; |
| 289 | } |
| 290 | let eintrag = sammler.get(bereich.bereich); |
| 291 | if (eintrag === undefined) { |
| 292 | /* Der Titel des jüngsten Laufs gewinnt: Die Liste ist neueste zuerst, |
| 293 | und benennt der Katalog einen Abschnitt um, ist die neue Fassung |
| 294 | die richtige. */ |
| 295 | eintrag = { titel: bereich.titel, laeufe: 0, unterGrenze: 0, gesamt: 0, richtig: 0 }; |
| 296 | sammler.set(bereich.bereich, eintrag); |
| 297 | } |
| 298 | eintrag.laeufe += 1; |
| 299 | eintrag.gesamt += bereich.gesamt; |
| 300 | eintrag.richtig += bereich.richtig; |
| 301 | if (bereich.richtig / bereich.gesamt < grenze) { |
| 302 | eintrag.unterGrenze += 1; |
| 303 | } |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | const bereiche = [...sammler.entries()] |
| 308 | .filter(([, werte]) => werte.unterGrenze >= MINDESTENS_UNTER_GRENZE) |
| 309 | .map(([bereich, werte]) => ({ bereich, ...werte })) |
| 310 | /* Schwächster zuerst, bei Gleichstand der häufiger auffällige; die |
| 311 | Bereichs-ID bricht den Rest, damit die Reihenfolge feststeht. */ |
| 312 | .sort( |
| 313 | (a, b) => |
| 314 | a.richtig / a.gesamt - b.richtig / b.gesamt || |
| 315 | b.unterGrenze - a.unterGrenze || |
| 316 | a.bereich.localeCompare(b.bereich, 'de'), |
| 317 | ) |
| 318 | .slice(0, HOECHSTENS_BEREICHE); |
| 319 | |
| 320 | return { grundlage: brauchbar.length, bereiche }; |
| 321 | } |
| 322 | |
| 323 | /** |
| 324 | * Die Schwellen, an denen die Oberfläche ihre Beschriftung ausrichtet. |
| 325 | * |
| 326 | * Ausdrücklich nicht doppelt gepflegt: Stünde „mindestens drei Fragen“ als |
| 327 | * Text im Bauteil, liefe es beim nächsten Wert still auseinander. |
| 328 | */ |
| 329 | export const VERGLEICH_GRENZEN = Object.freeze({ |
| 330 | mindestFragen: MINDEST_FRAGEN, |
| 331 | betrachteteLaeufe: BETRACHTETE_LAEUFE, |
| 332 | mindestensUnterGrenze: MINDESTENS_UNTER_GRENZE, |
| 333 | hoechstensBereiche: HOECHSTENS_BEREICHE, |
| 334 | }); |