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:
+43
-20
@@ -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 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;
|
||||
let tw = null, cas;
|
||||
if (pc === 'wechsel') { tw = Math.random() > 0.5; cas = tw ? 'accusative' : 'dative'; }
|
||||
else cas = pc;
|
||||
else { tw = null; cas = pc; }
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
const r = realizeFrame(prepKey, cas, tw, state);
|
||||
const tbl = lv.artType === 'indefinite' ? INDEF : DEF;
|
||||
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 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)};
|
||||
|
||||
@@ -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 ───────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user