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:
@@ -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