Enumerate every possible sentence, review it, and lock it with a snapshot

"Wir wohnen bei der Schwester. / We live at the sister." was two bugs at
once, and both were the same shape: a template applied to a pairing nobody
had ever read.

Root cause: sentences are built combinatorially (frames x verbs x nouns x
subjects). The space is ~2900 sentences and only a sample had ever been
looked at, so every fix so far has been whack-a-mole on whichever bad one
happened to surface.

The fix is structural, not another special case:

- composeFrame() is now pure, and enumerateCorpus() walks every frame,
  verb, noun and case for every consumer (fill-the-article, ein-Woerter,
  possessive, Wechsel im Kontext, fused). src/corpus.txt is that full
  enumeration, checked in and reviewed.
- Two tests make it foolproof rather than decorative:
    * the snapshot test fails if generation drifts from corpus.txt, so no
      new sentence can reach a learner unreviewed. Regenerate with
      `npm run corpus` and the diff IS the review.
    * a coverage test runs the real generators over every sentence level
      and asserts each question it produces has a key in the corpus — so
      the file is provably the live output, not a parallel implementation.
  Two more assert no un-fused contraction and no German leaking into an
  English line.

Bugs the audit then found and fixed:

- zu/von/bei + a person means "at/to/from their PLACE". English needs the
  -'s ("at the doctor's"), and with a bare definite article German only
  allows roles you visit (beim Arzt), not family — "bei der Schwester" is
  wrong, "bei meiner Schwester" is right, so family frames are now
  possessive-only. Added Aerztin/Lehrerin/Freundin so the role case has a
  non-fusing feminine form.
- "zu dem Arzt", "in dem Haus": a fill-the-article sentence may no longer
  show a two-word form German obligatorily fuses. Those pairings belong to
  the Kontraktionen questions, which already teach them. "zu" therefore
  leaves the Dativ lesson entirely, the way "bis" already had.
- mein Mann / meine Frau mean my husband / my wife, not my man / my woman.
- "mein Maedchen" is not idiomatic and was reachable from the possessive
  templates; the coverage test caught it.
- You cannot drive into a kitchen, a cellar or a library: the "in" motion
  frame is split into drivable places and on-foot-only places.
- "stellt den Stuhl an die Wand" is "against the wall", not "to the wall";
  haengen/schreiben/kleben take "on"; wohnen am See is "by the lake".

