Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion public/specs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -621,7 +621,7 @@ <h4>Stratégies utilisées (par ordre de priorité quand plusieurs s'appliquent)
</ul>
<p>Faits de base sans stratégie (grille + addition répétée suffisent) : table de 2, 3 × 3.</p>
<p><strong>Rythme d'introduction :</strong> 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.</p>
<p><strong>Exception phase finale :</strong> 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é.</p>
<p><strong>Exception phase finale :</strong> 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.</p>

<h3 id="parcours-recap"><span class="num">3.5</span>Récap de séance</h3>
<p class="source"><strong>Source :</strong> Butler (1988), Hattie &amp; Timperley (2007)</p>
Expand Down
7 changes: 6 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,8 @@ export default function App() {
if (updated.history.length > 0) {
updated.history[updated.history.length - 1].answeredWith = answered;
}
// 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 {
Expand All @@ -444,7 +446,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,
Expand Down
14 changes: 7 additions & 7 deletions src/__tests__/leitner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand All @@ -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);
});
});
44 changes: 43 additions & 1 deletion src/__tests__/sessionComposer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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', () => {
Expand Down
6 changes: 6 additions & 0 deletions src/lib/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
13 changes: 9 additions & 4 deletions src/lib/leitner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,17 @@ export function processAnswer<T extends Schedulable>(

// 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.
Expand Down
15 changes: 8 additions & 7 deletions src/lib/sessionComposer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ===
Expand Down
Loading