waffensachkunde

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

/ app src shared verlaufsvergleich.ts

13,9 KB Rohdatei
app/src/shared/verlaufsvergleich.ts — 334 Zeilen
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 });