Also: questions now carry the verb and case they were built from, the
article redraw is depth-bounded so a data mistake degrades to one odd
question instead of hanging, and level 3's advertised prep list matches
what it can actually generate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Uza6mhTpAvSwMQuFc4Cz8
This commit is contained in:
2026-09-18 07:24:29 +00:00
co-authored by Claude Opus 5
parent 1d5402edc7
commit 0dfc2955db
4 changed files with 3250 additions and 47 deletions
+2 -1
View File
@@ -7,7 +7,8 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview --host", "preview": "vite preview --host",
"test": "vitest run" "test": "vitest run",
"corpus": "node -e \"import('./src/engine.js').then(({enumerateCorpus,corpusToText})=>require('fs').writeFileSync('src/corpus.txt',corpusToText(enumerateCorpus())))\""
}, },
"dependencies": { "dependencies": {
"react": "^18.3.1", "react": "^18.3.1",
+2940
View File
File diff suppressed because it is too large Load Diff
+249 -46
View File
@@ -67,6 +67,10 @@ export const ALL_PREPS = [...COMMON_PREPS, ...RARE_PREPS];
// (bis nächsten Montag / bis zum Ende) — so it stays in the case-ID // (bis nächsten Montag / bis zum Ende) — so it stays in the case-ID
// quizzes but is excluded from fill-the-article sentence levels. // quizzes but is excluded from fill-the-article sentence levels.
const SENTENCE_ACC_PREPS = ['durch','für','gegen','ohne','um']; const SENTENCE_ACC_PREPS = ['durch','für','gegen','ohne','um'];
// "zu" always fuses with a definite article (zum/zur), so a fill-the-article
// sentence can never show it honestly — it lives in the Kontraktionen levels
// and the mixed rounds, which teach the fused form properly.
const SENTENCE_DAT_PREPS = PREPS_BY_CASE.dative.filter(p => p !== 'zu');
export const WECHSEL_EX = { export const WECHSEL_EX = {
an: {noun:'Tür', g:'feminine', en:'door', mot:'to', loc:'at'}, an: {noun:'Tür', g:'feminine', en:'door', mot:'to', loc:'at'},
@@ -94,7 +98,7 @@ export function wechselExample(prepKey, isMotion) {
// (kinship terms, temporal Nacht/Woche: „während einer Woche" is off). // (kinship terms, temporal Nacht/Woche: „während einer Woche" is off).
export const NOUNS = [ export const NOUNS = [
{w:'Hund',g:'masculine',en:'dog',gen:'Hundes'}, {w:'Hund',g:'masculine',en:'dog',gen:'Hundes'},
{w:'Mann',g:'masculine',en:'man',gen:'Mannes'}, {w:'Mann',g:'masculine',en:'man',gen:'Mannes',enPoss:'husband'},
{w:'Tisch',g:'masculine',en:'table',gen:'Tisches'}, {w:'Tisch',g:'masculine',en:'table',gen:'Tisches'},
{w:'Park',g:'masculine',en:'park',gen:'Parks'}, {w:'Park',g:'masculine',en:'park',gen:'Parks'},
{w:'Arzt',g:'masculine',en:'doctor',gen:'Arztes'}, {w:'Arzt',g:'masculine',en:'doctor',gen:'Arztes'},
@@ -107,7 +111,10 @@ export const NOUNS = [
{w:'Vater',g:'masculine',en:'father',gen:'Vaters',noIndef:true}, {w:'Vater',g:'masculine',en:'father',gen:'Vaters',noIndef:true},
{w:'Zug',g:'masculine',en:'train',gen:'Zuges'}, {w:'Zug',g:'masculine',en:'train',gen:'Zuges'},
{w:'Regen',g:'masculine',en:'rain',gen:'Regens',noIndef:true}, {w:'Regen',g:'masculine',en:'rain',gen:'Regens',noIndef:true},
{w:'Frau',g:'feminine',en:'woman'}, {w:'Frau',g:'feminine',en:'woman',enPoss:'wife'},
{w:'Ärztin',g:'feminine',en:'doctor'},
{w:'Lehrerin',g:'feminine',en:'teacher'},
{w:'Freundin',g:'feminine',en:'friend'},
{w:'Katze',g:'feminine',en:'cat'}, {w:'Katze',g:'feminine',en:'cat'},
{w:'Schule',g:'feminine',en:'school'}, {w:'Schule',g:'feminine',en:'school'},
{w:'Stadt',g:'feminine',en:'city'}, {w:'Stadt',g:'feminine',en:'city'},
@@ -166,7 +173,12 @@ const NOUN_BY_NAME = Object.fromEntries(NOUNS.map(n => [n.w, n]));
export const nounForm = (noun, cas) => export const nounForm = (noun, cas) =>
cas === 'genitive' && noun.gen ? noun.gen : noun.w; cas === 'genitive' && noun.gen ? noun.gen : noun.w;
const PERSONS = ['Mann','Arzt','Freund','Bruder','Lehrer','Vater','Frau','Schwester','Mutter','Tochter','Kind','Mädchen']; const PERSONS = ['Mann','Arzt','Freund','Bruder','Lehrer','Vater','Frau','Schwester','Mutter','Tochter','Kind','Mädchen','Ärztin','Lehrerin','Freundin'];
// zu/von/bei + a person means "to/from/at their PLACE". With a bare definite
// article that only sounds right for roles you visit (beim Arzt, zur Ärztin);
// for family German wants a possessive (bei meiner Schwester), so those frames
// are marked possOnly. English always needs the -'s.
const VISITABLE = ['Arzt','Lehrer','Freund','Ärztin','Lehrerin','Freundin'];
const PLACES_IN = ['Park','Garten','Schule','Stadt','Haus','Hotel','Museum','Büro','Restaurant']; const PLACES_IN = ['Park','Garten','Schule','Stadt','Haus','Hotel','Museum','Büro','Restaurant'];
// Nouns that read naturally with a possessive ("my …") // Nouns that read naturally with a possessive ("my …")
@@ -175,6 +187,9 @@ const POSSESSABLE = new Set([
'Büro','Schule','Stadt','Fenster','Tür','Musik', 'Büro','Schule','Stadt','Fenster','Tür','Musik',
'Wohnung','Zimmer','Küche','Tasche','Bett','Sofa','Schrank','Regal','Wand','Bild', 'Wohnung','Zimmer','Küche','Tasche','Bett','Sofa','Schrank','Regal','Wand','Bild',
]); ]);
POSSESSABLE.delete('Mädchen'); // "mein Mädchen" is not idiomatic
// German possessives reinterpret these: mein Mann = my husband, meine Frau = my wife.
export const possNounEn = noun => noun.enPoss || noun.en;
export const PRONOUNS = [ export const PRONOUNS = [
{key:'ich', nom:'ich', acc:'mich', dat:'mir', en:'I', enObj:'me', tag:'1st person', num:'sg'}, {key:'ich', nom:'ich', acc:'mich', dat:'mir', en:'I', enObj:'me', tag:'1st person', num:'sg'},
@@ -280,7 +295,7 @@ const SAFE_SUBJECT_KEYS = ['ich','du','er','wir','ihr'];
export const LEVELS = [ export const LEVELS = [
{id:2, tab:'article', name:'Akkusativ', desc:'durch, für, gegen, ohne, um', cases:['accusative'], preps:SENTENCE_ACC_PREPS, type:'article', artType:'definite'}, {id:2, tab:'article', name:'Akkusativ', desc:'durch, für, gegen, ohne, um', cases:['accusative'], preps:SENTENCE_ACC_PREPS, type:'article', artType:'definite'},
{id:3, tab:'article', name:'Dativ', desc:'mit, von, zu, aus, bei, nach, seit...', cases:['dative'], preps:PREPS_BY_CASE.dative, type:'article', artType:'definite'}, {id:3, tab:'article', name:'Dativ', desc:'mit, von, aus, bei, nach, seit...', cases:['dative'], preps:SENTENCE_DAT_PREPS, type:'article', artType:'definite'},
{id:4, tab:'article', name:'Wechselpräpositionen', desc:'Two-way: motion (Akk) vs location (Dat)', cases:['accusative','dative'], preps:PREPS_BY_CASE.wechsel, type:'article', artType:'definite', twoWay:true}, {id:4, tab:'article', name:'Wechselpräpositionen', desc:'Two-way: motion (Akk) vs location (Dat)', cases:['accusative','dative'], preps:PREPS_BY_CASE.wechsel, type:'article', artType:'definite', twoWay:true},
{id:4.5, tab:'article', name:'Kontraktionen', desc:'Fused forms in context: ins, am, zum…', cases:['accusative','dative'], type:'chunk', artType:'definite'}, {id:4.5, tab:'article', name:'Kontraktionen', desc:'Fused forms in context: ins, am, zum…', cases:['accusative','dative'], type:'chunk', artType:'definite'},
{id:5, tab:'article', name:'Genitiv', desc:'trotz, während, wegen, statt...', cases:['genitive'], preps:PREPS_BY_CASE.genitive, type:'article', artType:'definite'}, {id:5, tab:'article', name:'Genitiv', desc:'trotz, während, wegen, statt...', cases:['genitive'], preps:PREPS_BY_CASE.genitive, type:'article', artType:'definite'},
@@ -333,7 +348,17 @@ export const CONTRACTIONS = [
export const CONTRACTABLE = {}; export const CONTRACTABLE = {};
CONTRACTIONS.filter(c=>c.pref).forEach(c => { (CONTRACTABLE[c.prep] = CONTRACTABLE[c.prep] || {})[c.art] = c.form; }); CONTRACTIONS.filter(c=>c.pref).forEach(c => { (CONTRACTABLE[c.prep] = CONTRACTABLE[c.prep] || {})[c.art] = c.form; });
export const CHUNK_PREPS = new Set(Object.keys(CONTRACTABLE)); // an, in, auf, bei, von, zu export const CHUNK_PREPS = new Set(Object.keys(CONTRACTABLE));
export const ALL_GENDERS = ['masculine','feminine','neuter','plural'];
// Genders whose definite article does NOT fuse with this preposition. German
// all but requires zum/zur/im/ins/am/ans/beim/vom, so a fill-the-article
// sentence must never ask for the two-word form of one of those — it would be
// teaching a phrase no German says. Those pairings live in the fused
// "Kontraktionen" questions instead, which is where they belong.
export function nonFusingGenders(prepKey, cas) {
return ALL_GENDERS.filter(g => !CONTRACTABLE[prepKey]?.[DEF[g][cas]]);
}
// an, in, auf, bei, von, zu
export const WECHSEL_SET = new Set(PREPS_BY_CASE.wechsel); export const WECHSEL_SET = new Set(PREPS_BY_CASE.wechsel);
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
@@ -362,7 +387,10 @@ export const PREP_FRAMES = {
ohne: [{verbs:['kommen','reisen','gehen'], nouns:[...PERSONS,'Hund','Katze','Buch','Auto']}], ohne: [{verbs:['kommen','reisen','gehen'], nouns:[...PERSONS,'Hund','Katze','Buch','Auto']}],
um: [{verbs:['laufen','gehen'], nouns:['Tisch','Haus','Baum','Park','Garten']}], um: [{verbs:['laufen','gehen'], nouns:['Tisch','Haus','Baum','Park','Garten']}],
aus: [{verbs:['kommen','laufen'], nouns:['Haus','Schule','Büro','Museum','Hotel','Stadt','Garten','Restaurant']}], aus: [{verbs:['kommen','laufen'], nouns:['Haus','Schule','Büro','Museum','Hotel','Stadt','Garten','Restaurant']}],
bei: [{verbs:['wohnen','sein','warten'], nouns:PERSONS}], bei: [
{verbs:['wohnen','sein','warten'], nouns:VISITABLE, enPossS:true},
{verbs:['wohnen','sein','warten'], nouns:PERSONS, enPossS:true, possOnly:true},
],
mit: [ mit: [
{verbs:['sprechen'], nouns:PERSONS}, {verbs:['sprechen'], nouns:PERSONS},
{verbs:['spielen'], nouns:['Hund','Katze','Kind']}, {verbs:['spielen'], nouns:['Hund','Katze','Kind']},
@@ -371,8 +399,16 @@ export const PREP_FRAMES = {
nach: [{verbs:['kommen'], nouns:['Schule','Arbeit','Nacht','Woche'], enPrep:'after', enDet:'', poss:false}], nach: [{verbs:['kommen'], nouns:['Schule','Arbeit','Nacht','Woche'], enPrep:'after', enDet:'', poss:false}],
seit: [{verbs:['wohnen'], mid:{de:'hier',en:'here'}, nouns:['Nacht','Woche'], enPrep:'since', enDet:'that', seit: [{verbs:['wohnen'], mid:{de:'hier',en:'here'}, nouns:['Nacht','Woche'], enPrep:'since', enDet:'that',
enVerb:{Er:'has lived',Sie:'has lived',Wir:'have lived',Ich:'have lived'}, poss:false}], enVerb:{Er:'has lived',Sie:'has lived',Wir:'have lived',Ich:'have lived'}, poss:false}],
von: [{verbs:['kommen'], nouns:[...PERSONS,'Arbeit','Schule','Büro']}], von: [
zu: [{verbs:['gehen','fahren'], nouns:[...PERSONS,'Schule','Park','Museum','Büro','Restaurant','Hotel']}], {verbs:['kommen'], nouns:VISITABLE, enPossS:true},
{verbs:['kommen'], nouns:['Arbeit','Schule','Büro']},
{verbs:['kommen'], nouns:PERSONS, enPossS:true, possOnly:true},
],
zu: [
{verbs:['gehen','fahren'], nouns:VISITABLE, enPossS:true},
{verbs:['gehen','fahren'], nouns:['Schule','Park','Museum','Büro','Restaurant','Hotel']},
{verbs:['gehen','fahren'], nouns:PERSONS, enPossS:true, possOnly:true},
],
außer: [{fixedSubj:{de:'Alle',en:'Everyone',vkey:'Wir',enVkey:'Er'}, verbs:['kommen'], nouns:PERSONS, enPrep:'except'}], außer: [{fixedSubj:{de:'Alle',en:'Everyone',vkey:'Wir',enVkey:'Er'}, verbs:['kommen'], nouns:PERSONS, enPrep:'except'}],
gegenüber: [{verbs:['wohnen'], nouns:['Schule','Park','Museum','Hotel','Restaurant']}], gegenüber: [{verbs:['wohnen'], nouns:['Schule','Park','Museum','Hotel','Restaurant']}],
trotz: [{verbs:['schlafen','arbeiten'], nouns:['Musik','Regen','Wetter'], poss:false}], trotz: [{verbs:['schlafen','arbeiten'], nouns:['Musik','Regen','Wetter'], poss:false}],
@@ -397,18 +433,18 @@ export const PREP_FRAMES = {
an: { an: {
motion: [ motion: [
{verbs:['gehen','laufen'], nouns:['Tür','Fenster','Tafel']}, {verbs:['gehen','laufen'], nouns:['Tür','Fenster','Tafel']},
{verbs:['hängen'], mid:{de:'das Bild',en:'the picture'}, nouns:['Wand','Tür']}, {verbs:['hängen'], mid:{de:'das Bild',en:'the picture'}, nouns:['Wand','Tür'], enPrep:'on'},
{verbs:['schreiben'], mid:{de:'das Wort',en:'the word'}, nouns:['Tafel','Wand']}, {verbs:['schreiben'], mid:{de:'das Wort',en:'the word'}, nouns:['Tafel','Wand'], enPrep:'on'},
{verbs:['kleben'], mid:{de:'den Zettel',en:'the note'}, nouns:['Tür','Fenster','Schrank']}, {verbs:['kleben'], mid:{de:'den Zettel',en:'the note'}, nouns:['Tür','Fenster','Schrank'], enPrep:'on'},
{verbs:['stellen'], mid:{de:'den Stuhl',en:'the chair'}, nouns:['Fenster','Wand','Tür']}, {verbs:['stellen'], mid:{de:'den Stuhl',en:'the chair'}, nouns:['Fenster','Wand','Tür'], enPrep:'against'},
{verbs:['fahren'], nouns:['See','Fluss','Meer']}, {verbs:['fahren'], nouns:['See','Fluss','Meer']},
], ],
location: [ location: [
{verbs:['stehen','warten','sitzen'], nouns:['Tür','Fenster','Tafel']}, {verbs:['stehen','warten','sitzen'], nouns:['Tür','Fenster','Tafel']},
{verbs:['hängen'], fixedSubj:{de:'Das Bild',en:'The picture'}, nouns:['Wand','Tür'], enVerb:{Er:'is hanging',Sie:'is hanging',Wir:'are hanging',Ich:'am hanging'}}, {verbs:['hängen'], fixedSubj:{de:'Das Bild',en:'The picture'}, nouns:['Wand','Tür'], enPrep:'on', enVerb:{Er:'is hanging',Sie:'is hanging',Wir:'are hanging',Ich:'am hanging'}},
{verbs:['wohnen','leben'], nouns:['See','Fluss']}, {verbs:['wohnen','leben'], nouns:['See','Fluss'], enPrep:'by'},
{verbs:['arbeiten'], nouns:['Tisch','Fenster']}, {verbs:['arbeiten'], nouns:['Tisch','Fenster']},
{verbs:['spielen','schlafen'], nouns:['See','Fluss','Meer']}, {verbs:['spielen','schlafen'], nouns:['See','Fluss','Meer'], enPrep:'by'},
// ankommen takes DATIVE although you obviously arrived — the an- prefix // ankommen takes DATIVE although you obviously arrived — the an- prefix
// already carries the arrival, so the phrase only locates it. // already carries the arrival, so the phrase only locates it.
{verbs:['ankommen'], nouns:['Bahnhof','Flughafen'], post:{dative:' an', en:''}}, {verbs:['ankommen'], nouns:['Bahnhof','Flughafen'], post:{dative:' an', en:''}},
@@ -450,7 +486,9 @@ export const PREP_FRAMES = {
}, },
in: { in: {
motion: [ motion: [
{verbs:['gehen','fahren','kommen','laufen','rennen'], nouns:[...PLACES_IN,'Kirche','Bibliothek','Supermarkt','Wald','Dorf','Küche','Zimmer','Keller']}, // places you can drive into vs places you only enter on foot
{verbs:['gehen','fahren','kommen'], nouns:['Park','Garten','Stadt','Schule','Haus','Hotel','Museum','Büro','Restaurant','Wald','Dorf']},
{verbs:['gehen','laufen','rennen','kommen'], nouns:['Küche','Zimmer','Keller','Kirche','Bibliothek','Supermarkt','Haus','Schule','Museum','Restaurant','Büro','Garten']},
{verbs:['springen','fallen'], nouns:['See','Fluss','Wasser','Meer']}, {verbs:['springen','fallen'], nouns:['See','Fluss','Wasser','Meer']},
{verbs:['legen'], mid:{de:'das Buch',en:'the book'}, nouns:['Tasche','Schrank','Regal']}, {verbs:['legen'], mid:{de:'das Buch',en:'the book'}, nouns:['Tasche','Schrank','Regal']},
{verbs:['stecken'], mid:{de:'den Schlüssel',en:'the key'}, nouns:['Tasche','Tür','Schrank']}, {verbs:['stecken'], mid:{de:'den Schlüssel',en:'the key'}, nouns:['Tasche','Tür','Schrank']},
@@ -620,8 +658,13 @@ function framesFor(prepKey, tw) {
// Resolve a frame into concrete sentence parts. Returns null if the // Resolve a frame into concrete sentence parts. Returns null if the
// preposition has no usable frame for the given constraints. // preposition has no usable frame for the given constraints.
function realizeFrame(prepKey, cas, tw, state, { genderFilter, possOnly, indefOnly } = {}) { // Which frames are usable for a given preposition and question flavour.
// Pure: no randomness, so the enumerator below sees exactly what players see.
export function usableFrames(prepKey, tw, { genderFilter, possOnly, indefOnly } = {}) {
let frames = framesFor(prepKey, tw); let frames = framesFor(prepKey, tw);
// possOnly frames exist for possessive questions only — "bei meiner Schwester"
// is natural German, "bei der Schwester" is not.
frames = possOnly ? frames : frames.filter(f => !f.possOnly);
if (possOnly) { if (possOnly) {
frames = frames frames = frames
.filter(f => f.poss !== false) .filter(f => f.poss !== false)
@@ -633,7 +676,7 @@ function realizeFrame(prepKey, cas, tw, state, { genderFilter, possOnly, indefOn
// definite idiom (nach der Schule → "after school", seit → "since that…"), // definite idiom (nach der Schule → "after school", seit → "since that…"),
// and drop noIndef nouns — the remaining pairings read naturally with a/an. // and drop noIndef nouns — the remaining pairings read naturally with a/an.
frames = frames frames = frames
.filter(f => f.enDet === undefined) .filter(f => f.enDet === undefined && !f.enPossS)
.map(f => ({...f, nouns: f.nouns.filter(n => !NOUN_BY_NAME[n].noIndef)})) .map(f => ({...f, nouns: f.nouns.filter(n => !NOUN_BY_NAME[n].noIndef)}))
.filter(f => f.nouns.length); .filter(f => f.nouns.length);
} }
@@ -642,16 +685,14 @@ function realizeFrame(prepKey, cas, tw, state, { genderFilter, possOnly, indefOn
.map(f => ({...f, nouns: f.nouns.filter(n => genderFilter.includes(NOUN_BY_NAME[n].g))})) .map(f => ({...f, nouns: f.nouns.filter(n => genderFilter.includes(NOUN_BY_NAME[n].g))}))
.filter(f => f.nouns.length); .filter(f => f.nouns.length);
} }
if (!frames.length) return null; return frames;
}
// Prefer frames that can avoid repeating the previous question's verb. // Turn one fully-chosen combination into sentence parts. Pure — every sentence
const fresh = frames.filter(f => f.verbs.some(v => v !== state.last.verb)); // the app can ever show comes through here, which is what makes the corpus
const frame = pick(fresh.length ? fresh : frames); // snapshot in the tests exhaustive rather than a sample.
const nounName = pickAvoid(frame.nouns, state.last.noun); export function composeFrame(prepKey, cas, tw, frame, verbKey, nounName, subjKey) {
const noun = NOUN_BY_NAME[nounName]; const noun = NOUN_BY_NAME[nounName];
const verbKey = pickAvoid(frame.verbs, state.last.verb);
state.last.noun = nounName;
state.last.verb = verbKey;
let subjDe, subjEn, deVkey, enVkey; let subjDe, subjEn, deVkey, enVkey;
if (frame.fixedSubj) { if (frame.fixedSubj) {
@@ -660,10 +701,8 @@ function realizeFrame(prepKey, cas, tw, state, { genderFilter, possOnly, indefOn
deVkey = frame.fixedSubj.vkey || 'Er'; deVkey = frame.fixedSubj.vkey || 'Er';
enVkey = frame.fixedSubj.enVkey || deVkey; enVkey = frame.fixedSubj.enVkey || deVkey;
} else { } else {
const subj = pickAvoid(['Er','Sie','Wir','Ich'], state.last.subj); subjDe = subjKey; subjEn = SUBJ_EN[subjKey];
state.last.subj = subj; deVkey = subjKey; enVkey = subjKey;
subjDe = subj; subjEn = SUBJ_EN[subj];
deVkey = subj; enVkey = subj;
} }
const p = PREPS[prepKey]; const p = PREPS[prepKey];
@@ -677,11 +716,34 @@ function realizeFrame(prepKey, cas, tw, state, { genderFilter, possOnly, indefOn
const postDe = frame.post ? (frame.post[cas] || '') : ''; const postDe = frame.post ? (frame.post[cas] || '') : '';
const postEn = frame.post ? frame.post.en : ''; const postEn = frame.post ? frame.post.en : '';
const enDet = frame.enDet !== undefined ? frame.enDet : 'the'; const enDet = frame.enDet !== undefined ? frame.enDet : 'the';
// "bei + person" is "at someone's place" in English, never "at the sister".
const enPossS = !!frame.enPossS;
return { noun, verbKey, subjDe, subjEn, verbDe, verbEn, midDe, midEn, enPrep, postDe, postEn, enDet }; return { noun, verbKey, frame, subjDe, subjEn, verbDe, verbEn, midDe, midEn,
enPrep, postDe, postEn, enDet, enPossS };
} }
const enNoun = (r, det) => `${det}${det ? ' ' : ''}${r.noun.en}`; function realizeFrame(prepKey, cas, tw, state, opts = {}) {
const frames = usableFrames(prepKey, tw, opts);
if (!frames.length) return null;
// Prefer frames that can avoid repeating the previous question's verb.
const fresh = frames.filter(f => f.verbs.some(v => v !== state.last.verb));
const frame = pick(fresh.length ? fresh : frames);
const nounName = pickAvoid(frame.nouns, state.last.noun);
const verbKey = pickAvoid(frame.verbs, state.last.verb);
state.last.noun = nounName;
state.last.verb = verbKey;
let subjKey = null;
if (!frame.fixedSubj) {
subjKey = pickAvoid(['Er','Sie','Wir','Ich'], state.last.subj);
state.last.subj = subjKey;
}
return composeFrame(prepKey, cas, tw, frame, verbKey, nounName, subjKey);
}
const enNoun = (r, det, poss = false) => `${det}${det ? ' ' : ''}${poss ? possNounEn(r.noun) : r.noun.en}${r.enPossS ? "'s" : ''}`;
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
// QUESTION GENERATORS // QUESTION GENERATORS
@@ -721,7 +783,7 @@ function pronounChoices(pron, ans) {
return [ans, ...picked].sort(() => Math.random() - 0.5); return [ans, ...picked].sort(() => Math.random() - 0.5);
} }
export function genArticleQ(level, state = newState()) { export function genArticleQ(level, state = newState(), depth = 0) {
const lv = LEVELS.find(l=>l.id===level); const lv = LEVELS.find(l=>l.id===level);
const indef = lv.artType === 'indefinite'; const indef = lv.artType === 'indefinite';
// Draw preps until one yields a usable frame. With ein-words, mass nouns and // Draw preps until one yields a usable frame. With ein-words, mass nouns and
@@ -740,9 +802,17 @@ export function genArticleQ(level, state = newState()) {
return makeChunkQ(prepKey, cas, tw, state); return makeChunkQ(prepKey, cas, tw, state);
} }
r = realizeFrame(prepKey, cas, tw, state, { indefOnly: indef }); // Bare definite article: only genders that don't force a contraction.
r = realizeFrame(prepKey, cas, tw, state, indef
? { indefOnly: true }
: { genderFilter: nonFusingGenders(prepKey, cas) });
}
// No usable pairing for this preposition — draw again. Bounded so a data
// mistake degrades to one odd question instead of hanging the app.
if (!r) {
if (depth >= 8) r = realizeFrame(prepKey, cas, tw, state, {});
else return genArticleQ(level, state, depth + 1);
} }
if (!r) r = realizeFrame(prepKey, cas, tw, state); // hard fallback; unreachable with current data
state.last.prep = prepKey; state.last.prep = prepKey;
const tbl = indef ? INDEF : DEF; const tbl = indef ? INDEF : DEF;
@@ -754,7 +824,7 @@ export function genArticleQ(level, state = newState()) {
const rule = PREPS[prepKey].c === 'wechsel' const rule = PREPS[prepKey].c === 'wechsel'
? (tw ? `„${prepKey}" + motion (wohin?) → Akkusativ` : `„${prepKey}" + location (wo?) → Dativ`) ? (tw ? `„${prepKey}" + motion (wohin?) → Akkusativ` : `„${prepKey}" + location (wo?) → Dativ`)
: `„${prepKey}" → always ${CL[cas]}`; : `„${prepKey}" → always ${CL[cas]}`;
return {type:'article', noun:r.noun, cas, prepKey, ans, sentence, translation, rule, tw, artType:lv.artType, choices:articleChoices(ans, lv.artType)}; return {type:'article', noun:r.noun, cas, prepKey, verb:r.verbKey, ans, sentence, translation, rule, tw, artType:lv.artType, choices:articleChoices(ans, lv.artType)};
} }
export function genPrepQ(level, state = newState()) { export function genPrepQ(level, state = newState()) {
@@ -791,7 +861,7 @@ export function genWechselContextQ(level, state = newState()) {
const fused = CONTRACTABLE[prepKey]?.[art] || null; const fused = CONTRACTABLE[prepKey]?.[art] || null;
const answerSentence = fused ? `${head} ${fused} ${tail}` : `${head} ${prepKey} ${art} ${tail}`; const answerSentence = fused ? `${head} ${fused} ${tail}` : `${head} ${prepKey} ${art} ${tail}`;
const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, r.enDet)}${r.postEn}.`; const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, r.enDet)}${r.postEn}.`;
return {type:'wechselContext', prepKey, ans:cas, isMotion, verb:r.verbKey, noun:r.noun, return {type:'wechselContext', prepKey, ans:cas, cas, isMotion, verb:r.verbKey, noun:r.noun,
article:art, contraction:fused, sentence, answerSentence, translation, nounEn:r.noun.en}; article:art, contraction:fused, sentence, answerSentence, translation, nounEn:r.noun.en};
} }
@@ -825,29 +895,32 @@ export function genPossQ(level, state = newState()) {
state.last.prep = prepKey; state.last.prep = prepKey;
const ans = combinePoss(stem, POSS_ENDINGS[r.noun.g][cas]); const ans = combinePoss(stem, POSS_ENDINGS[r.noun.g][cas]);
const sentence = `${r.subjDe} ${r.verbDe}${r.midDe} ${prepKey} _____ ${nounForm(r.noun, cas)}${r.postDe}.`; const sentence = `${r.subjDe} ${r.verbDe}${r.midDe} ${prepKey} _____ ${nounForm(r.noun, cas)}${r.postDe}.`;
const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${possMeaning} ${r.noun.en}${r.postEn}.`; const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${possMeaning} ${possNounEn(r.noun)}${r.postEn}.`;
const rule = `${stem}- + ${CL[cas]} + ${r.noun.g} → ${ans}`; const rule = `${stem}- + ${CL[cas]} + ${r.noun.g} → ${ans}`;
return {type:'poss', noun:r.noun, stem, possMeaning, cas, ans, sentence, translation, rule, prepKey, tw, choices:possChoices(stem, ans)}; return {type:'poss', noun:r.noun, stem, possMeaning, cas, ans, sentence, translation, rule, prepKey, verb:r.verbKey, tw, choices:possChoices(stem, ans)};
} }
} }
// No preposition: simple object sentence. // No preposition: simple object sentence.
const cas = pick(lv.cases.filter(c => c !== 'genitive')) || lv.cases[0]; const cas = pick(lv.cases.filter(c => c !== 'genitive')) || lv.cases[0];
let noun, sentence, translation; let noun, sentence, translation, tplVerb;
if (cas === 'accusative') { if (cas === 'accusative') {
const t = pick(POSS_ACC_TEMPLATES); const t = pick(POSS_ACC_TEMPLATES);
noun = NOUN_BY_NAME[pickAvoid(t.nouns, state.last.noun)]; tplVerb = t.de;
noun = NOUN_BY_NAME[pickAvoid(t.nouns.filter(n => POSSESSABLE.has(n)), state.last.noun)];
sentence = `Ich ${t.de} _____ ${noun.w}.`; sentence = `Ich ${t.de} _____ ${noun.w}.`;
translation = `I ${t.en} ${possMeaning} ${noun.en}.`; translation = `I ${t.en} ${possMeaning} ${possNounEn(noun)}.`;
} else { } else {
noun = NOUN_BY_NAME[pickAvoid(PERSONS, state.last.noun)]; tplVerb = 'helfen';
noun = NOUN_BY_NAME[pickAvoid(PERSONS.filter(n => POSSESSABLE.has(n)), state.last.noun)];
sentence = `Ich helfe _____ ${noun.w}.`; sentence = `Ich helfe _____ ${noun.w}.`;
translation = `I help ${possMeaning} ${noun.en}.`; translation = `I help ${possMeaning} ${possNounEn(noun)}.`;
} }
state.last.noun = noun.w; state.last.noun = noun.w;
const ans = combinePoss(stem, POSS_ENDINGS[noun.g][cas]); const ans = combinePoss(stem, POSS_ENDINGS[noun.g][cas]);
const rule = `${stem}- + ${CL[cas]} + ${noun.g} → ${ans}`; const rule = `${stem}- + ${CL[cas]} + ${noun.g} → ${ans}`;
return {type:'poss', noun, stem, possMeaning, cas, ans, sentence, translation, rule, prepKey:null, tw:null, choices:possChoices(stem, ans)}; return {type:'poss', noun, stem, possMeaning, cas, ans, sentence, translation, rule,
prepKey:null, verb:tplVerb, tw:null, choices:possChoices(stem, ans)};
} }
export function genPronounQ(level, state = newState()) { export function genPronounQ(level, state = newState()) {
@@ -924,7 +997,7 @@ export function makeChunkQ(prepKey, cas, tw, state = newState(), genderFilter) {
const sentence = `${r.subjDe} ${r.verbDe}${r.midDe} _____ ${nounForm(r.noun, cas)}${r.postDe}.`; const sentence = `${r.subjDe} ${r.verbDe}${r.midDe} _____ ${nounForm(r.noun, cas)}${r.postDe}.`;
const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, r.enDet)}${r.postEn}.`; const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, r.enDet)}${r.postEn}.`;
const pref = contracts ? (CONTRACTIONS.find(x => x.prep === prepKey && x.art === article)?.pref ?? true) : null; const pref = contracts ? (CONTRACTIONS.find(x => x.prep === prepKey && x.art === article)?.pref ?? true) : null;
return { type:'chunk', prepKey, noun:r.noun, cas, ans:correct, choices, sentence, translation, tw, contracts, pref }; return { type:'chunk', prepKey, noun:r.noun, cas, verb:r.verbKey, ans:correct, choices, sentence, translation, tw, contracts, pref };
} }
// Dedicated "Kontraktionen" level: answer ALWAYS contracts (preferred forms only). // Dedicated "Kontraktionen" level: answer ALWAYS contracts (preferred forms only).
@@ -952,6 +1025,136 @@ export function gen(level, state = newState()) {
return genArticleQ(level, state); return genArticleQ(level, state);
} }
// ═══════════════════════════════════════════
// CORPUS ENUMERATION
// Every sentence the engine can ever build, so the whole space can be read
// and approved instead of spot-checked. src/corpus.txt is the approved
// snapshot; engine.test.js fails if generation drifts from it.
// ═══════════════════════════════════════════
const CORPUS_SUBJ = 'Er';
function corpusLine(kind, prepKey, cas, tw, frame, verbKey, nounName, artFn) {
const r = composeFrame(prepKey, cas, tw, frame, verbKey, nounName, CORPUS_SUBJ);
const { art, enDet } = artFn(r, cas);
const de = `${r.subjDe} ${r.verbDe}${r.midDe} ${prepKey} ${art} ${nounForm(r.noun, cas)}${r.postDe}.`;
const en = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, enDet, enDet === 'my')}${r.postEn}.`;
return `${kind.padEnd(6)} ${de}\n ${en}`;
}
// Every case a preposition can govern in a sentence level.
function casesFor(prepKey) {
const c = PREPS[prepKey].c;
return c === 'wechsel' ? [['accusative', true], ['dative', false]] : [[c, null]];
}
function corpusRec(kind, prepKey, cas, tw, frame, verbKey, nounName, artFn) {
const r = composeFrame(prepKey, cas, tw, frame, verbKey, nounName, CORPUS_SUBJ);
const { art, enDet, poss } = artFn(r, cas);
const de = `${r.subjDe} ${r.verbDe}${r.midDe} ${prepKey} ${art} ${nounForm(r.noun, cas)}${r.postDe}.`;
const en = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, enDet, !!poss)}${r.postEn}.`;
return { key: `${kind}|${prepKey}|${cas}|${verbKey}|${nounName}`, de, en };
}
export function enumerateCorpus() {
const out = new Map();
const add = rec => { if (!out.has(rec.key)) out.set(rec.key, rec); };
const artPreps = [...new Set(LEVELS.filter(l => l.type === 'article' && l.preps).flatMap(l => l.preps))];
const wechPreps = PREPS_BY_CASE.wechsel;
for (const prepKey of [...new Set([...artPreps, ...wechPreps])]) {
for (const [cas, tw] of casesFor(prepKey)) {
if (artPreps.includes(prepKey)) {
for (const frame of usableFrames(prepKey, tw, { genderFilter: nonFusingGenders(prepKey, cas) })) {
for (const v of frame.verbs) for (const n of frame.nouns) {
add(corpusRec('ART', prepKey, cas, tw, frame, v, n,
r => ({ art: DEF[r.noun.g][cas], enDet: r.enDet })));
}
}
for (const frame of usableFrames(prepKey, tw, { indefOnly: true })) {
for (const v of frame.verbs) for (const n of frame.nouns) {
add(corpusRec('INDEF', prepKey, cas, tw, frame, v, n,
r => ({ art: INDEF[r.noun.g][cas],
enDet: /^[aeiou]/i.test(r.noun.en) ? 'an' : 'a' })));
}
}
}
if (wechPreps.includes(prepKey)) {
for (const frame of usableFrames(prepKey, tw, {})) {
for (const v of frame.verbs) for (const n of frame.nouns) {
const r = composeFrame(prepKey, cas, tw, frame, v, n, CORPUS_SUBJ);
const art = DEF[r.noun.g][cas];
const fused = CONTRACTABLE[prepKey]?.[art];
const head = `${r.subjDe} ${r.verbDe}${r.midDe}`;
const rest = `${nounForm(r.noun, cas)}${r.postDe}.`;
add({ key: `WECH|${prepKey}|${cas}|${v}|${n}`,
de: fused ? `${head} ${fused} ${rest}` : `${head} ${prepKey} ${art} ${rest}`,
en: `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, r.enDet)}${r.postEn}.` });
}
}
}
for (const frame of usableFrames(prepKey, tw, { possOnly: true })) {
for (const v of frame.verbs) for (const n of frame.nouns) {
add(corpusRec('POSS', prepKey, cas, tw, frame, v, n,
r => ({ art: combinePoss('mein', POSS_ENDINGS[r.noun.g][cas]), enDet: 'my', poss: true })));
}
}
if (CHUNK_PREPS.has(prepKey)) {
for (const frame of usableFrames(prepKey, tw, {})) {
for (const v of frame.verbs) for (const n of frame.nouns) {
const art = DEF[NOUN_BY_NAME[n].g][cas];
const fused = CONTRACTABLE[prepKey]?.[art];
const r = composeFrame(prepKey, cas, tw, frame, v, n, CORPUS_SUBJ);
add({ key: `CHUNK|${prepKey}|${cas}|${v}|${n}`,
de: `${r.subjDe} ${r.verbDe}${r.midDe} ${fused || `${prepKey} ${art}`} ${nounForm(r.noun, cas)}${r.postDe}.`,
en: `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, r.enDet)}${r.postEn}.` });
}
}
}
}
}
// Possessive sentences with no preposition at all.
for (const t of POSS_ACC_TEMPLATES) {
for (const n of t.nouns.filter(x => POSSESSABLE.has(x))) {
const noun = NOUN_BY_NAME[n];
add({ key: `POSSTPL|-|accusative|${t.de}|${n}`,
de: `Ich ${t.de} ${combinePoss('mein', POSS_ENDINGS[noun.g].accusative)} ${noun.w}.`,
en: `I ${t.en} my ${possNounEn(noun)}.` });
}
}
for (const n of PERSONS.filter(x => POSSESSABLE.has(x))) {
const noun = NOUN_BY_NAME[n];
add({ key: `POSSTPL|-|dative|helfen|${n}`,
de: `Ich helfe ${combinePoss('mein', POSS_ENDINGS[noun.g].dative)} ${noun.w}.`,
en: `I help my ${possNounEn(noun)}.` });
}
return [...out.values()].sort((a, b) => a.key.localeCompare(b.key));
}
// The corpus key for a question the app actually produced. One definition,
// used by the coverage test — so the snapshot can't drift into fiction.
export function corpusKeyFor(q) {
if (!q?.noun) return null;
const n = q.noun.w;
if (q.type === 'wechselContext') return `WECH|${q.prepKey}|${q.cas}|${q.verb}|${n}`;
if (q.type === 'chunk') return `CHUNK|${q.prepKey}|${q.cas}|${q.verb}|${n}`;
if (q.type === 'article') return `${q.artType === 'indefinite' ? 'INDEF' : 'ART'}|${q.prepKey}|${q.cas}|${q.verb}|${n}`;
if (q.type === 'poss') return q.prepKey
? `POSS|${q.prepKey}|${q.cas}|${q.verb}|${n}`
: `POSSTPL|-|${q.cas}|${q.verb}|${n}`;
return null;
}
// The reviewed snapshot format: one greppable line per sentence.
export const corpusToText = recs =>
recs.map(r => `${r.key.padEnd(46)} ${r.de.padEnd(58)} ${r.en}`).join('\n') + '\n';
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
// STREAKS // STREAKS
// Local dates (not UTC) so a late-evening session counts as the // Local dates (not UTC) so a late-evening session counts as the
+59
View File
@@ -1,10 +1,12 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { import {
DEF, INDEF, PREPS, PREPS_BY_CASE, COMMON_PREPS, RARE_PREPS, ALL_PREPS, DEF, INDEF, PREPS, PREPS_BY_CASE, COMMON_PREPS, RARE_PREPS, ALL_PREPS,
NOUNS, nounForm, LEVELS, CONTRACTIONS, CONTRACTABLE, CHUNK_PREPS, NOUNS, nounForm, LEVELS, CONTRACTIONS, CONTRACTABLE, CHUNK_PREPS,
POSS_ENDINGS, combinePoss, PRONOUNS, VERBS, PREP_FRAMES, POSS_ENDINGS, combinePoss, PRONOUNS, VERBS, PREP_FRAMES,
createSession, gen, genContractionContextQ, genContractionQ, newState, buildChunkChoices, createSession, gen, genContractionContextQ, genContractionQ, newState, buildChunkChoices,
bumpStreak, currentStreak, weekDots, bumpStreak, currentStreak, weekDots,
enumerateCorpus, corpusToText, corpusKeyFor,
} from './engine.js'; } from './engine.js';
const runSession = (levelId, n) => { const runSession = (levelId, n) => {
@@ -520,3 +522,60 @@ describe('streaks', () => {
expect(currentStreak(null, '2026-07-03')).toBe(0); expect(currentStreak(null, '2026-07-03')).toBe(0);
}); });
}); });
// ── The sentence corpus ─────────────────────────────────────
// The engine builds sentences combinatorially, so most of what it can say has
// never been read by anyone. These two tests close that hole: the whole space
// is enumerated into src/corpus.txt and reviewed, and nothing the app actually
// generates is allowed to fall outside it.
describe('sentence corpus', () => {
const corpusPath = new URL('./corpus.txt', import.meta.url);
const onDisk = readFileSync(corpusPath, 'utf8');
it('matches the reviewed snapshot in src/corpus.txt', () => {
// If this fails, generation changed. Read the diff — every added or edited
// line is a sentence a learner will see — then regenerate with:
// npm run corpus
expect(corpusToText(enumerateCorpus())).toBe(onDisk);
});
it('every sentence the app generates is in the corpus', () => {
const known = new Set(enumerateCorpus().map(r => r.key));
const sentenceLevels = LEVELS
.filter(l => ['article', 'wechselContext', 'poss'].includes(l.type) || l.type === 'chunk')
.map(l => l.id);
const escaped = new Set();
for (const id of sentenceLevels) {
for (const q of runSession(id, 600)) {
const key = corpusKeyFor(q);
if (key && !known.has(key)) escaped.add(`level ${id}: ${key} — ${q.sentence}`);
}
}
expect([...escaped]).toEqual([]);
});
it('never shows a two-word form German would fuse (zu dem Arzt, in dem Haus)', () => {
for (const r of enumerateCorpus()) {
if (r.key.startsWith('CHUNK')) continue; // fused questions own that job
for (const [prep, arts] of Object.entries(CONTRACTABLE)) {
for (const art of Object.keys(arts)) {
expect(r.de.includes(` ${prep} ${art} `), `${r.de} should be "${arts[art]}"`).toBe(false);
}
}
}
});
it('no English translation contains a raw German word or an empty slot', () => {
const germanish = /[äöüßÄÖÜ]|\b(der|die|das|dem|den|des|ein|eine|einem|einen|mein|meine)\b/;
for (const r of enumerateCorpus()) {
expect(germanish.test(r.en), `leaked German in: ${r.en}`).toBe(false);
expect(r.en).not.toContain('undefined');
expect(r.en).not.toContain(' ');
expect(r.de).not.toContain('undefined');
expect(r.de).not.toContain(' ');
expect(r.de.endsWith('.')).toBe(true);
expect(r.en.endsWith('.')).toBe(true);
}
});
});