/** * Reifegrad und Prüfungsreife-Ampel. * * ## Was diese Datei beantwortet * * „Wie weit bin ich?“ – und zwar so, dass die Antwort einer Nachprüfung * standhält. Bis Fassung 0.11.0 hieß die Kennzahl `sicher` und bedeutete * *die letzte Antwort war richtig*. Das ist eine Ein-Antwort-Metrik und damit * flacher als die, die `PLAN.md` bei einem Mitbewerber kritisiert. * * ## Warum nicht einfach die FSRS-Abrufwahrscheinlichkeit * * Der naheliegende Ersatz wäre gewesen: für jede Frage `R` aus dem * Gedächtnismodell nehmen und mitteln. Nachgemessen an den Konstanten dieses * Projekts ergibt das eine Zahl, die **unehrlicher** ist als die abgelöste: * * | Katalog einmal durch, je Frage eine Antwort | Mittleres `R` | von 486 | * |---|---|---| * | 0 % richtig | 0,766 | 372 | * | 20 % richtig | 0,802 | 390 | * | 50 % richtig | 0,857 | 416 | * * Wer **jede** Frage genau einmal **falsch** beantwortet, stünde bei 372 von * 486. Der Grund ist kein Rechenfehler, sondern eine Verwechslung: `R` ist * eine **Planungsgröße**, keine Könnensmessung. Der Stabilitätswert nach der * ersten Antwort ist ein Vorgabewert aus der FSRS-Eichung – eine Annahme, * keine Messung. Und dieses Programm füttert das Modell mit einem Klick auf * eine von mehreren Antwortmöglichkeiten, den `bewertungAusErgebnis` * ausnahmslos als „gut“ wertet: Geraten und Gewusst sind für das Modell * dasselbe. * * Schlimmer noch: Die Zahl **stieg beim Falschantworten**. Eine überfällige * Frage mit `S = 2,31` und 20 Tagen Abstand steht bei `R = 0,707`; nach einem * „Nicht gewusst“ steht sie bei `R = 0,884`, weil der neue Stand wieder am * ersten Tag gemessen wird. In allen fünf nachgerechneten Fällen stieg sie. * * ## Die Regel: erst der belegte Abruf zählt * * Eine Frage geht erst in den Reifegrad ein, wenn sie **nach mindestens einem * Tag Abstand richtig beantwortet** wurde. Vorher zählt sie null. * * Diese Grenze ist nicht erfunden. `fsrs.ts` zieht sie bereits selbst: Unter * einem Tag Abstand greift dort die Kurzfristformel, weil „die * Vergessenskurve kein brauchbarer Maßstab“ ist. Was innerhalb eines Tages * geschieht, ist Wiedererkennen; Erinnern zeigt sich erst über Nacht. * * Der Beleg ist eine **Aussage über das Jetzt**, kein Orden für früher: Eine * falsche Antwort nimmt ihn wieder weg, gleich wie lange die Frage vorher * saß. Daraus folgt die Eigenschaft, an der der erste Entwurf gescheitert * war – **die Zahl kann durch eine falsche Antwort nie steigen.** * * ## Die Zahl ist eine Untergrenze, keine Prognose * * Sie sagt, was belegt sitzt, nicht was in der Prüfung herauskäme. Raten ist * nicht eingerechnet, Ungesehenes gilt als nicht gekonnt, und ein Beleg * verfällt mit der Zeit. * * Die Belegregel heilt allerdings nur die **Anzeige**, nicht die * Terminierung: Für die Wiedervorlage zählt die erratene Antwort weiter als * „gut“. Was das kostet und warum dort nichts geändert wird, ist in * `docs/entscheidung-ratewahrscheinlichkeit.md` nachgemessen – kurz: Ein * Rater mit Trefferchance 1/3 bekommt die Frage an 53 von 61 Tagen wieder * vorgelegt, und der Beleg steht dabei nur an 40 Prozent der Tage. Damit setzt sie dieselbe Haltung fort, die * `lernplan.ts` im Modulkopf festhält: eher zu niedrig als zu hoch. Wer diese * Grenze überschreitet, hat Luft – wie viel, sagt das Programm nicht, weil es * das nicht weiß. * * Die Begründung der Schwellen und die Messwerte stehen in * `docs/entscheidung-reifegrad.md`. */ import { abrufwahrscheinlichkeit } from './fsrs'; /** * Kleinster Abstand, mit dem gerechnet wird. * * `abrufwahrscheinlichkeit(S, 0)` ist für **jedes** `S` exakt 1 – ohne diese * Untergrenze stünde eine gerade eben beantwortete Frage bei 100 Prozent. * `tageZwischen` im Anwendungskern liefert Bruchteile von Tagen, der Fall * tritt also bei jeder Sitzung ein. */ export const MINDESTABSTAND_TAGE = 1; /** * Strengste Bestehensgrenze, die dieses Programm kennt. * * Aus `pruefung.ts`: Das Profil „Standard“ verlangt 80 Prozent, „nach Art * privater Lehrgangsträger“ höchstens 15 Fehler auf 75 Fragen – also * ebenfalls 60 von 75, das sind 80 Prozent. Die übrigen festen Profile liegen * darunter (75 und 70 Prozent). Das anpassbare Profil bleibt außen vor: Was * der Nutzer selbst einstellen kann, taugt nicht als Maßstab. */ export const SCHWELLE_KIPPE = 0.8; /** * Ab hier gilt der Stand als prüfungsreif. * * Fünf Punkte über der strengsten Bestehensgrenze. Der Abstand ist kein * Sicherheitszuschlag aus dem Bauch, sondern deckt das, was zwischen einem * Katalog und einem Bogen liegt: Eine Prüfung zieht 75 bis 100 Fragen aus 575 * und trifft dabei nicht den Durchschnitt. Wer genau auf der Grenze steht, * besteht bei günstiger Ziehung und fällt bei ungünstiger durch. * * Bewusst **keine** Wahrscheinlichkeitsangabe daneben. Eine Zahl wie „neun von * zehn Läufen“ setzte voraus, dass alle Fragen dieselbe Trefferchance haben – * das Gegenteil ist der Fall, die Verteilung ist zweigipflig. Die Rechnung * wäre exakt aussehend und falsch. */ export const SCHWELLE_REIF = 0.85; /** Stand einer einzelnen Frage, wie ihn die Reiferechnung braucht. */ export interface ReifeFrage { /** Abschnitt, sonst Kapitel – die Ebene der Bereichsaufschlüsselung. */ readonly bereich: string; /** FSRS-Stabilität in Tagen; `null`, solange nie beantwortet. */ readonly stabilitaet: number | null; /** Tage seit der letzten Antwort, als Bruchzahl. */ readonly tageSeitAntwort: number; /** Ob der Abruf belegt ist – siehe Modulkopf. */ readonly bestaetigt: boolean; } /** * Beitrag einer einzelnen Frage, `zusatzTage` in der Zukunft. * * @returns Wert zwischen 0 und 1. Ohne Beleg immer 0. */ export function abrufFuer(frage: ReifeFrage, zusatzTage = 0): number { if (!frage.bestaetigt || frage.stabilitaet === null) { return 0; } const tage = Math.max(MINDESTABSTAND_TAGE, frage.tageSeitAntwort + zusatzTage); return abrufwahrscheinlichkeit(frage.stabilitaet, tage); } /** * Reifegrad über eine Fragenmenge: der Mittelwert der Einzelbeiträge. * * Ungesehene und unbelegte Fragen bleiben **im Nenner**. Sie herauszunehmen * ergäbe eine Zahl, die bei der ersten belegten Frage auf 100 Prozent * springt – die häufigste Art, eine Fortschrittsanzeige zu belügen. * * @param zusatzTage Blick in die Zukunft, für die Prognose zum Prüfungstermin. */ export function reifegradVon(fragen: readonly ReifeFrage[], zusatzTage = 0): number { if (fragen.length === 0) { return 0; } let summe = 0; for (const frage of fragen) { summe += abrufFuer(frage, zusatzTage); } return summe / fragen.length; } /** * Die Zahl, die in der Oberfläche steht. * * Gerundet, nicht abgeschnitten: Abschneiden wäre um bis zu eine ganze Frage * zu pessimistisch, und die Zahl ist bereits eine Untergrenze – ein zweiter * Abschlag darauf wäre keine Vorsicht mehr, sondern eine zweite Verzerrung. */ export function belegteFragen(reifegrad: number, fragenGesamt: number): number { return Math.round(reifegrad * fragenGesamt); } /** * Wie viele Fragen eine Schwelle bei dieser Menge verlangt. * * Aufgerundet: 0,85 von 486 sind 413,1 Fragen, und 413 erreichen die Quote * nicht. Dieselbe Rechnung wie `benoetigteTreffer` in `pruefung.ts`, aus * demselben Grund. */ export function benoetigteFragen(schwelle: number, fragenGesamt: number): number { return Math.ceil(schwelle * fragenGesamt); } /** * Die Stufen der Ampel. * * `ohne_beleg` ist keine vierte Farbe, sondern der ehrliche Sonderfall: Wer * noch keine Frage ein zweites Mal wiedergesehen hat, bekommt keine Einstufung * vorgegaukelt, sondern die Auskunft, woran das liegt. */ export type Reifestufe = 'ohne_beleg' | 'zurueck' | 'kippe' | 'reif'; /** Das Wort, das in der Oberfläche steht. */ export const STUFE_WORT: Readonly> = Object.freeze({ ohne_beleg: 'Noch kein belegter Stand', zurueck: 'Noch nicht so weit', kippe: 'Auf der Kippe', reif: 'Prüfungsreif', }); /** * Einstufung – **an der angezeigten Zahl, nicht am Bruchwert**. * * Der Unterschied ist keine Feinheit. Entschiede die Stufe am ungerundeten * Reifegrad und nennte der Satz die gerundete Zahl, gäbe es ein Band, in dem * „Prüfungsreif“ neben einer Zahl steht, die eine Zeile tiefer als noch nicht * ausreichend ausgewiesen ist. So gilt: Die Stufe ist genau dann erreicht, * wenn die genannte Zahl die genannte Zielzahl erreicht. */ export function stufeFuer(belegt: number, fragenGesamt: number): Reifestufe { if (fragenGesamt === 0 || belegt === 0) { return 'ohne_beleg'; } if (belegt >= benoetigteFragen(SCHWELLE_REIF, fragenGesamt)) { return 'reif'; } if (belegt >= benoetigteFragen(SCHWELLE_KIPPE, fragenGesamt)) { return 'kippe'; } return 'zurueck'; } /** * Die Gesamtstufe, gedeckelt durch einen zurückliegenden K.-o.-Bereich. * * Manche Prüfungsordnungen lassen in Notwehr und Notstand höchstens zwei * Fehler zu, gleich wie gut der Rest ist (`pruefung.ts`, `koKriterien`). Wer * insgesamt bei 88 Prozent steht und dort bei 60, fällt sicher durch. Eine * Ampel namens „Prüfungsreife“, die das verschweigt, wäre gefährlicher als * gar keine – deshalb deckelt der schwächste K.-o.-Bereich das Gesamturteil. * * Gedeckelt wird auf `kippe`, nicht auf `zurueck`: Der übrige Stand ist ja * vorhanden, es fehlt eine benannte Stelle. Wer ohnehin schon zurückliegt, * bleibt dort. */ export function gesamtstufeMitDeckel( gesamt: Reifestufe, kokriterien: readonly Reifestufe[], ): Reifestufe { if (gesamt !== 'reif') { return gesamt; } return kokriterien.every((stufe) => stufe === 'reif') ? 'reif' : 'kippe'; } /** * Der Kernsatz zu einem Stand – **einmal formuliert, überall derselbe**. * * Steht hier und nicht in der Komponente, weil ihn drei Stellen brauchen: * der Startbildschirm, der PDF-Lernbericht und das Handbuch. Drei * Formulierungen desselben Sachverhalts driften auseinander, sobald eine * Schwelle sich ändert – und dann behauptet der ausgedruckte Bericht etwas * anderes als der Bildschirm, von dem er stammt. * * Die Zahl steht **vorn**, das Urteil dahinter: „413 von 486 Fragen sitzen * belegt“ ist überprüfbar, „prüfungsreif“ ist eine Auslegung davon. Wer nur * den Anfang liest, hat die Tatsache; wer weiterliest, bekommt die Einordnung. */ export function reifesatz( belegt: number, fragenGesamt: number, stufe: Reifestufe, deckelnd: readonly string[] = [], ): string { if (stufe === 'ohne_beleg') { return ( 'Noch keine Frage ist belegt: Dafür muss eine Frage nach mindestens einem Tag Abstand ' + 'noch einmal richtig beantwortet werden. Beim ersten Mal zählt sie nicht mit – ' + 'Wiedererkennen ist kein Erinnern.' ); } const kern = `${String(belegt)} von ${String(fragenGesamt)} Fragen sitzen belegt.`; /* Die Einordnung des Gesamtstands – **ohne** den Deckel eines K.-o.-Bereichs. Genau darauf kam es an: `stufe` ist bereits gedeckelt, und aus ihr allein liesse sich nicht ablesen, ob der Gesamtstand für sich genommen reicht. */ const ohneDeckel = stufeFuer(belegt, fragenGesamt); const einordnung = ohneDeckel === 'reif' ? 'Das liegt über der strengsten Bestehensgrenze dieses Programms.' : ohneDeckel === 'kippe' ? `Das ist genau die strengste Bestehensgrenze dieses Programms – ohne jeden ` + `Abstand. Für „${STUFE_WORT.reif}“ wären ` + `${String(benoetigteFragen(SCHWELLE_REIF, fragenGesamt))} nötig.` : `Die strengste Bestehensgrenze dieses Programms verlangt ` + `${String(benoetigteFragen(SCHWELLE_KIPPE, fragenGesamt))}.`; if (deckelnd.length > 0) { const bereiche = deckelnd.join(' und '); const nachsatz = 'Manche Prüfungsstellen lassen dort nur zwei Fehler zu, gleich wie gut der Rest ist.'; /* „Insgesamt reicht das“ nur, wenn es das auch tut. Bis Fassung 0.19.1 stand dieser Satz unbedingt, sobald ein K.-o.-Bereich zurücklag – gemeldet mit „2 von 486 Fragen sitzen belegt. Insgesamt reicht das – aber I.5 liegt zurück.“ Bei 2 von 486 reicht überhaupt nichts. Der Deckel ist nur dann die Nachricht, wenn der Gesamtstand allein genügen würde; sonst ist er ein Zusatz zu einer Einordnung, die ohnehin nicht trägt. Der Grund steht in beiden Fällen vor der Ampelfarbe, nicht dahinter: Wer „prüfungsreif“ läse und den Nachsatz überginge, ginge mit einer Lücke in die Prüfung, die ihn unabhängig vom Rest durchfallen lässt. */ return ohneDeckel === 'reif' ? `${kern} Insgesamt reicht das – aber ${bereiche} liegt zurück. ${nachsatz}` : `${kern} ${einordnung} Dazu liegt ${bereiche} zurück. ${nachsatz}`; } return `${kern} ${einordnung}`; }