waffensachkunde

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

/ app src shared lernplan.ts

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