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:
+2940
File diff suppressed because it is too large
Load Diff
+249
-46
@@ -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
|
||||
// quizzes but is excluded from fill-the-article sentence levels.
|
||||
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 = {
|
||||
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).
|
||||
export const NOUNS = [
|
||||
{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:'Park',g:'masculine',en:'park',gen:'Parks'},
|
||||
{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:'Zug',g:'masculine',en:'train',gen:'Zuges'},
|
||||
{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:'Schule',g:'feminine',en:'school'},
|
||||
{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) =>
|
||||
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'];
|
||||
|
||||
// Nouns that read naturally with a possessive ("my …")
|
||||
@@ -175,6 +187,9 @@ const POSSESSABLE = new Set([
|
||||
'Büro','Schule','Stadt','Fenster','Tür','Musik',
|
||||
'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 = [
|
||||
{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 = [
|
||||
{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.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'},
|
||||
@@ -333,7 +348,17 @@ export const CONTRACTIONS = [
|
||||
|
||||
export const CONTRACTABLE = {};
|
||||
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);
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -362,7 +387,10 @@ export const PREP_FRAMES = {
|
||||
ohne: [{verbs:['kommen','reisen','gehen'], nouns:[...PERSONS,'Hund','Katze','Buch','Auto']}],
|
||||
um: [{verbs:['laufen','gehen'], nouns:['Tisch','Haus','Baum','Park','Garten']}],
|
||||
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: [
|
||||
{verbs:['sprechen'], nouns:PERSONS},
|
||||
{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}],
|
||||
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}],
|
||||
von: [{verbs:['kommen'], nouns:[...PERSONS,'Arbeit','Schule','Büro']}],
|
||||
zu: [{verbs:['gehen','fahren'], nouns:[...PERSONS,'Schule','Park','Museum','Büro','Restaurant','Hotel']}],
|
||||
von: [
|
||||
{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'}],
|
||||
gegenüber: [{verbs:['wohnen'], nouns:['Schule','Park','Museum','Hotel','Restaurant']}],
|
||||
trotz: [{verbs:['schlafen','arbeiten'], nouns:['Musik','Regen','Wetter'], poss:false}],
|
||||
@@ -397,18 +433,18 @@ export const PREP_FRAMES = {
|
||||
an: {
|
||||
motion: [
|
||||
{verbs:['gehen','laufen'], nouns:['Tür','Fenster','Tafel']},
|
||||
{verbs:['hängen'], mid:{de:'das Bild',en:'the picture'}, nouns:['Wand','Tür']},
|
||||
{verbs:['schreiben'], mid:{de:'das Wort',en:'the word'}, nouns:['Tafel','Wand']},
|
||||
{verbs:['kleben'], mid:{de:'den Zettel',en:'the note'}, nouns:['Tür','Fenster','Schrank']},
|
||||
{verbs:['stellen'], mid:{de:'den Stuhl',en:'the chair'}, nouns:['Fenster','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'], enPrep:'on'},
|
||||
{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'], enPrep:'against'},
|
||||
{verbs:['fahren'], nouns:['See','Fluss','Meer']},
|
||||
],
|
||||
location: [
|
||||
{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:['wohnen','leben'], nouns:['See','Fluss']},
|
||||
{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'], enPrep:'by'},
|
||||
{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
|
||||
// already carries the arrival, so the phrase only locates it.
|
||||
{verbs:['ankommen'], nouns:['Bahnhof','Flughafen'], post:{dative:' an', en:''}},
|
||||
@@ -450,7 +486,9 @@ export const PREP_FRAMES = {
|
||||
},
|
||||
in: {
|
||||
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:['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']},
|
||||
@@ -620,8 +658,13 @@ function framesFor(prepKey, tw) {
|
||||
|
||||
// Resolve a frame into concrete sentence parts. Returns null if the
|
||||
// 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);
|
||||
// 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) {
|
||||
frames = frames
|
||||
.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…"),
|
||||
// and drop noIndef nouns — the remaining pairings read naturally with a/an.
|
||||
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)}))
|
||||
.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))}))
|
||||
.filter(f => f.nouns.length);
|
||||
}
|
||||
if (!frames.length) return null;
|
||||
return frames;
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Turn one fully-chosen combination into sentence parts. Pure — every sentence
|
||||
// the app can ever show comes through here, which is what makes the corpus
|
||||
// snapshot in the tests exhaustive rather than a sample.
|
||||
export function composeFrame(prepKey, cas, tw, frame, verbKey, nounName, subjKey) {
|
||||
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;
|
||||
if (frame.fixedSubj) {
|
||||
@@ -660,10 +701,8 @@ function realizeFrame(prepKey, cas, tw, state, { genderFilter, possOnly, indefOn
|
||||
deVkey = frame.fixedSubj.vkey || 'Er';
|
||||
enVkey = frame.fixedSubj.enVkey || deVkey;
|
||||
} else {
|
||||
const subj = pickAvoid(['Er','Sie','Wir','Ich'], state.last.subj);
|
||||
state.last.subj = subj;
|
||||
subjDe = subj; subjEn = SUBJ_EN[subj];
|
||||
deVkey = subj; enVkey = subj;
|
||||
subjDe = subjKey; subjEn = SUBJ_EN[subjKey];
|
||||
deVkey = subjKey; enVkey = subjKey;
|
||||
}
|
||||
|
||||
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 postEn = frame.post ? frame.post.en : '';
|
||||
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
|
||||
@@ -721,7 +783,7 @@ function pronounChoices(pron, ans) {
|
||||
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 indef = lv.artType === 'indefinite';
|
||||
// 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);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const tbl = indef ? INDEF : DEF;
|
||||
@@ -754,7 +824,7 @@ export function genArticleQ(level, state = newState()) {
|
||||
const rule = PREPS[prepKey].c === 'wechsel'
|
||||
? (tw ? `„${prepKey}" + motion (wohin?) → Akkusativ` : `„${prepKey}" + location (wo?) → Dativ`)
|
||||
: `„${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()) {
|
||||
@@ -791,7 +861,7 @@ export function genWechselContextQ(level, state = newState()) {
|
||||
const fused = CONTRACTABLE[prepKey]?.[art] || null;
|
||||
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}.`;
|
||||
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};
|
||||
}
|
||||
|
||||
@@ -825,29 +895,32 @@ export function genPossQ(level, state = newState()) {
|
||||
state.last.prep = prepKey;
|
||||
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 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}`;
|
||||
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.
|
||||
const cas = pick(lv.cases.filter(c => c !== 'genitive')) || lv.cases[0];
|
||||
let noun, sentence, translation;
|
||||
let noun, sentence, translation, tplVerb;
|
||||
if (cas === 'accusative') {
|
||||
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}.`;
|
||||
translation = `I ${t.en} ${possMeaning} ${noun.en}.`;
|
||||
translation = `I ${t.en} ${possMeaning} ${possNounEn(noun)}.`;
|
||||
} 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}.`;
|
||||
translation = `I help ${possMeaning} ${noun.en}.`;
|
||||
translation = `I help ${possMeaning} ${possNounEn(noun)}.`;
|
||||
}
|
||||
state.last.noun = noun.w;
|
||||
const ans = combinePoss(stem, POSS_ENDINGS[noun.g][cas]);
|
||||
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()) {
|
||||
@@ -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 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;
|
||||
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).
|
||||
@@ -952,6 +1025,136 @@ export function gen(level, state = newState()) {
|
||||
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
|
||||
// Local dates (not UTC) so a late-evening session counts as the
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
DEF, INDEF, PREPS, PREPS_BY_CASE, COMMON_PREPS, RARE_PREPS, ALL_PREPS,
|
||||
NOUNS, nounForm, LEVELS, CONTRACTIONS, CONTRACTABLE, CHUNK_PREPS,
|
||||
POSS_ENDINGS, combinePoss, PRONOUNS, VERBS, PREP_FRAMES,
|
||||
createSession, gen, genContractionContextQ, genContractionQ, newState, buildChunkChoices,
|
||||
bumpStreak, currentStreak, weekDots,
|
||||
enumerateCorpus, corpusToText, corpusKeyFor,
|
||||
} from './engine.js';
|
||||
|
||||
const runSession = (levelId, n) => {
|
||||
@@ -520,3 +522,60 @@ describe('streaks', () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user