waffensachkunde

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

/ app src shared reife.ts

12,7 KB Rohdatei
app/src/shared/reife.ts — 311 Zeilen
1 /**
2 * Reifegrad und Prüfungsreife-Ampel.
3 *
4 * ## Was diese Datei beantwortet
5 *
6 * „Wie weit bin ich?“ – und zwar so, dass die Antwort einer Nachprüfung
7 * standhält. Bis Fassung 0.11.0 hieß die Kennzahl `sicher` und bedeutete
8 * *die letzte Antwort war richtig*. Das ist eine Ein-Antwort-Metrik und damit
9 * flacher als die, die `PLAN.md` bei einem Mitbewerber kritisiert.
10 *
11 * ## Warum nicht einfach die FSRS-Abrufwahrscheinlichkeit
12 *
13 * Der naheliegende Ersatz wäre gewesen: für jede Frage `R` aus dem
14 * Gedächtnismodell nehmen und mitteln. Nachgemessen an den Konstanten dieses
15 * Projekts ergibt das eine Zahl, die **unehrlicher** ist als die abgelöste:
16 *
17 * | Katalog einmal durch, je Frage eine Antwort | Mittleres `R` | von 486 |
18 * |---|---|---|
19 * | 0 % richtig | 0,766 | 372 |
20 * | 20 % richtig | 0,802 | 390 |
21 * | 50 % richtig | 0,857 | 416 |
22 *
23 * Wer **jede** Frage genau einmal **falsch** beantwortet, stünde bei 372 von
24 * 486. Der Grund ist kein Rechenfehler, sondern eine Verwechslung: `R` ist
25 * eine **Planungsgröße**, keine Könnensmessung. Der Stabilitätswert nach der
26 * ersten Antwort ist ein Vorgabewert aus der FSRS-Eichung – eine Annahme,
27 * keine Messung. Und dieses Programm füttert das Modell mit einem Klick auf
28 * eine von mehreren Antwortmöglichkeiten, den `bewertungAusErgebnis`
29 * ausnahmslos als „gut“ wertet: Geraten und Gewusst sind für das Modell
30 * dasselbe.
31 *
32 * Schlimmer noch: Die Zahl **stieg beim Falschantworten**. Eine überfällige
33 * Frage mit `S = 2,31` und 20 Tagen Abstand steht bei `R = 0,707`; nach einem
34 * „Nicht gewusst“ steht sie bei `R = 0,884`, weil der neue Stand wieder am
35 * ersten Tag gemessen wird. In allen fünf nachgerechneten Fällen stieg sie.
36 *
37 * ## Die Regel: erst der belegte Abruf zählt
38 *
39 * Eine Frage geht erst in den Reifegrad ein, wenn sie **nach mindestens einem
40 * Tag Abstand richtig beantwortet** wurde. Vorher zählt sie null.
41 *
42 * Diese Grenze ist nicht erfunden. `fsrs.ts` zieht sie bereits selbst: Unter
43 * einem Tag Abstand greift dort die Kurzfristformel, weil „die
44 * Vergessenskurve kein brauchbarer Maßstab“ ist. Was innerhalb eines Tages
45 * geschieht, ist Wiedererkennen; Erinnern zeigt sich erst über Nacht.
46 *
47 * Der Beleg ist eine **Aussage über das Jetzt**, kein Orden für früher: Eine
48 * falsche Antwort nimmt ihn wieder weg, gleich wie lange die Frage vorher
49 * saß. Daraus folgt die Eigenschaft, an der der erste Entwurf gescheitert
50 * war – **die Zahl kann durch eine falsche Antwort nie steigen.**
51 *
52 * ## Die Zahl ist eine Untergrenze, keine Prognose
53 *
54 * Sie sagt, was belegt sitzt, nicht was in der Prüfung herauskäme. Raten ist
55 * nicht eingerechnet, Ungesehenes gilt als nicht gekonnt, und ein Beleg
56 * verfällt mit der Zeit.
57 *
58 * Die Belegregel heilt allerdings nur die **Anzeige**, nicht die
59 * Terminierung: Für die Wiedervorlage zählt die erratene Antwort weiter als
60 * „gut“. Was das kostet und warum dort nichts geändert wird, ist in
61 * `docs/entscheidung-ratewahrscheinlichkeit.md` nachgemessen – kurz: Ein
62 * Rater mit Trefferchance 1/3 bekommt die Frage an 53 von 61 Tagen wieder
63 * vorgelegt, und der Beleg steht dabei nur an 40 Prozent der Tage. Damit setzt sie dieselbe Haltung fort, die
64 * `lernplan.ts` im Modulkopf festhält: eher zu niedrig als zu hoch. Wer diese
65 * Grenze überschreitet, hat Luft – wie viel, sagt das Programm nicht, weil es
66 * das nicht weiß.
67 *
68 * Die Begründung der Schwellen und die Messwerte stehen in
69 * `docs/entscheidung-reifegrad.md`.
70 */
71
72 import { abrufwahrscheinlichkeit } from './fsrs';
73
74 /**
75 * Kleinster Abstand, mit dem gerechnet wird.
76 *
77 * `abrufwahrscheinlichkeit(S, 0)` ist für **jedes** `S` exakt 1 – ohne diese
78 * Untergrenze stünde eine gerade eben beantwortete Frage bei 100 Prozent.
79 * `tageZwischen` im Anwendungskern liefert Bruchteile von Tagen, der Fall
80 * tritt also bei jeder Sitzung ein.
81 */
82 export const MINDESTABSTAND_TAGE = 1;
83
84 /**
85 * Strengste Bestehensgrenze, die dieses Programm kennt.
86 *
87 * Aus `pruefung.ts`: Das Profil „Standard“ verlangt 80 Prozent, „nach Art
88 * privater Lehrgangsträger“ höchstens 15 Fehler auf 75 Fragen – also
89 * ebenfalls 60 von 75, das sind 80 Prozent. Die übrigen festen Profile liegen
90 * darunter (75 und 70 Prozent). Das anpassbare Profil bleibt außen vor: Was
91 * der Nutzer selbst einstellen kann, taugt nicht als Maßstab.
92 */
93 export const SCHWELLE_KIPPE = 0.8;
94
95 /**
96 * Ab hier gilt der Stand als prüfungsreif.
97 *
98 * Fünf Punkte über der strengsten Bestehensgrenze. Der Abstand ist kein
99 * Sicherheitszuschlag aus dem Bauch, sondern deckt das, was zwischen einem
100 * Katalog und einem Bogen liegt: Eine Prüfung zieht 75 bis 100 Fragen aus 575
101 * und trifft dabei nicht den Durchschnitt. Wer genau auf der Grenze steht,
102 * besteht bei günstiger Ziehung und fällt bei ungünstiger durch.
103 *
104 * Bewusst **keine** Wahrscheinlichkeitsangabe daneben. Eine Zahl wie „neun von
105 * zehn Läufen“ setzte voraus, dass alle Fragen dieselbe Trefferchance haben –
106 * das Gegenteil ist der Fall, die Verteilung ist zweigipflig. Die Rechnung
107 * wäre exakt aussehend und falsch.
108 */
109 export const SCHWELLE_REIF = 0.85;
110
111 /** Stand einer einzelnen Frage, wie ihn die Reiferechnung braucht. */
112 export interface ReifeFrage {
113 /** Abschnitt, sonst Kapitel – die Ebene der Bereichsaufschlüsselung. */
114 readonly bereich: string;
115 /** FSRS-Stabilität in Tagen; `null`, solange nie beantwortet. */
116 readonly stabilitaet: number | null;
117 /** Tage seit der letzten Antwort, als Bruchzahl. */
118 readonly tageSeitAntwort: number;
119 /** Ob der Abruf belegt ist – siehe Modulkopf. */
120 readonly bestaetigt: boolean;
121 }
122
123 /**
124 * Beitrag einer einzelnen Frage, `zusatzTage` in der Zukunft.
125 *
126 * @returns Wert zwischen 0 und 1. Ohne Beleg immer 0.
127 */
128 export function abrufFuer(frage: ReifeFrage, zusatzTage = 0): number {
129 if (!frage.bestaetigt || frage.stabilitaet === null) {
130 return 0;
131 }
132 const tage = Math.max(MINDESTABSTAND_TAGE, frage.tageSeitAntwort + zusatzTage);
133 return abrufwahrscheinlichkeit(frage.stabilitaet, tage);
134 }
135
136 /**
137 * Reifegrad über eine Fragenmenge: der Mittelwert der Einzelbeiträge.
138 *
139 * Ungesehene und unbelegte Fragen bleiben **im Nenner**. Sie herauszunehmen
140 * ergäbe eine Zahl, die bei der ersten belegten Frage auf 100 Prozent
141 * springt – die häufigste Art, eine Fortschrittsanzeige zu belügen.
142 *
143 * @param zusatzTage Blick in die Zukunft, für die Prognose zum Prüfungstermin.
144 */
145 export function reifegradVon(fragen: readonly ReifeFrage[], zusatzTage = 0): number {
146 if (fragen.length === 0) {
147 return 0;
148 }
149 let summe = 0;
150 for (const frage of fragen) {
151 summe += abrufFuer(frage, zusatzTage);
152 }
153 return summe / fragen.length;
154 }
155
156 /**
157 * Die Zahl, die in der Oberfläche steht.
158 *
159 * Gerundet, nicht abgeschnitten: Abschneiden wäre um bis zu eine ganze Frage
160 * zu pessimistisch, und die Zahl ist bereits eine Untergrenze – ein zweiter
161 * Abschlag darauf wäre keine Vorsicht mehr, sondern eine zweite Verzerrung.
162 */
163 export function belegteFragen(reifegrad: number, fragenGesamt: number): number {
164 return Math.round(reifegrad * fragenGesamt);
165 }
166
167 /**
168 * Wie viele Fragen eine Schwelle bei dieser Menge verlangt.
169 *
170 * Aufgerundet: 0,85 von 486 sind 413,1 Fragen, und 413 erreichen die Quote
171 * nicht. Dieselbe Rechnung wie `benoetigteTreffer` in `pruefung.ts`, aus
172 * demselben Grund.
173 */
174 export function benoetigteFragen(schwelle: number, fragenGesamt: number): number {
175 return Math.ceil(schwelle * fragenGesamt);
176 }
177
178 /**
179 * Die Stufen der Ampel.
180 *
181 * `ohne_beleg` ist keine vierte Farbe, sondern der ehrliche Sonderfall: Wer
182 * noch keine Frage ein zweites Mal wiedergesehen hat, bekommt keine Einstufung
183 * vorgegaukelt, sondern die Auskunft, woran das liegt.
184 */
185 export type Reifestufe = 'ohne_beleg' | 'zurueck' | 'kippe' | 'reif';
186
187 /** Das Wort, das in der Oberfläche steht. */
188 export const STUFE_WORT: Readonly<Record<Reifestufe, string>> = Object.freeze({
189 ohne_beleg: 'Noch kein belegter Stand',
190 zurueck: 'Noch nicht so weit',
191 kippe: 'Auf der Kippe',
192 reif: 'Prüfungsreif',
193 });
194
195 /**
196 * Einstufung – **an der angezeigten Zahl, nicht am Bruchwert**.
197 *
198 * Der Unterschied ist keine Feinheit. Entschiede die Stufe am ungerundeten
199 * Reifegrad und nennte der Satz die gerundete Zahl, gäbe es ein Band, in dem
200 * „Prüfungsreif“ neben einer Zahl steht, die eine Zeile tiefer als noch nicht
201 * ausreichend ausgewiesen ist. So gilt: Die Stufe ist genau dann erreicht,
202 * wenn die genannte Zahl die genannte Zielzahl erreicht.
203 */
204 export function stufeFuer(belegt: number, fragenGesamt: number): Reifestufe {
205 if (fragenGesamt === 0 || belegt === 0) {
206 return 'ohne_beleg';
207 }
208 if (belegt >= benoetigteFragen(SCHWELLE_REIF, fragenGesamt)) {
209 return 'reif';
210 }
211 if (belegt >= benoetigteFragen(SCHWELLE_KIPPE, fragenGesamt)) {
212 return 'kippe';
213 }
214 return 'zurueck';
215 }
216
217 /**
218 * Die Gesamtstufe, gedeckelt durch einen zurückliegenden K.-o.-Bereich.
219 *
220 * Manche Prüfungsordnungen lassen in Notwehr und Notstand höchstens zwei
221 * Fehler zu, gleich wie gut der Rest ist (`pruefung.ts`, `koKriterien`). Wer
222 * insgesamt bei 88 Prozent steht und dort bei 60, fällt sicher durch. Eine
223 * Ampel namens „Prüfungsreife“, die das verschweigt, wäre gefährlicher als
224 * gar keine – deshalb deckelt der schwächste K.-o.-Bereich das Gesamturteil.
225 *
226 * Gedeckelt wird auf `kippe`, nicht auf `zurueck`: Der übrige Stand ist ja
227 * vorhanden, es fehlt eine benannte Stelle. Wer ohnehin schon zurückliegt,
228 * bleibt dort.
229 */
230 export function gesamtstufeMitDeckel(
231 gesamt: Reifestufe,
232 kokriterien: readonly Reifestufe[],
233 ): Reifestufe {
234 if (gesamt !== 'reif') {
235 return gesamt;
236 }
237 return kokriterien.every((stufe) => stufe === 'reif') ? 'reif' : 'kippe';
238 }
239
240 /**
241 * Der Kernsatz zu einem Stand – **einmal formuliert, überall derselbe**.
242 *
243 * Steht hier und nicht in der Komponente, weil ihn drei Stellen brauchen:
244 * der Startbildschirm, der PDF-Lernbericht und das Handbuch. Drei
245 * Formulierungen desselben Sachverhalts driften auseinander, sobald eine
246 * Schwelle sich ändert – und dann behauptet der ausgedruckte Bericht etwas
247 * anderes als der Bildschirm, von dem er stammt.
248 *
249 * Die Zahl steht **vorn**, das Urteil dahinter: „413 von 486 Fragen sitzen
250 * belegt“ ist überprüfbar, „prüfungsreif“ ist eine Auslegung davon. Wer nur
251 * den Anfang liest, hat die Tatsache; wer weiterliest, bekommt die Einordnung.
252 */
253 export function reifesatz(
254 belegt: number,
255 fragenGesamt: number,
256 stufe: Reifestufe,
257 deckelnd: readonly string[] = [],
258 ): string {
259 if (stufe === 'ohne_beleg') {
260 return (
261 'Noch keine Frage ist belegt: Dafür muss eine Frage nach mindestens einem Tag Abstand ' +
262 'noch einmal richtig beantwortet werden. Beim ersten Mal zählt sie nicht mit – ' +
263 'Wiedererkennen ist kein Erinnern.'
264 );
265 }
266
267 const kern = `${String(belegt)} von ${String(fragenGesamt)} Fragen sitzen belegt.`;
268
269 /*
270 Die Einordnung des Gesamtstands – **ohne** den Deckel eines
271 K.-o.-Bereichs. Genau darauf kam es an: `stufe` ist bereits gedeckelt,
272 und aus ihr allein liesse sich nicht ablesen, ob der Gesamtstand für
273 sich genommen reicht.
274 */
275 const ohneDeckel = stufeFuer(belegt, fragenGesamt);
276 const einordnung =
277 ohneDeckel === 'reif'
278 ? 'Das liegt über der strengsten Bestehensgrenze dieses Programms.'
279 : ohneDeckel === 'kippe'
280 ? `Das ist genau die strengste Bestehensgrenze dieses Programms – ohne jeden ` +
281 `Abstand. Für „${STUFE_WORT.reif}“ wären ` +
282 `${String(benoetigteFragen(SCHWELLE_REIF, fragenGesamt))} nötig.`
283 : `Die strengste Bestehensgrenze dieses Programms verlangt ` +
284 `${String(benoetigteFragen(SCHWELLE_KIPPE, fragenGesamt))}.`;
285
286 if (deckelnd.length > 0) {
287 const bereiche = deckelnd.join(' und ');
288 const nachsatz =
289 'Manche Prüfungsstellen lassen dort nur zwei Fehler zu, gleich wie gut der Rest ist.';
290
291 /*
292 „Insgesamt reicht das“ nur, wenn es das auch tut.
293
294 Bis Fassung 0.19.1 stand dieser Satz unbedingt, sobald ein K.-o.-Bereich
295 zurücklag – gemeldet mit „2 von 486 Fragen sitzen belegt. Insgesamt
296 reicht das – aber I.5 liegt zurück.“ Bei 2 von 486 reicht überhaupt
297 nichts. Der Deckel ist nur dann die Nachricht, wenn der Gesamtstand
298 allein genügen würde; sonst ist er ein Zusatz zu einer Einordnung, die
299 ohnehin nicht trägt.
300
301 Der Grund steht in beiden Fällen vor der Ampelfarbe, nicht dahinter:
302 Wer „prüfungsreif“ läse und den Nachsatz überginge, ginge mit einer
303 Lücke in die Prüfung, die ihn unabhängig vom Rest durchfallen lässt.
304 */
305 return ohneDeckel === 'reif'
306 ? `${kern} Insgesamt reicht das – aber ${bereiche} liegt zurück. ${nachsatz}`
307 : `${kern} ${einordnung} Dazu liegt ${bereiche} zurück. ${nachsatz}`;
308 }
309
310 return `${kern} ${einordnung}`;
311 }