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