From 04f880f30ccba7e02fef3fe121e98e6289a48800 Mon Sep 17 00:00:00 2001 From: Ivan Schneider Date: Mon, 15 Jun 2026 09:40:14 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(intro):=20d=C3=A9bloque=20l'intro=20des?= =?UTF-8?q?=20derniers=20faits=20(7=C3=979/8=C3=979/9=C3=979)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux verrous empêchaient l'introduction des faits les plus durs — ceux que le seeding par dominance du placement ne peut pas inférer (au-delà du dernier fait réussi : typiquement 7×9, 8×9, 9×9). Résultat : l'image mystère restait incomplète, parfois à vie pour un enfant qui rate la table de 9 (feedback utilisateur). 1. recentlyIntroduced (filtre 48h, sessionComposer) s'appuyait sur history[0].date, qui pour un fait DOMINÉ au placement n'est pas sa date d'intro mais sa 1ʳᵉ révision. Dès que la table de 9 était revue, ses faits passaient pour « introduits récemment » et bloquaient l'intro des faits similaires. On introduit un champ explicite `introducedAt`, posé uniquement à l'écran d'intro réel (App.tsx) ; les faits dominés ne l'ont jamais → ils n'entrent plus dans la fenêtre 48h. Met le code en conformité avec specs §1.2. 2. TAIL_INTRO_THRESHOLD passait l'exception « fin de parcours » à ≤2 restants, en supposant que seuls 8×9/9×9 pouvaient rester bloqués. Le trou de dominance couvre tout le coin difficile (3+ faits). Seuil 2 → 7 (≥29/36 introduits = fin de parcours). Migration : aucune. Les profils existants n'ont pas `introducedAt` → traités comme « non introduits récemment », ce qui débloque immédiatement tout profil coincé (comportement voulu). Tests : régression dédiée (queue introduite même en ratant les ×9), gate ≤7 restants, garde-fou 48h piloté par introducedAt. Specs §3.4bis + changelog mis à jour. Co-Authored-By: Claude Fable 5 --- public/specs/index.html | 2 +- src/App.tsx | 12 ++++++-- src/__tests__/leitner.test.ts | 14 ++++----- src/__tests__/sessionComposer.test.ts | 44 ++++++++++++++++++++++++++- src/lib/changelog.ts | 6 ++++ src/lib/leitner.ts | 13 +++++--- src/lib/sessionComposer.ts | 15 ++++----- src/types.ts | 6 ++++ 8 files changed, 90 insertions(+), 22 deletions(-) diff --git a/public/specs/index.html b/public/specs/index.html index f4a2149e..6be6a54b 100644 --- a/public/specs/index.html +++ b/public/specs/index.html @@ -621,7 +621,7 @@

