waffensachkunde

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

/ app src shared lernplan.ts

16,5 KB Rohdatei
app/src/shared/lernplan.ts — 412 Zeilen
1 /**
2 * Lernplan – FSRS auf einen Prüfungstermin hin.
3 *
4 * FSRS (siehe `fsrs.ts`) plant für dauerhaftes Behalten: Es hält die
5 * Abrufwahrscheinlichkeit jeder Frage bei rund 90 % und dehnt die Abstände
6 * dabei immer weiter. Für eine Prüfung an einem festen Tag ist das nicht ganz
7 * das Richtige. Hier zählt ein anderes Ziel: **an genau diesem einen Tag soll
8 * möglichst viel abrufbar sein.**
9 *
10 * Dieses Modul ist die Anpassung daran. Es steht bewusst getrennt von
11 * `fsrs.ts`, damit erkennbar bleibt, was belegtes Referenzverfahren ist und
12 * was eigene Entscheidung. Alles hier ist eigene Entscheidung.
13 *
14 * ## Die beiden Regeln
15 *
16 * **1. Die Zielquote steigt, je näher der Termin rückt.**
17 * Weit vor der Prüfung genügen 90 % – wer zu früh zu oft wiederholt,
18 * verschwendet Zeit, die für neue Fragen fehlt. In den letzten Wochen wird
19 * die Zielquote schrittweise auf 97 % angehoben. Dadurch rücken die Termine
20 * von selbst zusammen und der Stoff ist am Prüfungstag frisch.
21 *
22 * **2. Es wird nicht auf den Termin gedeckelt.**
23 * Nachgerechnet und bewusst so gelassen – siehe
24 * `docs/entscheidung-lernphasen.md`. Jede geprüfte Kappung schiebt alles, was
25 * über den Termin hinausginge, auf den Prüfungstag selbst; gemessen 432 von
26 * 486 Fragen an einem Tag.
27 * Naheliegend wäre, jede Frage vor der Prüfung noch einmal vorzulegen. Das
28 * wäre aber verschwendete Zeit: Eine Frage mit 60 Tagen Stabilität sitzt zehn
29 * Tage vor der Prüfung noch zu 97,7 % – sie braucht keine Wiederholung. Genau
30 * diese Fragen filtert Regel 1 bereits heraus, ohne dass eine zweite Regel
31 * nötig ist. Wer wirklich alles noch einmal sehen will, hat dafür die
32 * Prüfungssimulation.
33 *
34 * ## Ehrlichkeit der Prognose
35 *
36 * Die Prognose ist der Mittelwert der Abrufwahrscheinlichkeiten über den
37 * **gesamten** Katalog. Eine nie beantwortete Frage geht mit 0 ein. Das ist
38 * bewusst streng: Geraten wird nicht eingerechnet, und ungesehene Fragen
39 * gelten als nicht gekonnt. Die Zahl ist damit eher zu niedrig als zu hoch –
40 * in dieser Richtung schadet ein Fehler niemandem.
41 */
42
43 import { intervallTage, naechsterStand, type FsrsGrad, type Gedaechtnisstand } from './fsrs';
44 import { reifegradVon, type ReifeFrage } from './reife';
45
46 /** Zielquote ohne Prüfungstermin – der Vorgabewert von FSRS. */
47 export const ZIELQUOTE_GRUND = 0.9;
48
49 /** Zielquote am Prüfungstag. */
50 export const ZIELQUOTE_TERMIN = 0.97;
51
52 /**
53 * Ab dieser Zahl von Tagen vor dem Termin greift die Anhebung.
54 *
55 * Vier Wochen: kurz genug, dass die zusätzlichen Wiederholungen nicht die
56 * ganze Vorbereitungszeit auffressen, lang genug, dass die Anhebung sanft
57 * verläuft und nicht als plötzlicher Sprung erlebt wird.
58 */
59 export const ANHEBUNG_AB_TAGEN = 28;
60
61 /**
62 * Umfang einer Lernsitzung – zugleich der Tagesvorschlag ohne Prüfungstermin.
63 *
64 * Steht hier und nicht in der Oberfläche, damit der vorgeschlagene Tagesplan
65 * und die Schaltfläche „Weiterlernen“ nicht auseinanderlaufen können.
66 */
67 export const SITZUNGSUMFANG = 20;
68
69 /** Angenommene Bearbeitungsdauer, solange keine eigene Historie vorliegt. */
70 export const SEKUNDEN_PRO_FRAGE_VORGABE = 25;
71
72 /**
73 * Wie viele Tage die Einführung neuer Fragen nach vorn gezogen wird.
74 *
75 * Eine Frage, die zum ersten Mal am Vorabend auftaucht, ist am Prüfungstag
76 * nicht gefestigt. Der Puffer zieht die Einführung deshalb nach vorn, damit
77 * möglichst jede Frage wenigstens eine Wiederholung erlebt. Er wächst nicht
78 * über drei Tage hinaus – bei kurzer Vorbereitungszeit wäre das sonst der
79 * größte Teil davon.
80 *
81 * **Er stoppt die Einführung nicht.** Der Kommentar behauptete das bis
82 * Fassung 0.16.0 („Tage, an denen keine neuen Fragen mehr eingeführt
83 * werden“), und der Code leistete es nie: Der Puffer ist ein kleinerer
84 * Divisor, ab vier Tagen Rest ist er null, und die Rate wird täglich neu
85 * gebildet. Wer zurückliegt, bekommt bis zum Vortag neue Fragen angetragen –
86 * und dazu von {@link machbarkeitFuer} die Auskunft, dass das nicht mehr zu
87 * schaffen ist. Am Prüfungstag selbst kommen keine mehr dazu, weil ein Beleg
88 * an diesem Tag nicht mehr entstehen kann.
89 */
90 export const PUFFER_TAGE_MAX = 3;
91
92 /** Einschätzung, ob das Pensum in der verbleibenden Zeit zu schaffen ist. */
93 export type Machbarkeit =
94 'kein_termin' | 'termin_vorbei' | 'entspannt' | 'machbar' | 'knapp' | 'zu_wenig_zeit';
95
96 /** Empfohlenes Tagespensum. */
97 export interface Tagespensum {
98 /** Neue, noch nie beantwortete Fragen. */
99 readonly neu: number;
100 /** Heute zur Wiederholung fällige Fragen. */
101 readonly wiederholung: number;
102 /** Summe aus beidem. */
103 readonly gesamt: number;
104 /** Geschätzte Dauer in Minuten, auf Basis des eigenen bisherigen Tempos. */
105 readonly minuten: number;
106 }
107
108 /** Der vollständige Lernplan, wie ihn die Oberfläche anzeigt. */
109 export interface Lernplan {
110 /** Prüfungstermin als ISO-Datum, oder `null`, wenn keiner gesetzt ist. */
111 readonly termin: string | null;
112 /** Volle Tage bis zum Termin. 0 = heute, negativ = vorbei. */
113 readonly tageBisTermin: number | null;
114 readonly gesamtFragen: number;
115 readonly nieBeantwortet: number;
116 readonly faellig: number;
117 /** Aktuell angesetzte Zielquote zwischen {@link ZIELQUOTE_GRUND} und {@link ZIELQUOTE_TERMIN}. */
118 readonly zielquote: number;
119 /** Geschätzte Trefferquote, wenn heute geprüft würde. */
120 readonly prognoseHeute: number;
121 /**
122 * Geschätzte Trefferquote am Prüfungstag, **wenn ab jetzt nicht mehr
123 * gelernt wird**. Die Differenz zu {@link prognoseHeute} zeigt, was
124 * Nichtstun kostet.
125 */
126 readonly prognoseAmTermin: number | null;
127 readonly pensum: Tagespensum;
128 readonly machbarkeit: Machbarkeit;
129 /** Sekunden je Frage, die der Schätzung zugrunde liegen. */
130 readonly sekundenProFrage: number;
131 /**
132 * Bereits angesetzte Wiederholungen je Tag, beginnend mit heute.
133 *
134 * Eine **Untergrenze**, keine Prognose: Was hier steht, ist der heutige
135 * Kalenderstand; jede Antwort von heute setzt neue Termine. Die neuen
136 * Fragen bleiben draußen – sie kommen mit der Einführungsrate, die als
137 * eigene, planbare Größe daneben steht.
138 *
139 * Optional geführt wie die übrigen jüngeren Erweiterungen, damit die
140 * Attrappen der Oberflächentests mit dem bisherigen Zuschnitt gültig
141 * bleiben.
142 */
143 readonly vorschau?: readonly number[];
144 }
145
146 /**
147 * Stand einer einzelnen Frage, wie ihn die Planung braucht.
148 *
149 * Erbt bewusst von {@link ReifeFrage}: Planung und Reifegrad rechnen auf
150 * **derselben** Zeilenform, damit die Prognose des Lernplans und die Zahl der
151 * Ampel nicht nur zufällig übereinstimmen, sondern dieselbe Rechnung sind.
152 * Genau das Auseinanderlaufen war der Befund in `docs/stand.md` 7.1.
153 */
154 export interface PlanFrage extends ReifeFrage {
155 /** Ob die Frage heute zur Wiederholung ansteht. */
156 readonly faellig: boolean;
157 /**
158 * In wie vielen Tagen die Frage ansteht; 0 heißt heute oder überfällig.
159 *
160 * `null` für nie beantwortete Fragen: Sie haben keinen Termin, sondern
161 * warten auf die Einführungsrate. Gebraucht wird der Wert für die
162 * Arbeitslast-Vorschau (siehe {@link Lernplan.vorschau}).
163 */
164 readonly faelligInTagen?: number | null;
165 }
166
167 /** Alles, was die Planung an Fakten braucht. Kommt aus der Datenbank. */
168 export interface Planungsdaten {
169 readonly termin: string | null;
170 readonly tageBisTermin: number | null;
171 readonly fragen: readonly PlanFrage[];
172 /** Bisheriges Tempo; `null`, solange zu wenig Historie vorliegt. */
173 readonly sekundenProFrage: number | null;
174 }
175
176 /**
177 * Zielquote für den heutigen Tag.
178 *
179 * Ohne Termin bleibt es bei {@link ZIELQUOTE_GRUND}. Sonst steigt sie linear
180 * an, sobald weniger als {@link ANHEBUNG_AB_TAGEN} Tage verbleiben, und
181 * erreicht am Termin selbst {@link ZIELQUOTE_TERMIN}.
182 */
183 export function zielquoteFuer(tageBisTermin: number | null): number {
184 if (tageBisTermin === null || !Number.isFinite(tageBisTermin)) {
185 return ZIELQUOTE_GRUND;
186 }
187 if (tageBisTermin >= ANHEBUNG_AB_TAGEN) {
188 return ZIELQUOTE_GRUND;
189 }
190 /* Nach dem Termin bleibt die Zielquote oben: Wer den Termin verstreichen
191 lässt, ohne ihn zu ändern, lernt vermutlich für einen Nachholtermin. */
192 const naehe = 1 - Math.max(0, tageBisTermin) / ANHEBUNG_AB_TAGEN;
193 return ZIELQUOTE_GRUND + (ZIELQUOTE_TERMIN - ZIELQUOTE_GRUND) * naehe;
194 }
195
196 /**
197 * Tage, an denen noch neue Fragen eingeführt werden können.
198 *
199 * @see PUFFER_TAGE_MAX zur Begründung des Abzugs.
200 */
201 export function einfuehrungstage(tageBisTermin: number): number {
202 const puffer = Math.min(PUFFER_TAGE_MAX, Math.floor(tageBisTermin / 5));
203 return Math.max(1, tageBisTermin - puffer);
204 }
205
206 /**
207 * Neue Fragen für heute – das „neu“ des Tagespensums.
208 *
209 * Eine Funktion, zwei Verwender, und genau das ist der Punkt:
210 * {@link lernplanBerechnen} zeigt die Zahl an, `Lernstand.sitzung()` deckelt
211 * die neuen Fragen einer Sitzung damit. Anzeige und Sitzung sind dieselbe
212 * Rechnung. Bis Fassung 0.20.0 war die Rate reine Anzeige – die Sitzung
213 * füllte unabhängig davon mit neuen Fragen auf (`docs/stand.md` 7.11).
214 *
215 * Mit Termin werden die ungesehenen Fragen gleichmäßig über die
216 * verbleibenden {@link einfuehrungstage} verteilt. Die Rate ist keine Menge,
217 * die sich abarbeitet, sondern wird täglich neu gebildet – wer Tage
218 * auslässt, bekommt danach von selbst mehr.
219 *
220 * Ohne Termin gibt es nichts zu verteilen – dann gilt {@link SITZUNGSUMFANG},
221 * damit der Plan nicht „nichts zu tun“ meldet, während der halbe Katalog
222 * ungesehen ist. Für die übliche Sitzung von 20 Fragen ist das zugleich das
223 * bisherige Verhalten: Der Deckel liegt auf der Sitzungsgröße und greift
224 * nicht. Nach dem Termin bleibt es bei 0: Wer den Termin nicht nachträgt,
225 * bekommt keinen erfundenen Plan.
226 *
227 * **Am Prüfungstag selbst ebenfalls 0**, und der Grund ist keine Schonung,
228 * sondern eine Ableitung aus der Belegregel: Eine Frage, die heute zum ersten
229 * Mal auftaucht, kann nicht mehr belegt werden. Der Beleg verlangt eine
230 * richtige Antwort nach mindestens einem Tag Abstand
231 * (`MINDESTABSTAND_TAGE` in `reife.ts`), und diesen Tag gibt es nicht mehr.
232 * Neue Fragen sind heute nicht knapp, sondern wirkungslos.
233 *
234 * Vorher stand hier `< 0`. Da `einfuehrungstage(0)` auf 1 zurückfällt, ergab
235 * das am Prüfungstag `ceil(n / 1)` – der ganze Rest. Nachgerechnet: 300
236 * ungesehene Fragen und 20 fällige ergaben am Morgen der Prüfung „Heute 320
237 * Fragen, etwa 133 Minuten“.
238 *
239 * Der Vortag bleibt ausdrücklich, wie er ist: Dort ist der Beleg noch
240 * erreichbar, mehr Tage gibt es nicht, und `machbarkeitFuer` sagt von selbst,
241 * wenn das nicht mehr zu schaffen ist. Ein harter Einführungsstopp über
242 * mehrere Tage wäre schlechter – er verstecke dreizehn ungesehene Fragen bei
243 * drei Tagen Rest ganz.
244 */
245 export function einfuehrungsrate(nieBeantwortet: number, tageBisTermin: number | null): number {
246 if (tageBisTermin === null) {
247 return Math.min(nieBeantwortet, SITZUNGSUMFANG);
248 }
249 if (tageBisTermin <= 0) {
250 return 0;
251 }
252 return Math.min(nieBeantwortet, Math.ceil(nieBeantwortet / einfuehrungstage(tageBisTermin)));
253 }
254
255 /**
256 * Einschätzung des Tagespensums.
257 *
258 * Bewertet wird die **Zeit**, nicht die Anzahl: Eine Stunde täglich ist eine
259 * Aussage, die jeder einordnen kann; „62 Fragen“ ist es nicht.
260 */
261 export function machbarkeitFuer(minuten: number, tageBisTermin: number | null): Machbarkeit {
262 if (tageBisTermin === null) {
263 return 'kein_termin';
264 }
265 if (tageBisTermin < 0) {
266 return 'termin_vorbei';
267 }
268 if (minuten <= 20) {
269 return 'entspannt';
270 }
271 if (minuten <= 45) {
272 return 'machbar';
273 }
274 if (minuten <= 90) {
275 return 'knapp';
276 }
277 return 'zu_wenig_zeit';
278 }
279
280 /**
281 * Obergrenze der Wiedervorlage in Tagen.
282 *
283 * FSRS kann bei gut sitzendem Stoff Intervalle von Jahren berechnen. Für eine
284 * Prüfungsvorbereitung ist das ohne Nutzen: Ein halbes Jahr ist länger als
285 * jede realistische Vorbereitungszeit, und eine Frage nach zwei Jahren wieder
286 * vorzulegen hilft niemandem, der in acht Wochen geprüft wird.
287 */
288 export const MAX_INTERVALL_TAGE = 180;
289
290 /** Ergebnis der Terminberechnung nach einer Antwort. */
291 export interface Wiedervorlage extends Gedaechtnisstand {
292 /**
293 * Abstand bis zur nächsten Vorlage in ganzen Tagen.
294 *
295 * 0 bedeutet: sofort wieder fällig – spätestens die nächste Sitzung legt
296 * die Frage wieder vorn vor.
297 */
298 readonly intervallTage: number;
299 }
300
301 /**
302 * Schreibt den Gedächtnisstand fort und bestimmt daraus den nächsten Termin.
303 *
304 * Die einzige Stelle, an der FSRS und Prüfungstermin zusammenkommen.
305 *
306 * Ein „nochmal“ führt bewusst zu Intervall 0 statt zu dem, was FSRS
307 * ausrechnen würde (rund fünf Stunden): Die Frage ist sofort wieder fällig
308 * und steht in der nächsten Sitzung in der Vorranggruppe vorn. Dass sie
309 * schon in der **laufenden** Sitzung wiederkommt, leistet nicht dieses
310 * Intervall, sondern die Oberfläche: `useSitzung` reiht eine falsch
311 * beantwortete Frage bis zu zweimal ans Sitzungsende, bis sie einmal richtig
312 * beantwortet ist. (Bis Fassung 0.20.0 behauptete dieser Kommentar das
313 * Wiedersehen in derselben Sitzung, und niemand leistete es.) Der
314 * Gedächtnisstand wird davon nicht berührt: Er wird ganz normal nach FSRS
315 * fortgeschrieben und bestimmt weiterhin, wie es danach weitergeht.
316 */
317 export function wiedervorlageBerechnen(
318 bisher: Gedaechtnisstand | null,
319 grad: FsrsGrad,
320 abstandTage: number,
321 tageBisTermin: number | null,
322 ): Wiedervorlage {
323 const stand = naechsterStand(bisher, grad, abstandTage);
324
325 if (grad === 1) {
326 return { ...stand, intervallTage: 0 };
327 }
328
329 const roh = intervallTage(stand.stabilitaet, zielquoteFuer(tageBisTermin));
330 const tage = Math.min(MAX_INTERVALL_TAGE, Math.max(1, Math.round(roh)));
331
332 return { ...stand, intervallTage: tage };
333 }
334
335 /** So viele Tage weit reicht die Arbeitslast-Vorschau höchstens. */
336 export const VORSCHAU_TAGE = 14;
337
338 /**
339 * Die bereits angesetzten Wiederholungen der nächsten Tage.
340 *
341 * **Wozu.** Der Plan kannte bis 0.22.0 nur „heute“. Weil die Zielquote ab
342 * {@link ANHEBUNG_AB_TAGEN} Tagen vor dem Termin steigt und die Intervalle
343 * dadurch zusammenrücken, türmt sich vor der Prüfung ein Wiederholungsberg,
344 * den der Lernende erst am jeweiligen Morgen erfährt. Wer Schichtdienst,
345 * Familie oder einen vollen Kalender hat, kann Lerntage aber nur planen, wenn
346 * er die kommende Last kennt.
347 *
348 * **Was das ist und was nicht.** Eine reine Bestandsauskunft: So viele Fragen
349 * stehen an diesem Tag **jetzt schon** im Kalender. Keine Prognose — und
350 * ausdrücklich eine **Untergrenze**, denn jede Antwort von heute setzt neue
351 * Termine. Mit wachsendem Abstand wird sie systematisch leerer; die Anzeige
352 * sagt das, statt eine leere Woche zu suggerieren.
353 *
354 * Die neuen Fragen bleiben draußen: Sie haben keinen Termin, sondern kommen
355 * mit der Einführungsrate — eine planbare Größe, die daneben steht.
356 */
357 export function vorschauBauen(
358 fragen: readonly PlanFrage[],
359 tageBisTermin: number | null,
360 ): readonly number[] {
361 const weite =
362 tageBisTermin === null || tageBisTermin < 0
363 ? VORSCHAU_TAGE
364 : Math.min(VORSCHAU_TAGE, tageBisTermin);
365
366 const tage = new Array<number>(weite + 1).fill(0);
367 for (const frage of fragen) {
368 const wann = frage.faelligInTagen;
369 if (wann === null || wann === undefined || wann > weite) {
370 continue;
371 }
372 tage[wann] = (tage[wann] ?? 0) + 1;
373 }
374 return tage;
375 }
376
377 /** Stellt aus den Rohdaten den Lernplan zusammen. */
378 export function lernplanBerechnen(daten: Planungsdaten): Lernplan {
379 const { termin, tageBisTermin, fragen } = daten;
380
381 const gesamtFragen = fragen.length;
382 const nieBeantwortet = fragen.filter((f) => f.stabilitaet === null).length;
383 const faellig = fragen.filter((f) => f.faellig).length;
384
385 const sekundenProFrage =
386 daten.sekundenProFrage !== null && daten.sekundenProFrage > 0
387 ? daten.sekundenProFrage
388 : SEKUNDEN_PRO_FRAGE_VORGABE;
389
390 /* Dieselbe Rate, mit der `Lernstand.sitzung()` die neuen Fragen deckelt –
391 Begründung und Randfälle stehen an der Funktion selbst. */
392 const neu = einfuehrungsrate(nieBeantwortet, tageBisTermin);
393
394 const gesamt = neu + faellig;
395 const minuten = Math.round((gesamt * sekundenProFrage) / 60);
396
397 return {
398 termin,
399 tageBisTermin,
400 gesamtFragen,
401 nieBeantwortet,
402 faellig,
403 zielquote: zielquoteFuer(tageBisTermin),
404 prognoseHeute: reifegradVon(fragen, 0),
405 prognoseAmTermin:
406 tageBisTermin === null || tageBisTermin < 0 ? null : reifegradVon(fragen, tageBisTermin),
407 pensum: { neu, wiederholung: faellig, gesamt, minuten },
408 machbarkeit: machbarkeitFuer(minuten, tageBisTermin),
409 sekundenProFrage,
410 vorschau: vorschauBauen(fragen, tageBisTermin),
411 };
412 }