From 1d5402edc7607c125969f3f205154b3fc110c7c8 Mon Sep 17 00:00:00 2001 From: George Turkington Date: Thu, 17 Sep 2026 07:05:42 +0000 Subject: [PATCH] Kontraktionen: ask it backwards, as a two-tap word cloud MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "hinter + das = ?" with options am / im / uebers / hinters was no test at all — only one option could start with "hinter", so you never had to know which article was fused in. The whole level was answerable on the prefix. Flipped it. The fused form is given and you take it apart: - The form is shown large ("im"), and you pick the preposition from a word cloud, then the article from a second one. Graded on both parts. - The preposition step is a real question for the truncated forms — im vs am, vom, zum/zur — which are exactly the ones that get mixed up. - The article step is the point: -s means das, -m means dem, -r means der. Distractors are always real articles (das/dem/der/die/den), never whole contraction forms, so elimination on shape no longer works. - Feedback names which half was wrong and states the ending rule, then highlights the article in the definite-article table. - The chosen preposition chip is tappable to change before committing; backspace does the same on desktop. On reveal the line resolves to the correct pair. Tests: the old flaw is pinned shut (nothing on offer is ever a contraction form), both choice sets are checked, and a data test asserts the -s/-m/-r ending predicts the article for all 16 contractions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017Uza6mhTpAvSwMQuFc4Cz8 --- src/App.jsx | 111 ++++++++++++++++++++++++++++++++++++++------- src/engine.js | 19 ++++++-- src/engine.test.js | 52 ++++++++++++++++++++- 3 files changed, 160 insertions(+), 22 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 588178f..87ccfd3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -184,6 +184,7 @@ export default function App() { const [res, setRes] = useState([]); const [chart, setChart] = useState(false); const [rule, setRule] = useState(false); + const [pickPrep, setPickPrep] = useState(null); const [learnStep, setLearnStep] = useState(0); const [st, setSt] = useState({sc:{},tc:0,ta:0,streak:0,ld:null}); const [access, setAccess] = useState(null); @@ -239,7 +240,7 @@ export default function App() { const lvObj = LEVELS.find(l => l.id === id); if (lvObj?.tab) setTab(lvObj.tab); sessionRef.current = createSession(id); - setLv(id); setRes([]); setInp(''); setRev(false); setQ(sessionRef.current.next()); setScr('practice'); + setLv(id); setRes([]); setInp(''); setRev(false); setPickPrep(null); setQ(sessionRef.current.next()); setScr('practice'); }; const openCheckout = plan => { @@ -257,7 +258,9 @@ export default function App() { const doSubmit = useCallback((overrideInput) => { const userAns = (overrideInput ?? inp).trim(); if (!userAns) { setShake(true); setTimeout(()=>setShake(false),400); return; } - const correct = userAns.toLowerCase() === q.ans.toLowerCase(); + // Two-part answers (contractions) grade against "prep art", not the form. + const target = q.answerKey ?? q.ans; + const correct = userAns.toLowerCase() === target.toLowerCase(); if (overrideInput !== undefined) setInp(overrideInput); // Dismiss mobile keyboard so feedback is visible try { iref.current?.blur(); } catch(e){} @@ -274,7 +277,7 @@ export default function App() { const doNext = useCallback(() => { if (res.length >= ROUND) { setScr('summary'); return; } - setInp(''); setRev(false); setOk(false); + setInp(''); setRev(false); setOk(false); setPickPrep(null); if (!sessionRef.current) sessionRef.current = createSession(lv); setQ(sessionRef.current.next()); }, [res, lv]); @@ -288,6 +291,17 @@ export default function App() { if (chart) { if (e.key === 'Escape') setChart(false); return; } if (scr === 'summary' && e.key === 'Enter') { e.preventDefault(); summaryRef.current?.(); return; } if (scr !== 'practice') return; + if (!rev && q?.type === 'contraction') { + const cloud = pickPrep === null ? q.prepChoices : q.artChoices; + const idx = ['1','2','3','4','5','6'].indexOf(e.key); + if (idx >= 0 && cloud[idx]) { + e.preventDefault(); + if (pickPrep === null) setPickPrep(cloud[idx]); + else submitRef.current(`${pickPrep} ${cloud[idx]}`); + return; + } + if (e.key === 'Backspace' && pickPrep !== null) { e.preventDefault(); setPickPrep(null); return; } + } if (!rev && q?.choices) { const idx = ['1','2','3','4'].indexOf(e.key); if (idx >= 0 && q.choices[idx]) { e.preventDefault(); submitRef.current(q.choices[idx]); return; } @@ -691,18 +705,47 @@ export default function App() { )} - {/* PRACTICE: contractions */} + {/* PRACTICE: contractions — shown fused, taken apart in two taps */} {scr === 'practice' && q?.type === 'contraction' && ( -
-
Contract the preposition and article
-
- {q.prep} + {q.art} = ? +
+
What is this a contraction of?
+
+ {q.ans}
-
+ +
+ {rev + ? + {q.prep} + {q.art} + + : pickPrep + ? <> + + + + ? + + : ? + ?} +
+ +
+ + {!rev ? ( - +
+
+ {pickPrep ? 'Now the article' : 'Which preposition?'} +
+ { if (pickPrep === null) setPickPrep(w); else doSubmit(`${pickPrep} ${w}`); }} + /> + {pickPrep && !IS_TOUCH && ( +
+ backspace to change the preposition +
+ )} +
) : quickTick ? ( ) : ( @@ -967,6 +1010,22 @@ function AnswerGrid({ choices, onPick }) { ); } +// A loose cloud of word chips. Sizes vary a little so it reads as a cloud +// rather than a list, but every chip keeps a full-height tap target. +function WordCloud({ words, onPick }) { + return ( +
+ {words.map((w, i) => ( + + ))} +
+ ); +} + // One-tap correct answer: no solution shown, just a tick before auto-skip. function QuickTick() { return ( @@ -1239,16 +1298,29 @@ function ChunkFeedback({ q, inp, ok }) { } function ContractionFeedback({ q, inp, ok }) { + const [gotPrep, gotArt] = (inp || '').split(' '); + const ending = q.ans.slice(-1); + const endingRule = {s:'das', m:'dem', r:'der'}[ending]; return (
{ok - ? <>✓ {q.prep} + {q.art} = {q.ans} - : <>✗ {inp} → {q.ans}} -
-
- {q.prep} + {q.art} = {q.ans} + ? <>✓ {q.ans} = {q.prep} + {q.art} + : <>✗ {gotPrep} + {gotArt} → {q.prep} + {q.art}}
+ {!ok && ( +
+ {q.ans} = {q.prep} + {q.art} + {gotPrep !== q.prep && <> — the preposition is {q.prep}, not {gotPrep}.} +
+ )} + {endingRule && ( +
+ Ending -{ending} always means{' '} + {endingRule}. That is the whole trick: + -s → das, -m → dem, -r → der. +
+ )}
{q.pref ? 'Preferred — use this contraction in both speech and writing.' @@ -1261,7 +1333,7 @@ function ContractionFeedback({ q, inp, ok }) { valueFor={(g, c) => DEF[g][c]} focusG={null} isCorrect={(g, c, val) => normAns(val) === normAns(q.art)} - isWrong={null} + isWrong={ok ? null : (g, c, val) => !!gotArt && normAns(val) === normAns(gotArt)} />
); @@ -1561,6 +1633,11 @@ const S = { choiceBtn: { flex:'1 1 auto', minWidth:90, padding:'14px 16px', borderRadius:10, border:'1.5px solid', background:'#fff', color:'#1a1a1a', cursor:'pointer', fontSize:14, fontFamily:'inherit', textAlign:'center', transition:'all 0.12s', ...(IS_TOUCH ? {minHeight:66, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center'} : {}) }, + cloud: { display:'flex', flexWrap:'wrap', gap:10, justifyContent:'center', alignItems:'center', maxWidth:360, margin:'0 auto' }, + cloudChip: { position:'relative', padding:'14px 20px', borderRadius:999, border:'1.5px solid #ddd', background:'#fff', color:'#1a1a1a', fontWeight:700, cursor:'pointer', fontFamily:'inherit', minHeight:52, transition:'all 0.12s' }, + cloudKbd: { position:'absolute', top:4, right:8, fontSize:9, color:'#ccc', fontWeight:600 }, + cloudStep: { fontSize:11, fontWeight:700, textTransform:'uppercase', letterSpacing:0.8, color:'#bbb', marginBottom:12 }, + chosenChip: { padding:'6px 16px', borderRadius:999, border:`1.5px solid ${GREEN}`, background:'#f3ffe8', color:GREEN, fontSize:20, fontWeight:700, cursor:'pointer', fontFamily:'inherit' }, ruleBtn: { marginLeft:8, background:'none', border:'1px solid #ddd', borderRadius:999, padding:'3px 10px', color:'#888', fontSize:11, fontWeight:600, cursor:'pointer', fontFamily:'inherit' }, ruleLead: { fontSize:13, color:'#444', lineHeight:1.6, marginBottom:14 }, ruleCard: { background:'#fafafa', borderRadius:8, padding:'12px 14px', marginBottom:10 }, diff --git a/src/engine.js b/src/engine.js index 8343801..f4538ee 100644 --- a/src/engine.js +++ b/src/engine.js @@ -688,10 +688,10 @@ const enNoun = (r, det) => `${det}${det ? ' ' : ''}${r.noun.en}`; // ═══════════════════════════════════════════ // The answer plus 3 distractors drawn from a pool, shuffled. -function pickChoices(ans, pool) { +function pickChoices(ans, pool, n = 4) { const rem = [...new Set(pool)].filter(a => a !== ans); const picked = []; - while (picked.length < 3 && rem.length) picked.push(rem.splice(Math.floor(Math.random()*rem.length), 1)[0]); + while (picked.length < n - 1 && rem.length) picked.push(rem.splice(Math.floor(Math.random()*rem.length), 1)[0]); return [ans, ...picked].sort(() => Math.random() - 0.5); } @@ -876,12 +876,25 @@ export function genPronounQ(level, state = newState()) { return {type:'pronoun', pronKey, pron, cas, ans, sentence, translation, choices:pronounChoices(pron, ans)}; } +// Every preposition that fuses, and every article that shows up inside a +// contraction plus two that never do — so "which article is in there?" can't be +// answered by elimination. +export const CONTRACTION_PREPS = [...new Set(CONTRACTIONS.map(c => c.prep))]; +const CONTRACTION_ARTS = ['das','dem','der','die','den']; + +// Asked the other way round: the fused form is GIVEN and you take it apart. +// Asking "hinter + das = ?" was no test at all — only one option could start +// with "hinter". Going backwards, the real question survives: -s is das, -m is +// dem, -r is der, and am/ans, im/ins, zum/zur are the pairs that actually cost +// you marks. export function genContractionQ(state = newState()) { const form = drawBag(state, 'contr', CONTRACTIONS.map(c=>c.form), state.last.contr); state.last.contr = form; const c = CONTRACTIONS.find(x => x.form === form); return {type:'contraction', prep:c.prep, art:c.art, ans:c.form, pref:c.pref, cas:c.c, - choices:pickChoices(c.form, CONTRACTIONS.map(x=>x.form))}; + answerKey: `${c.prep} ${c.art}`, + prepChoices: pickChoices(c.prep, CONTRACTION_PREPS, 6), + artChoices: pickChoices(c.art, CONTRACTION_ARTS, 4)}; } // Build the 4 choices for a fused prep+article question: always a BLEND of diff --git a/src/engine.test.js b/src/engine.test.js index 274651e..0bd87bf 100644 --- a/src/engine.test.js +++ b/src/engine.test.js @@ -3,7 +3,7 @@ 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, newState, buildChunkChoices, PREPS_BY_CASE as _PBC, + createSession, gen, genContractionContextQ, genContractionQ, newState, buildChunkChoices, bumpStreak, currentStreak, weekDots, } from './engine.js'; @@ -278,6 +278,54 @@ describe('contractions', () => { expect(correct).toBe('an die'); }); + it('Kontraktionen is asked backwards: the fused form is given, you take it apart', () => { + const state = newState(); + const seenForms = new Set(); + for (let i = 0; i < 300; i++) { + const q = genContractionQ(state); + const c = CONTRACTIONS.find(x => x.form === q.ans); + expect(c, `${q.ans} is a real contraction`).toBeTruthy(); + seenForms.add(q.ans); + + // the question is graded on the two parts, not the form + expect(q.answerKey).toBe(`${c.prep} ${c.art}`); + + expect(q.prepChoices).toContain(c.prep); + expect(q.prepChoices.length).toBe(6); + expect(new Set(q.prepChoices).size).toBe(6); + + expect(q.artChoices).toContain(c.art); + expect(q.artChoices.length).toBe(4); + expect(new Set(q.artChoices).size).toBe(4); + + // the old flaw: offering whole contraction forms meant only one option + // could start with the given preposition. Nothing on offer is a form now. + const forms = new Set(CONTRACTIONS.map(x => x.form)); + for (const w of [...q.prepChoices, ...q.artChoices]) expect(forms.has(w)).toBe(false); + } + expect(seenForms.size).toBe(CONTRACTIONS.length); + }); + + it('the article step is a real discrimination, never answerable by elimination', () => { + const state = newState(); + for (let i = 0; i < 300; i++) { + const q = genContractionQ(state); + // at least one wrong article that a careless learner would plausibly pick + const wrong = q.artChoices.filter(a => a !== q.art); + expect(wrong.length).toBe(3); + expect(wrong.every(a => ['das','dem','der','die','den'].includes(a))).toBe(true); + } + }); + + it('the -s/-m/-r ending always predicts the article', () => { + const byEnding = { s:'das', m:'dem', r:'der' }; + for (const c of CONTRACTIONS) { + const expected = byEnding[c.form.slice(-1)]; + expect(expected, `${c.form} has an unexpected ending`).toBeTruthy(); + expect(c.art, `${c.form} = ${c.prep} + ${c.art}`).toBe(expected); + } + }); + it('dedicated Kontraktionen level always yields a preferred contraction', () => { const state = newState(); for (let i = 0; i < 200; i++) { @@ -339,7 +387,7 @@ describe('pronoun questions', () => { }); describe('one-tap choices on every question type', () => { - it.each([11, 12, 13, 14, 15, 16, 17, 19])('level %s: 4 unique choices containing the answer', (id) => { + it.each([11, 12, 13, 14, 15, 16, 17])('level %s: 4 unique choices containing the answer', (id) => { for (const q of runSession(id, 200)) { expect(q.choices.length).toBe(4); expect(new Set(q.choices).size).toBe(4);