waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 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 | } |