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
+59
View File
@@ -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);
}
});
});