waffensachkunde

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

/ app src shared lernplan.ts

21,9 KB Rohdatei
app/src/shared/lernplan.ts — 531 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 * Untergrenze der Zielquote in einem Bereich mit K.-o.-Kriterium.
178 *
179 * **Warum es sie gibt.** Manche Prüfungsordnungen lassen in Notwehr und
180 * Notstand höchstens zwei Fehler zu, gleich wie gut der Rest sitzt. Die
181 * Reifeampel weiß das seit 0.22.0 und deckelt daran ihr Gesamturteil
182 * ({@link gesamtstufeMitDeckel}) – der Planer wusste es nicht und behandelte
183 * die 43 Fragen aus I.5 wie jede andere. Die Ampel warnte also, und die
184 * Wiedervorlage handelte nicht danach.
185 *
186 * **Warum 0,95 und nicht 0,97.** Nachgerechnet mit den Parametern dieses
187 * Projekts (Abfall 0,1542): Bei einer Stabilität von 60 Tagen ergibt 0,90 ein
188 * Intervall von 60 Tagen, 0,95 eines von 24 und 0,97 eines von 13. Die
189 * Terminquote dauerhaft anzulegen hieße, diese Fragen viereinhalbmal so oft
190 * vorzulegen wie alle anderen – auch ein Jahr vor der Prüfung. 0,95 hält sie
191 * spürbar enger, ohne die Sitzung zu füllen; und sobald der Termin naht,
192 * steigt die allgemeine Quote ohnehin darüber und die Untergrenze greift
193 * nicht mehr.
194 */
195 export const ZIELQUOTE_KO = 0.95;
196
197 /**
198 * Zielquote für den heutigen Tag.
199 *
200 * Ohne Termin bleibt es bei {@link ZIELQUOTE_GRUND}. Sonst steigt sie linear
201 * an, sobald weniger als {@link ANHEBUNG_AB_TAGEN} Tage verbleiben, und
202 * erreicht am Termin selbst {@link ZIELQUOTE_TERMIN}.
203 */
204 export function zielquoteFuer(tageBisTermin: number | null, istKoBereich = false): number {
205 /* Die Untergrenze steht am Ende und nicht als eigener Zweig: Sie soll die
206 Anhebung zum Termin nicht ersetzen, sondern nur verhindern, dass ein
207 K.-o.-Bereich fern vom Termin auf die Grundquote zurückfällt. */
208 const untergrenze = istKoBereich ? ZIELQUOTE_KO : 0;
209 if (tageBisTermin === null || !Number.isFinite(tageBisTermin)) {
210 return Math.max(ZIELQUOTE_GRUND, untergrenze);
211 }
212 if (tageBisTermin >= ANHEBUNG_AB_TAGEN) {
213 return Math.max(ZIELQUOTE_GRUND, untergrenze);
214 }
215 /* Nach dem Termin bleibt die Zielquote oben: Wer den Termin verstreichen
216 lässt, ohne ihn zu ändern, lernt vermutlich für einen Nachholtermin. */
217 const naehe = 1 - Math.max(0, tageBisTermin) / ANHEBUNG_AB_TAGEN;
218 return Math.max(ZIELQUOTE_GRUND + (ZIELQUOTE_TERMIN - ZIELQUOTE_GRUND) * naehe, untergrenze);
219 }
220
221 /**
222 * Tage, an denen noch neue Fragen eingeführt werden können.
223 *
224 * @see PUFFER_TAGE_MAX zur Begründung des Abzugs.
225 */
226 export function einfuehrungstage(tageBisTermin: number): number {
227 const puffer = Math.min(PUFFER_TAGE_MAX, Math.floor(tageBisTermin / 5));
228 return Math.max(1, tageBisTermin - puffer);
229 }
230
231 /**
232 * Neue Fragen für heute – das „neu“ des Tagespensums.
233 *
234 * Eine Funktion, zwei Verwender, und genau das ist der Punkt:
235 * {@link lernplanBerechnen} zeigt die Zahl an, `Lernstand.sitzung()` deckelt
236 * die neuen Fragen einer Sitzung damit. Anzeige und Sitzung sind dieselbe
237 * Rechnung. Bis Fassung 0.20.0 war die Rate reine Anzeige – die Sitzung
238 * füllte unabhängig davon mit neuen Fragen auf (`docs/stand.md` 7.11).
239 *
240 * Mit Termin werden die ungesehenen Fragen gleichmäßig über die
241 * verbleibenden {@link einfuehrungstage} verteilt. Die Rate ist keine Menge,
242 * die sich abarbeitet, sondern wird täglich neu gebildet – wer Tage
243 * auslässt, bekommt danach von selbst mehr.
244 *
245 * Ohne Termin gibt es nichts zu verteilen – dann gilt {@link SITZUNGSUMFANG},
246 * damit der Plan nicht „nichts zu tun“ meldet, während der halbe Katalog
247 * ungesehen ist. Für die übliche Sitzung von 20 Fragen ist das zugleich das
248 * bisherige Verhalten: Der Deckel liegt auf der Sitzungsgröße und greift
249 * nicht. Nach dem Termin bleibt es bei 0: Wer den Termin nicht nachträgt,
250 * bekommt keinen erfundenen Plan.
251 *
252 * **Am Prüfungstag selbst ebenfalls 0**, und der Grund ist keine Schonung,
253 * sondern eine Ableitung aus der Belegregel: Eine Frage, die heute zum ersten
254 * Mal auftaucht, kann nicht mehr belegt werden. Der Beleg verlangt eine
255 * richtige Antwort nach mindestens einem Tag Abstand
256 * (`MINDESTABSTAND_TAGE` in `reife.ts`), und diesen Tag gibt es nicht mehr.
257 * Neue Fragen sind heute nicht knapp, sondern wirkungslos.
258 *
259 * Vorher stand hier `< 0`. Da `einfuehrungstage(0)` auf 1 zurückfällt, ergab
260 * das am Prüfungstag `ceil(n / 1)` – der ganze Rest. Nachgerechnet: 300
261 * ungesehene Fragen und 20 fällige ergaben am Morgen der Prüfung „Heute 320
262 * Fragen, etwa 133 Minuten“.
263 *
264 * Der Vortag bleibt ausdrücklich, wie er ist: Dort ist der Beleg noch
265 * erreichbar, mehr Tage gibt es nicht, und `machbarkeitFuer` sagt von selbst,
266 * wenn das nicht mehr zu schaffen ist. Ein harter Einführungsstopp über
267 * mehrere Tage wäre schlechter – er verstecke dreizehn ungesehene Fragen bei
268 * drei Tagen Rest ganz.
269 */
270 export function einfuehrungsrate(nieBeantwortet: number, tageBisTermin: number | null): number {
271 if (tageBisTermin === null) {
272 return Math.min(nieBeantwortet, SITZUNGSUMFANG);
273 }
274 if (tageBisTermin <= 0) {
275 return 0;
276 }
277 return Math.min(nieBeantwortet, Math.ceil(nieBeantwortet / einfuehrungstage(tageBisTermin)));
278 }
279
280 /**
281 * Einschätzung des Tagespensums.
282 *
283 * Bewertet wird die **Zeit**, nicht die Anzahl: Eine Stunde täglich ist eine
284 * Aussage, die jeder einordnen kann; „62 Fragen“ ist es nicht.
285 */
286 export function machbarkeitFuer(minuten: number, tageBisTermin: number | null): Machbarkeit {
287 if (tageBisTermin === null) {
288 return 'kein_termin';
289 }
290 if (tageBisTermin < 0) {
291 return 'termin_vorbei';
292 }
293 if (minuten <= 20) {
294 return 'entspannt';
295 }
296 if (minuten <= 45) {
297 return 'machbar';
298 }
299 if (minuten <= 90) {
300 return 'knapp';
301 }
302 return 'zu_wenig_zeit';
303 }
304
305 /**
306 * Obergrenze der Wiedervorlage in Tagen.
307 *
308 * FSRS kann bei gut sitzendem Stoff Intervalle von Jahren berechnen. Für eine
309 * Prüfungsvorbereitung ist das ohne Nutzen: Ein halbes Jahr ist länger als
310 * jede realistische Vorbereitungszeit, und eine Frage nach zwei Jahren wieder
311 * vorzulegen hilft niemandem, der in acht Wochen geprüft wird.
312 */
313 export const MAX_INTERVALL_TAGE = 180;
314
315 /** Ergebnis der Terminberechnung nach einer Antwort. */
316 export interface Wiedervorlage extends Gedaechtnisstand {
317 /**
318 * Abstand bis zur nächsten Vorlage in ganzen Tagen.
319 *
320 * 0 bedeutet: sofort wieder fällig – spätestens die nächste Sitzung legt
321 * die Frage wieder vorn vor.
322 */
323 readonly intervallTage: number;
324 }
325
326 /**
327 * Schreibt den Gedächtnisstand fort und bestimmt daraus den nächsten Termin.
328 *
329 * Die einzige Stelle, an der FSRS und Prüfungstermin zusammenkommen.
330 *
331 * Ein „nochmal“ führt bewusst zu Intervall 0 statt zu dem, was FSRS
332 * ausrechnen würde (rund fünf Stunden): Die Frage ist sofort wieder fällig
333 * und steht in der nächsten Sitzung in der Vorranggruppe vorn. Dass sie
334 * schon in der **laufenden** Sitzung wiederkommt, leistet nicht dieses
335 * Intervall, sondern die Oberfläche: `useSitzung` reiht eine falsch
336 * beantwortete Frage bis zu zweimal ans Sitzungsende, bis sie einmal richtig
337 * beantwortet ist. (Bis Fassung 0.20.0 behauptete dieser Kommentar das
338 * Wiedersehen in derselben Sitzung, und niemand leistete es.) Der
339 * Gedächtnisstand wird davon nicht berührt: Er wird ganz normal nach FSRS
340 * fortgeschrieben und bestimmt weiterhin, wie es danach weitergeht.
341 */
342 /**
343 * Streut ein Wiedervorlage-Intervall, damit nicht ganze Tagesjahrgänge im
344 * Gleichschritt marschieren.
345 *
346 * **Das Problem.** Ohne Streuung ist der Termin punktgenau `jetzt + n Tage`.
347 * Wer an einem Abend zwanzig neue Fragen mit „gut“ beantwortet, bekommt für
348 * alle zwanzig dasselbe Intervall — und damit denselben Termin, und zwei
349 * Termine später wieder. Die Arbeitslastvorschau macht diese Berge sichtbar;
350 * geglättet hat sie sie nie.
351 *
352 * **Warum aus der Frage-ID und nicht aus einer Zufallsquelle.** Der erste
353 * Entwurf nahm `this.zufall` des Lernstands. Er fiel sofort auf: Jeder Test,
354 * der einen Abstand nachrechnet, ohne die Quelle festzunageln, wurde damit
355 * zufällig — zwei Zusicherungen wurden auf der Stelle rot, eine davon ohne
356 * jede Berührung. Ein Verfahren, das reproduzierbare Zahlen liefert, darf
357 * nicht an einer Quelle hängen, die es nicht kennt. Derselbe Versatz für
358 * dieselbe Frage ist zudem kein Nachteil: Verschieden sind die **Fragen**
359 * untereinander, und genau darum geht es.
360 *
361 * **Die Regeln.** Höchstens 15 Prozent, gedeckelt auf sieben Tage, und erst
362 * ab drei Tagen: Darunter ist ein Tag Versatz keine Streuung mehr, sondern
363 * eine andere Antwort — bei Intervall 1 hieße „ein Tag früher“ noch heute.
364 */
365 export function gestreutesIntervall(tage: number, frageId: string): number {
366 if (tage < 3) {
367 return tage;
368 }
369 const spanne = Math.min(7, Math.max(1, Math.round(tage * 0.15)));
370 /* Ein einfacher, stabiler Streuwert aus der Kennung. Keine Kryptographie –
371 gebraucht wird nur, dass benachbarte Fragen verschieden herauskommen. */
372 let summe = 0;
373 for (let i = 0; i < frageId.length; i += 1) {
374 summe = (summe * 31 + frageId.charCodeAt(i)) % 100_003;
375 }
376 const versatz = (summe % (2 * spanne + 1)) - spanne;
377 return Math.min(MAX_INTERVALL_TAGE, Math.max(1, tage + versatz));
378 }
379
380 /**
381 * Wie {@link gestreutesIntervall}, aber mit Blick auf den Kalender.
382 *
383 * **Was die Streuung allein nicht kann.** Sie verteilt Fragen gleichmäßig
384 * über ein Fenster – aber blind. Sie weiß nicht, dass am Donnerstag schon
385 * neunzig Wiederholungen stehen und am Freitag vier. Wer an drei Abenden
386 * hintereinander lernt, baut sich Berge, die die Streuung nur ein wenig
387 * verwischt.
388 *
389 * **Was hier dazukommt.** Aus demselben Fenster wird der Tag mit der
390 * **geringsten** bereits angesetzten Last gewählt. Das Fenster ist unverändert
391 * das der Streuung: höchstens 15 Prozent, gedeckelt auf sieben Tage, erst ab
392 * drei Tagen Intervall. Es wird also nichts verschoben, was nicht ohnehin
393 * verschoben würde – die Genauigkeit des Modells bleibt, wo sie war.
394 *
395 * **Die Eigenschaft, auf die es ankommt.** Sind alle Tage des Fensters gleich
396 * belastet – der Regelfall bei einem leeren Kalender –, kommt **genau** das
397 * heraus, was die Streuung liefert. Der Gleichstand entscheidet nach dem
398 * Abstand zum gestreuten Wert. Der Ausgleich ist damit eine Verfeinerung und
399 * keine Ablösung: Ohne Last ändert sich nichts, mit Last wird geglättet.
400 *
401 * @param lastJeTag Wie viele Fragen an Tag `n` (von heute aus gezählt) bereits
402 * fällig sind. `null`, wenn es keine Auskunft gibt – dann wird gestreut wie
403 * bisher.
404 */
405 export function ausgeglichenesIntervall(
406 tage: number,
407 frageId: string,
408 lastJeTag: ReadonlyMap<number, number> | null,
409 ): number {
410 const gestreut = gestreutesIntervall(tage, frageId);
411 if (lastJeTag === null || tage < 3) {
412 return gestreut;
413 }
414
415 const spanne = Math.min(7, Math.max(1, Math.round(tage * 0.15)));
416 const von = Math.max(1, tage - spanne);
417 const bis = Math.min(MAX_INTERVALL_TAGE, tage + spanne);
418
419 let bester = gestreut;
420 let besteLast = Number.POSITIVE_INFINITY;
421 let besterAbstand = Number.POSITIVE_INFINITY;
422
423 for (let kandidat = von; kandidat <= bis; kandidat += 1) {
424 const last = lastJeTag.get(kandidat) ?? 0;
425 const abstand = Math.abs(kandidat - gestreut);
426 if (last < besteLast || (last === besteLast && abstand < besterAbstand)) {
427 bester = kandidat;
428 besteLast = last;
429 besterAbstand = abstand;
430 }
431 }
432 return bester;
433 }
434
435 export function wiedervorlageBerechnen(
436 bisher: Gedaechtnisstand | null,
437 grad: FsrsGrad,
438 abstandTage: number,
439 tageBisTermin: number | null,
440 istKoBereich = false,
441 ): Wiedervorlage {
442 const stand = naechsterStand(bisher, grad, abstandTage);
443
444 if (grad === 1) {
445 return { ...stand, intervallTage: 0 };
446 }
447
448 const roh = intervallTage(stand.stabilitaet, zielquoteFuer(tageBisTermin, istKoBereich));
449 const tage = Math.min(MAX_INTERVALL_TAGE, Math.max(1, Math.round(roh)));
450
451 return { ...stand, intervallTage: tage };
452 }
453
454 /** So viele Tage weit reicht die Arbeitslast-Vorschau höchstens. */
455 export const VORSCHAU_TAGE = 14;
456
457 /**
458 * Die bereits angesetzten Wiederholungen der nächsten Tage.
459 *
460 * **Wozu.** Der Plan kannte bis 0.22.0 nur „heute“. Weil die Zielquote ab
461 * {@link ANHEBUNG_AB_TAGEN} Tagen vor dem Termin steigt und die Intervalle
462 * dadurch zusammenrücken, türmt sich vor der Prüfung ein Wiederholungsberg,
463 * den der Lernende erst am jeweiligen Morgen erfährt. Wer Schichtdienst,
464 * Familie oder einen vollen Kalender hat, kann Lerntage aber nur planen, wenn
465 * er die kommende Last kennt.
466 *
467 * **Was das ist und was nicht.** Eine reine Bestandsauskunft: So viele Fragen
468 * stehen an diesem Tag **jetzt schon** im Kalender. Keine Prognose — und
469 * ausdrücklich eine **Untergrenze**, denn jede Antwort von heute setzt neue
470 * Termine. Mit wachsendem Abstand wird sie systematisch leerer; die Anzeige
471 * sagt das, statt eine leere Woche zu suggerieren.
472 *
473 * Die neuen Fragen bleiben draußen: Sie haben keinen Termin, sondern kommen
474 * mit der Einführungsrate — eine planbare Größe, die daneben steht.
475 */
476 export function vorschauBauen(
477 fragen: readonly PlanFrage[],
478 tageBisTermin: number | null,
479 ): readonly number[] {
480 const weite =
481 tageBisTermin === null || tageBisTermin < 0
482 ? VORSCHAU_TAGE
483 : Math.min(VORSCHAU_TAGE, tageBisTermin);
484
485 const tage = new Array<number>(weite + 1).fill(0);
486 for (const frage of fragen) {
487 const wann = frage.faelligInTagen;
488 if (wann === null || wann === undefined || wann > weite) {
489 continue;
490 }
491 tage[wann] = (tage[wann] ?? 0) + 1;
492 }
493 return tage;
494 }
495
496 /** Stellt aus den Rohdaten den Lernplan zusammen. */
497 export function lernplanBerechnen(daten: Planungsdaten): Lernplan {
498 const { termin, tageBisTermin, fragen } = daten;
499
500 const gesamtFragen = fragen.length;
501 const nieBeantwortet = fragen.filter((f) => f.stabilitaet === null).length;
502 const faellig = fragen.filter((f) => f.faellig).length;
503
504 const sekundenProFrage =
505 daten.sekundenProFrage !== null && daten.sekundenProFrage > 0
506 ? daten.sekundenProFrage
507 : SEKUNDEN_PRO_FRAGE_VORGABE;
508
509 /* Dieselbe Rate, mit der `Lernstand.sitzung()` die neuen Fragen deckelt –
510 Begründung und Randfälle stehen an der Funktion selbst. */
511 const neu = einfuehrungsrate(nieBeantwortet, tageBisTermin);
512
513 const gesamt = neu + faellig;
514 const minuten = Math.round((gesamt * sekundenProFrage) / 60);
515
516 return {
517 termin,
518 tageBisTermin,
519 gesamtFragen,
520 nieBeantwortet,
521 faellig,
522 zielquote: zielquoteFuer(tageBisTermin),
523 prognoseHeute: reifegradVon(fragen, 0),
524 prognoseAmTermin:
525 tageBisTermin === null || tageBisTermin < 0 ? null : reifegradVon(fragen, tageBisTermin),
526 pensum: { neu, wiederholung: faellig, gesamt, minuten },
527 machbarkeit: machbarkeitFuer(minuten, tageBisTermin),
528 sekundenProFrage,
529 vorschau: vorschauBauen(fragen, tageBisTermin),
530 };
531 }