Fix ungrammatical ein-word sentences and the a/the translation mismatch

The ein-Wörter level reused sentence frames written for definite articles
and blindly swapped in the ein/eine table, producing non-German like
'trotz eines Wetters' and 'während einer Woche' while the English still
said 'the'.

- Nouns gain a noIndef flag (mass nouns: Wetter, Regen, Musik, Arbeit;
  definite-idiom temporal/kinship nouns: Nacht, Woche, Vater, Mutter,
  Bruder, Schwester, Tochter). They are never paired with an ein-word.
- Frames with a custom English determiner (nach → 'after school',
  seit → 'since that week') are definite idioms and are skipped for
  ein-word questions.
- genArticleQ redraws prepositions whose frames are entirely ruled out
  (trotz has only mass nouns), instead of generating a bad sentence.
- Indefinite translations now use a/an to match the German article;
  definite levels keep 'the'.
- Tests pin all of this down (75 passing).
This commit is contained in:
2026-07-21 07:15:45 +00:00
parent c4c28f46ed
commit 1444814062
2 changed files with 74 additions and 25 deletions
+48 -25
View File
@@ -89,6 +89,9 @@ export function wechselExample(prepKey, isMotion) {
}
// gen: genitive noun form for masculine/neuter (des Mannes, des Hauses)
// noIndef: never combine with ein-words — mass nouns („eines Wetters" is not
// German) and nouns whose frames only read naturally with a definite article
// (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'},
@@ -99,24 +102,24 @@ export const NOUNS = [
{w:'Baum',g:'masculine',en:'tree',gen:'Baumes'},
{w:'Garten',g:'masculine',en:'garden',gen:'Gartens'},
{w:'Stuhl',g:'masculine',en:'chair',gen:'Stuhls'},
{w:'Bruder',g:'masculine',en:'brother',gen:'Bruders'},
{w:'Bruder',g:'masculine',en:'brother',gen:'Bruders',noIndef:true},
{w:'Lehrer',g:'masculine',en:'teacher',gen:'Lehrers'},
{w:'Vater',g:'masculine',en:'father',gen:'Vaters'},
{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'},
{w:'Regen',g:'masculine',en:'rain',gen:'Regens',noIndef:true},
{w:'Frau',g:'feminine',en:'woman'},
{w:'Katze',g:'feminine',en:'cat'},
{w:'Schule',g:'feminine',en:'school'},
{w:'Stadt',g:'feminine',en:'city'},
{w:'Straße',g:'feminine',en:'street'},
{w:'Tür',g:'feminine',en:'door'},
{w:'Schwester',g:'feminine',en:'sister'},
{w:'Mutter',g:'feminine',en:'mother'},
{w:'Tochter',g:'feminine',en:'daughter'},
{w:'Musik',g:'feminine',en:'music'},
{w:'Nacht',g:'feminine',en:'night'},
{w:'Woche',g:'feminine',en:'week'},
{w:'Arbeit',g:'feminine',en:'work'},
{w:'Schwester',g:'feminine',en:'sister',noIndef:true},
{w:'Mutter',g:'feminine',en:'mother',noIndef:true},
{w:'Tochter',g:'feminine',en:'daughter',noIndef:true},
{w:'Musik',g:'feminine',en:'music',noIndef:true},
{w:'Nacht',g:'feminine',en:'night',noIndef:true},
{w:'Woche',g:'feminine',en:'week',noIndef:true},
{w:'Arbeit',g:'feminine',en:'work',noIndef:true},
{w:'Brücke',g:'feminine',en:'bridge'},
{w:'Kind',g:'neuter',en:'child',gen:'Kindes'},
{w:'Haus',g:'neuter',en:'house',gen:'Hauses'},
@@ -128,7 +131,7 @@ export const NOUNS = [
{w:'Museum',g:'neuter',en:'museum',gen:'Museums'},
{w:'Büro',g:'neuter',en:'office',gen:'Büros'},
{w:'Restaurant',g:'neuter',en:'restaurant',gen:'Restaurants'},
{w:'Wetter',g:'neuter',en:'weather',gen:'Wetters'},
{w:'Wetter',g:'neuter',en:'weather',gen:'Wetters',noIndef:true},
{w:'Dach',g:'neuter',en:'roof',gen:'Daches'},
{w:'Konzert',g:'neuter',en:'concert',gen:'Konzerts'},
];
@@ -480,7 +483,7 @@ 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 } = {}) {
function realizeFrame(prepKey, cas, tw, state, { genderFilter, possOnly, indefOnly } = {}) {
let frames = framesFor(prepKey, tw);
if (possOnly) {
frames = frames
@@ -488,6 +491,15 @@ function realizeFrame(prepKey, cas, tw, state, { genderFilter, possOnly } = {})
.map(f => ({...f, nouns: f.nouns.filter(n => POSSESSABLE.has(n))}))
.filter(f => f.nouns.length);
}
if (indefOnly) {
// ein-word questions: drop frames whose English determiner is a baked-in
// 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)
.map(f => ({...f, nouns: f.nouns.filter(n => !NOUN_BY_NAME[n].noIndef)}))
.filter(f => f.nouns.length);
}
if (genderFilter) {
frames = frames
.map(f => ({...f, nouns: f.nouns.filter(n => genderFilter.includes(NOUN_BY_NAME[n].g))}))
@@ -550,24 +562,35 @@ function articleChoices(ans, artType) {
export function genArticleQ(level, state = newState()) {
const lv = LEVELS.find(l=>l.id===level);
const prepKey = drawBag(state, 'prep', lv.preps, state.last.prep);
state.last.prep = prepKey;
const pc = PREPS[prepKey].c;
let tw = null, cas;
if (pc === 'wechsel') { tw = Math.random() > 0.5; cas = tw ? 'accusative' : 'dative'; }
else cas = pc;
const indef = lv.artType === 'indefinite';
// Draw preps until one yields a usable frame. With ein-words, mass nouns and
// definite idioms can rule a preposition out entirely (e.g. trotz, whose
// nouns are all uncountable — „trotz eines Wetters" is not German).
let prepKey, cas, tw = null, r = null;
for (let i = 0; i <= lv.preps.length && !r; i++) {
prepKey = drawBag(state, 'prep', lv.preps, state.last.prep);
const pc = PREPS[prepKey].c;
if (pc === 'wechsel') { tw = Math.random() > 0.5; cas = tw ? 'accusative' : 'dative'; }
else { tw = null; cas = pc; }
// Mixed definite lessons: contraction-capable prep → fused "chunk" question.
if (lv.mixed && lv.artType === 'definite' && CHUNK_PREPS.has(prepKey)) {
return makeChunkQ(prepKey, cas, tw, state);
// Mixed definite lessons: contraction-capable prep → fused "chunk" question.
if (lv.mixed && lv.artType === 'definite' && CHUNK_PREPS.has(prepKey)) {
state.last.prep = prepKey;
return makeChunkQ(prepKey, cas, tw, state);
}
r = realizeFrame(prepKey, cas, tw, state, { indefOnly: indef });
}
if (!r) r = realizeFrame(prepKey, cas, tw, state); // hard fallback; unreachable with current data
state.last.prep = prepKey;
const r = realizeFrame(prepKey, cas, tw, state);
const tbl = lv.artType === 'indefinite' ? INDEF : DEF;
const tbl = indef ? INDEF : DEF;
const ans = tbl[r.noun.g][cas];
// ein-words translate as a/an, never the frame's default "the".
const detEn = indef ? (/^[aeiou]/i.test(r.noun.en) ? 'an' : 'a') : r.enDet;
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} ${enNoun(r, r.enDet)}${r.postEn}.`;
const rule = pc === 'wechsel'
const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, detEn)}${r.postEn}.`;
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)};
+26
View File
@@ -109,6 +109,32 @@ describe('article questions', () => {
const seen = new Set(runSession(3, lv.preps.length).map(q => q.prepKey));
expect([...seen].sort()).toEqual([...lv.preps].sort());
});
it('ein-Wörter never pairs an ein-word with a mass/definite-only noun', () => {
for (const q of runSession(7, 400)) {
expect(q.noun.noIndef, `„ein ${q.noun.w}" should never be generated`).toBeFalsy();
}
});
it('ein-Wörter never uses definite-idiom frames (nach, seit) or all-mass preps (trotz)', () => {
for (const q of runSession(7, 400)) {
expect(['nach', 'seit', 'trotz']).not.toContain(q.prepKey);
}
});
it('ein-Wörter translations say a/an, matching the German article', () => {
for (const q of runSession(7, 400)) {
const usesA = q.translation.includes(` a ${q.noun.en}`) || q.translation.includes(` an ${q.noun.en}`);
expect(usesA, `"${q.translation}" should use a/an for „${q.ans} ${q.noun.w}"`).toBe(true);
expect(q.translation).not.toContain(` the ${q.noun.en}`);
}
});
it('definite levels still translate with "the"', () => {
for (const q of runSession(2, 200)) {
expect(q.translation).toContain(` the ${q.noun.en}`);
}
});
});
// ── Preposition case quiz ───────────────────────────────────