Stratégies utilisées (par ordre de priorité quand plusieurs s'appliquent)

Faits de base sans stratégie (grille + addition répétée suffisent) : table de 2, 3 × 3.

Rythme d'introduction : Maximum 2 nouveaux faits par séance. Un nouveau fait n'est introduit que si les faits précédemment introduits sont au moins en boîte 2.

-

Exception phase finale : quand il ne reste que ≤ 2 faits à introduire (typiquement 8×9 et 9×9 après le seeding par dominance du placement, qui ne peuvent être inférés par aucun fait du set placement), la règle boîte≥2 est levée. Sinon un seul fait raté en boîte 1 bloquerait indéfiniment l'introduction des derniers faits — alors que l'enfant maîtrise déjà la quasi-totalité, la règle protectrice n'a plus d'utilité.

+

Exception phase finale : quand il ne reste que ≤ 7 faits à introduire (soit ≥ 29/36 introduits), la règle boîte≥2 est levée. Sinon un seul fait raté en boîte 1 bloquerait indéfiniment l'introduction des derniers faits — alors que l'enfant maîtrise déjà la quasi-totalité, la règle protectrice n'a plus d'utilité. Le seuil couvre tout le coin difficile que le seeding par dominance du placement ne peut pas inférer (les faits au-delà du dernier réussi — ex : 7×9, 8×9, 9×9), pas seulement 8×9/9×9.

3.5Récap de séance

Source : Butler (1988), Hattie & Timperley (2007)

diff --git a/src/App.tsx b/src/App.tsx index 9cd40cbb..33868295 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -428,7 +428,12 @@ export default function App() { if (updated.history.length > 0) { updated.history[updated.history.length - 1].answeredWith = answered; } - if (!updated.introduced) updated.introduced = true; + if (!updated.introduced) { + updated.introduced = true; + // Posé par symétrie avec la multiplication ; pas encore lu côté + // division (composeDivisionSession n'a pas de fenêtre 48h, §11.6). + updated.introducedAt = today; + } trackPromotion(getDivisionFactKey(dividend, divisor), current.box, updated.box); return { ...prev, @@ -444,7 +449,10 @@ export default function App() { if (updated.history.length > 0) { updated.history[updated.history.length - 1].answeredWith = answered; } - if (!updated.introduced) updated.introduced = true; + if (!updated.introduced) { + updated.introduced = true; + updated.introducedAt = today; // date d'intro réelle (cf. §1.2) + } trackPromotion(getFactKey(a, b), current.box, updated.box); return { ...prev, diff --git a/src/__tests__/leitner.test.ts b/src/__tests__/leitner.test.ts index 2a71f085..d4f003f9 100644 --- a/src/__tests__/leitner.test.ts +++ b/src/__tests__/leitner.test.ts @@ -26,13 +26,13 @@ describe('shouldIntroduceNew', () => { expect(shouldIntroduceNew(facts)).toBe(false); }); - it('relâche la règle quand il ne reste que ≤ 2 faits à introduire', () => { - // 34 introduits dont 1 en boîte 1, 2 non introduits (typique post-placement) + it('relâche la règle quand il ne reste que ≤ 7 faits à introduire (fin de parcours)', () => { + // 33 introduits dont 1 en boîte 1, 3 non introduits (cas réel : trou de + // dominance du placement = 7×9/8×9/9×9). L'ancien seuil (2) bloquait ici. const facts = createInitialFacts(); - for (let i = 0; i < 34; i++) { + for (let i = 0; i < 33; i++) { facts[i] = intro(facts[i], i === 0 ? 1 : 2); } - // facts[34] et facts[35] restent introduced=false expect(shouldIntroduceNew(facts)).toBe(true); }); @@ -44,12 +44,12 @@ describe('shouldIntroduceNew', () => { expect(shouldIntroduceNew(facts)).toBe(true); }); - it('ne relâche PAS si 3 faits restent à introduire (encore en phase d\'apprentissage)', () => { + it('ne relâche PAS si beaucoup de faits restent à introduire (> 7, début de parcours)', () => { const facts = createInitialFacts(); - for (let i = 0; i < 33; i++) { + for (let i = 0; i < 20; i++) { facts[i] = intro(facts[i], i === 0 ? 1 : 2); } - // 33 introduits dont 1 en boîte 1, 3 non introduits + // 20 introduits dont 1 en boîte 1, 16 non introduits → protection maintenue expect(shouldIntroduceNew(facts)).toBe(false); }); }); diff --git a/src/__tests__/sessionComposer.test.ts b/src/__tests__/sessionComposer.test.ts index 0ae05f4b..b49bbe69 100644 --- a/src/__tests__/sessionComposer.test.ts +++ b/src/__tests__/sessionComposer.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { composeSession } from '../lib/sessionComposer'; import { createInitialFacts, getFactKey } from '../lib/facts'; -import { computeNextDue, addDays } from '../lib/leitner'; +import { computeNextDue, addDays, processAnswer } from '../lib/leitner'; import { PLACEMENT_FACTS, seedFromPlacement, @@ -36,6 +36,7 @@ function introduce( ): void { const fact = facts.find((f) => getFactKey(f.a, f.b) === getFactKey(a, b))!; fact.introduced = true; + fact.introducedAt = introDate; // date d'intro réelle → pilote la fenêtre 48h fact.box = box; fact.lastSeen = lastSeen; fact.nextDue = computeNextDue(box, lastSeen); @@ -147,6 +148,47 @@ describe('composeSession — introduction des derniers faits', () => { .map((q) => getFactKey(q.fact.a, q.fact.b)); expect(introducedKeys).not.toContain(getFactKey(9, 9)); }); + + it('introduit la queue (7×9/8×9/9×9) en quelques séances même si l\'enfant rate la table de 9 (régression feedback Gwennaelle)', () => { + // Profil réel reconstruit : 33 faits introduits par DOMINANCE au placement + // (donc SANS introducedAt), en boîte 2 et dus chaque jour ; 7×9/8×9/9×9 + // jamais introduits (indominables). Avant le fix, deux verrous se cumulaient + // pour bloquer la queue à vie : (a) la table de 9 en révision active passait + // pour « introduite récemment » (history[0].date) et bloquait l'intro des + // faits similaires ; (b) la gate (seuil 2) refusait l'intro avec 3 restants. + const tail = [getFactKey(7, 9), getFactKey(8, 9), getFactKey(9, 9)]; + const facts = createInitialFacts(); + const yesterday = addDays(TODAY, -1); + for (const f of facts) { + if (tail.includes(getFactKey(f.a, f.b))) continue; + f.introduced = true; // dominance placement : volontairement PAS d'introducedAt + f.box = 2; + f.lastSeen = yesterday; + f.nextDue = computeNextDue(2, yesterday); // dus aujourd'hui + } + const profile = profileWith(facts); + + // Rejoue 10 séances ; l'enfant rate systématiquement les faits ×9 (donc + // ils restent en boîte 1, révisés en permanence — le pire cas). + let day = TODAY; + for (let s = 0; s < 10; s++) { + for (const q of composeSession(profile, day)) { + const f = profile.facts.find((x) => x.a === q.fact.a && x.b === q.fact.b)!; + if (q.isIntroduction && !f.introduced) { + f.introduced = true; + f.introducedAt = day; // réplique le comportement de App.tsx + } + const isNine = f.a === 9 || f.b === 9; + Object.assign(f, processAnswer(f, !isNine, isNine ? 4000 : 2000, day, 'keypad')); + } + day = addDays(day, 1); + } + + for (const k of tail) { + const f = profile.facts.find((x) => getFactKey(x.a, x.b) === k)!; + expect(f.introduced, `${k} doit finir introduit`).toBe(true); + } + }); }); describe('composeSession — bonus reviews', () => { diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index 0fef8ccd..63d024e8 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -9,6 +9,12 @@ export interface ChangelogEntry { // CI, lint), seulement ce qui change l'expérience visible côté enfant ou // parent. Garder court et concret. export const CHANGELOG: ChangelogEntry[] = [ + { + date: '2026-06-15', + items: [ + "Image mystère qui ne se complétait jamais entièrement : les faits les plus difficiles (typiquement 7×9, 8×9, 9×9) pouvaient n'être jamais proposés à l'apprentissage, laissant quelques cases définitivement vides en bas de l'image — surtout si l'enfant butait sur la table de 9. Désormais ces derniers faits finissent toujours par être introduits, et l'image peut être dévoilée jusqu'au bout.", + ], + }, { date: '2026-06-11', items: [ diff --git a/src/lib/leitner.ts b/src/lib/leitner.ts index 5065664e..13247364 100644 --- a/src/lib/leitner.ts +++ b/src/lib/leitner.ts @@ -107,12 +107,17 @@ export function processAnswer( // Phase finale : seuil sous lequel on introduit les derniers faits restants // même si certains faits sont en boîte 1. Sans ça, un seul fait raté en -// boîte 1 bloque indéfiniment l'intro des derniers faits (typiquement 8×9 -// et 9×9 après le seeding par dominance du test de placement, qui ne peuvent -// être inférés par aucun fait du set placement). À ce stade, l'enfant +// boîte 1 bloque indéfiniment l'intro des derniers faits après le seeding par +// dominance du test de placement (qui ne peut inférer aucun fait du coin +// difficile non couvert par les réponses correctes). À ce stade, l'enfant // maîtrise déjà la quasi-totalité ; la règle protectrice du début n'a plus // d'utilité. -const TAIL_INTRO_THRESHOLD = 2; +// +// Le trou de dominance n'est pas limité à 8×9/9×9 : il couvre tout le coin +// difficile au-delà du dernier fait réussi au placement (ex : 7×9, 8×9, 9×9 — +// 3 faits). Le seuil englobe ce coin avec marge (≤ 7 restants ⇔ ≥ 29/36 +// introduits = clairement en fin de parcours). +const TAIL_INTRO_THRESHOLD = 7; /** * Returns true if a new fact should be introduced. diff --git a/src/lib/sessionComposer.ts b/src/lib/sessionComposer.ts index 8500be71..7f0d3953 100644 --- a/src/lib/sessionComposer.ts +++ b/src/lib/sessionComposer.ts @@ -80,13 +80,14 @@ export function composeSession(profile: UserProfile, now: string): SessionQuesti // mode tail de shouldIntroduceNew. // // Similarité 48h (specs §1.2) : on espace les *introductions*, pas les - // révisions actives — l'interférence joue à l'apprentissage. Faits sans - // history (dominés au placement) exclus : sinon une table en révision - // active bloquerait à jamais l'intro de ses derniers faits. - const recentlyIntroduced = facts.filter((f) => { - const introDate = f.history[0]?.date; - return f.introduced && introDate && daysBetween(introDate, today) < 2; - }); + // révisions actives — l'interférence joue à l'apprentissage. On s'appuie sur + // `introducedAt`, posé uniquement lors de l'écran d'intro réel. Un fait dominé + // au placement (introduit sans écran) n'a pas d'`introducedAt` → il n'entre + // jamais ici, même une fois révisé : sinon une table en révision active + // bloquerait à jamais l'intro de ses derniers faits (ex 7×9/8×9/9×9). + const recentlyIntroduced = facts.filter( + (f) => f.introducedAt && daysBetween(f.introducedAt, today) < 2, + ); const newFacts: MultiFact[] = []; if (shouldIntroduceNew(facts)) { diff --git a/src/types.ts b/src/types.ts index c892b1cb..d7ec66b7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,6 +16,10 @@ export interface MultiFact { nextDue: string; // ISO date history: Attempt[]; introduced: boolean; // le fait a-t-il été présenté conceptuellement ? + introducedAt?: string; // ISO date de l'écran d'intro réel (≠ 1ʳᵉ révision). + // Absent pour les faits dominés au placement (jamais + // introduits par un écran) → ils ne déclenchent pas + // l'espacement 48h des intros similaires (§1.2). } // === Niveau 2 — division (cf. specs §11) === @@ -31,6 +35,8 @@ export interface DivisionFact { nextDue: string; // ISO date history: Attempt[]; introduced: boolean; + introducedAt?: string; // cf. MultiFact.introducedAt (posé par symétrie, pas + // encore lu : la division n'a pas de fenêtre 48h). } export interface Badge { From 9efad4b275b5094f3a76e5365184fb1131f1ff49 Mon Sep 17 00:00:00 2001 From: Ivan Schneider Date: Mon, 15 Jun 2026 13:18:58 +0200 Subject: [PATCH 2/2] =?UTF-8?q?refactor(intro):=20retire=20introducedAt=20?= =?UTF-8?q?c=C3=B4t=C3=A9=20division=20(YAGNI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le champ était écrit mais jamais lu : composeDivisionSession n'a pas de fenêtre 48h (§11.6), et l'anti-interférence division passe par questionConflict (même dividende). On le garde strictement là où il est lu — la multiplication. Co-Authored-By: Claude Fable 5 --- src/App.tsx | 9 +++------ src/types.ts | 2 -- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 33868295..1ce9bbec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -428,12 +428,9 @@ export default function App() { if (updated.history.length > 0) { updated.history[updated.history.length - 1].answeredWith = answered; } - if (!updated.introduced) { - updated.introduced = true; - // Posé par symétrie avec la multiplication ; pas encore lu côté - // division (composeDivisionSession n'a pas de fenêtre 48h, §11.6). - updated.introducedAt = today; - } + // Pas d'`introducedAt` côté division : pas de fenêtre 48h ici (§11.6), + // l'anti-interférence passe par `questionConflict` (même dividende). + if (!updated.introduced) updated.introduced = true; trackPromotion(getDivisionFactKey(dividend, divisor), current.box, updated.box); return { ...prev, diff --git a/src/types.ts b/src/types.ts index d7ec66b7..e9d14658 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,8 +35,6 @@ export interface DivisionFact { nextDue: string; // ISO date history: Attempt[]; introduced: boolean; - introducedAt?: string; // cf. MultiFact.introducedAt (posé par symétrie, pas - // encore lu : la division n'a pas de fenêtre 48h). } export interface Badge {