From 99294900a00b44e02cfb402bdefee889c597db27 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Thu, 12 Nov 2026 02:47:38 +0100 Subject: [PATCH] =?UTF-8?q?Prompt=20d'un=20coup=20:=20le=20classement=20de?= =?UTF-8?q?=20l'analyse,=20au=20niveau=20calcul=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le classement du conseil vient de l'analyse du coup joué (conseils en version 2) : mêmes équités que la feuille du maître et l'analyse. Le titre dit le niveau du meilleur coup ; une ligne plus basse le dit aussi. Co-Authored-By: Claude Opus 5.5 --- app/src/domain/analysis/analyse.ts | 7 +- app/src/domain/analysis/numbers.ts | 37 ++++++-- app/src/domain/game/advice.ts | 10 +- app/src/domain/game/engine.ts | 17 ++-- app/src/domain/prompts/prompts.ts | 23 ++++- app/src/engine/wasm-engine.ts | 10 +- app/src/ui/advice-texts.ts | 4 +- app/src/ui/advice-view.ts | 4 +- app/tests/engine-analysis.test.ts | 29 ++++++ app/tests/game.test.ts | 1 - app/tests/prompts.test.ts | 92 ++++++++++++++++++- ...007-architecture-et-conventions-de-code.md | 18 ++++ docs/prompts/README.md | 2 +- docs/prompts/coup.md | 10 +- 14 files changed, 216 insertions(+), 48 deletions(-) diff --git a/app/src/domain/analysis/analyse.ts b/app/src/domain/analysis/analyse.ts index 665249b..3f563b8 100644 --- a/app/src/domain/analysis/analyse.ts +++ b/app/src/domain/analysis/analyse.ts @@ -102,10 +102,9 @@ export async function analyseDecision( if (analysis === null) { return null; } - const ranking = - options.ranking === false - ? [] - : await engine.rankMoves(before.position, cube, before.roll, ply); + // Le classement est celui de l'analyse même : mêmes coups, mêmes + // niveaux et mêmes équités que le coup joué et le meilleur coup. + const ranking = options.ranking === false ? [] : analysis.ranking; const start = await engine.board(before.position, cube); const facts = async (move: EvaluatedMove) => factsOf(start.onRoll, start.opponent, await engine.board(move.position, cube)); diff --git a/app/src/domain/analysis/numbers.ts b/app/src/domain/analysis/numbers.ts index 8c29335..3798562 100644 --- a/app/src/domain/analysis/numbers.ts +++ b/app/src/domain/analysis/numbers.ts @@ -7,6 +7,7 @@ // Licence : GPLv3 ou ultérieure (voir LICENSE). import type { AdvisedMove, MoveAdvice } from "../game/advice.js"; +import type { Ply } from "../game/engine.js"; /** Un nombre à la française, signé, à trois décimales : « −0,123 ». */ export function formatEquity(value: number): string { @@ -34,18 +35,21 @@ export interface RankingRow { /** * Le classement d'un conseil tel que l'affiche la feuille du maître : les - * coups du classement du moteur, chacun avec son écart par rapport au - * premier ; puis le coup joué, s'il n'y figure pas, avec l'écart de - * l'analyse du coup joué. + * coups du classement de l'analyse, chacun avec son écart par rapport au + * premier ; puis le coup joué, s'il n'y figure pas. Le coup joué a toujours + * l'écart de l'analyse (equityLoss), celui que dit le maître. */ export function rankingRows(advice: MoveAdvice): RankingRow[] { const first = advice.ranking[0]?.equity ?? advice.best.equity; - const rows = advice.ranking.map((move, i) => ({ - move, - rank: i + 1, - loss: move.equity - first, - played: move.notation === advice.played.notation, - })); + const rows = advice.ranking.map((move, i) => { + const played = move.notation === advice.played.notation; + return { + move, + rank: i + 1, + loss: played ? advice.equityLoss : move.equity - first, + played, + }; + }); if (!rows.some((row) => row.played)) { rows.push({ move: advice.played, @@ -56,3 +60,18 @@ export function rankingRows(advice: MoveAdvice): RankingRow[] { } return rows; } + +/** + * Le niveau du classement d'un conseil : celui auquel le moteur a évalué le + * meilleur coup. C'est le niveau demandé (advice.ply), sauf quand gnubg + * s'arrête avant, parce qu'un seul coup a passé ses filtres : tout le + * classement reste alors au niveau inférieur. + */ +export function rankingPly(advice: MoveAdvice): Ply { + return advice.best.ply; +} + +/** « 1 pli », « 2 plis » : gnubg compte les plis à partir de 0. */ +export function pliesText(ply: Ply): string { + return `${ply} ${ply > 1 ? "plis" : "pli"}`; +} diff --git a/app/src/domain/game/advice.ts b/app/src/domain/game/advice.ts index c9a876c..8eb96d2 100644 --- a/app/src/domain/game/advice.ts +++ b/app/src/domain/game/advice.ts @@ -15,8 +15,10 @@ import type { HalfMove, PositionId } from "./position.js"; import type { Roll } from "./roll.js"; /** Version du format d'un conseil enregistré. Un conseil d'une autre - * version est ignoré à la reprise (il peut être recalculé). */ -export const ADVICE_VERSION = 1; + * version est ignoré à la reprise (il peut être recalculé). Version 2 : le + * classement des coups vient de l'analyse du coup joué, et non plus d'un + * second classement comme la commande « hint » de gnubg. */ +export const ADVICE_VERSION = 2; /** * Le conseil du maître sur une décision du joueur : un coup ou une décision @@ -66,8 +68,8 @@ export interface MoveAdvice { readonly best: AdvisedMove; readonly playedFacts: MoveFacts; readonly bestFacts: MoveFacts; - /** Les meilleurs coups du classement du moteur, du meilleur au moins - * bon (cinq au plus). */ + /** Les meilleurs coups du classement de l'analyse, du meilleur au + * moins bon (cinq au plus), chacun au niveau où le moteur l'a évalué. */ readonly ranking: readonly AdvisedMove[]; /** Équité du coup joué moins celle du meilleur : 0 ou négative. */ readonly equityLoss: number; diff --git a/app/src/domain/game/engine.ts b/app/src/domain/game/engine.ts index e8429e1..b667d17 100644 --- a/app/src/domain/game/engine.ts +++ b/app/src/domain/game/engine.ts @@ -80,17 +80,6 @@ export interface Engine { ply: Ply, ): Promise; - /** Les coups légaux d'un jet classés du meilleur au moins bon, au - * niveau ply, comme la commande « hint » de gnubg (les coups écartés - * par ses filtres gardent l'évaluation d'un niveau inférieur, voir leur - * ply). */ - rankMoves( - position: PositionId, - cube: EngineCube, - roll: Roll, - ply: Ply, - ): Promise; - /** L'analyse d'une décision de videau, position vue du joueur au trait * (celui qui double ou non ; take et pass sont la réponse de son * adversaire). */ @@ -222,6 +211,12 @@ export interface MoveAnalysis { /** Équité du coup joué moins celle du meilleur : 0 ou négative. */ readonly equityLoss: number; readonly skill: Skill; + /** Le classement de l'analyse, du meilleur au moins bon (dix coups au + * plus) : la liste de l'analyse de partie de gnubg, chaque coup au + * niveau où gnubg l'a évalué (ply). Les coups écartés par ses filtres + * gardent l'évaluation d'un niveau inférieur ; le meilleur coup et le + * coup joué y ont les évaluations de best et played. */ + readonly ranking: readonly EvaluatedMove[]; } /** Décision de videau : doubler ou non (joueur au trait), prendre ou passer diff --git a/app/src/domain/prompts/prompts.ts b/app/src/domain/prompts/prompts.ts index 15f82c6..881bca0 100644 --- a/app/src/domain/prompts/prompts.ts +++ b/app/src/domain/prompts/prompts.ts @@ -20,7 +20,13 @@ import { type ReviewedDecision, summarizeAnalysis, } from "../analysis/game-analysis.js"; -import { formatEquity, formatPercent, rankingRows } from "../analysis/numbers.js"; +import { + formatEquity, + formatPercent, + pliesText, + rankingPly, + rankingRows, +} from "../analysis/numbers.js"; import { diceWords, type MasterPhrases, masterWords } from "../analysis/phrases.js"; import { type Advice, @@ -83,7 +89,7 @@ const plural = (n: number, one: string, many: string) => `${n} ${n > 1 ? many : /** « analyse à 2 plis ». */ export function plyText(ply: Ply): string { - return `analyse à ${ply} ${ply > 1 ? "plis" : "pli"}`; + return `analyse à ${pliesText(ply)}`; } /** Nombre de décisions analysées à partir duquel le niveau du joueur se @@ -146,8 +152,14 @@ export function decisionPrompt( niveau_eval: plyText(data.advice.ply), appreciation: masterWords(phrases, data.advice).word, }; + // Le niveau d'un classement de coups : celui de son meilleur coup, que + // le moteur a pu évaluer moins profondément que demandé. return data.advice.kind === "move" - ? fillPrompt(templates.move, { ...common, ...moveValues(data.advice) }) + ? fillPrompt(templates.move, { + ...common, + niveau_eval: plyText(rankingPly(data.advice)), + ...moveValues(data.advice), + }) : fillPrompt(templates.cube, { ...common, ...cubeValues(data.advice) }); } @@ -163,7 +175,10 @@ function moveValues(advice: MoveAdvice): Record { // de « Voir les chiffres du moteur », avec les mêmes chiffres. const rows = rankingRows(advice).filter((row) => row.rank <= 3 || row.played); const ranking = rows.map((row) => { - const ply = row.move.ply === advice.ply ? "" : `, évalué à ${row.move.ply} pli`; + // Un coup écarté par les filtres du moteur garde l'évaluation d'un + // niveau inférieur à celui du classement : la ligne le dit. + const ply = + row.move.ply === rankingPly(advice) ? "" : `, évalué à ${pliesText(row.move.ply)}`; const mine = row.played ? " (mon coup)" : ""; return `${row.rank}. ${row.move.notation}${mine} : équité ${formatEquity(row.move.equity)}, écart ${formatEquity(row.loss)}${ply}`; }); diff --git a/app/src/engine/wasm-engine.ts b/app/src/engine/wasm-engine.ts index 58e73f1..7712aec 100644 --- a/app/src/engine/wasm-engine.ts +++ b/app/src/engine/wasm-engine.ts @@ -182,6 +182,7 @@ export function createWasmEngine(run: RunCommand): Engine { best: CliEvaluatedMove | null; equity_loss: number; skill: CliSkill; + ranking: CliEvaluatedMove[]; }; if (answer.rank === null || answer.played === null || answer.best === null) { return null; @@ -193,17 +194,10 @@ export function createWasmEngine(run: RunCommand): Engine { best: toEvaluatedMove(answer.best), equityLoss: answer.equity_loss, skill: toSkill(answer.skill), + ranking: answer.ranking.map(toEvaluatedMove), }; }, - async rankMoves(position: PositionId, cube: EngineCube, roll: Roll, ply: Ply) { - const args = ["--ply", String(ply), "moves", position, await matchId(cube)]; - const answer = (await run([...args, diceText(roll)])) as { - moves: CliEvaluatedMove[]; - }; - return answer.moves.map(toEvaluatedMove); - }, - async analyseCube( position: PositionId, cube: EngineCube, diff --git a/app/src/ui/advice-texts.ts b/app/src/ui/advice-texts.ts index cc309de..dac6fc0 100644 --- a/app/src/ui/advice-texts.ts +++ b/app/src/ui/advice-texts.ts @@ -26,8 +26,8 @@ export const ADVICE_TEXTS = { replay: "Rejouer le coup", numbers: "Voir les chiffres du moteur", you: "(vous)", - moveNote: (ply: number) => - `Écart d'équité à ${ply} plis, par rapport au meilleur coup. Un écart de 0,03 ou moins reste un bon coup.`, + moveNote: (plies: string) => + `Écart d'équité à ${plies}, par rapport au meilleur coup. Un écart de 0,03 ou moins reste un bon coup.`, cubeNote: (ply: number) => `Équités de celui qui double, pour un videau de 1, à ${ply} plis. Écart de votre décision : `, cubeRows: { diff --git a/app/src/ui/advice-view.ts b/app/src/ui/advice-view.ts index c546657..56656e9 100644 --- a/app/src/ui/advice-view.ts +++ b/app/src/ui/advice-view.ts @@ -7,7 +7,7 @@ // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). -import { rankingRows } from "../domain/analysis/numbers.js"; +import { pliesText, rankingPly, rankingRows } from "../domain/analysis/numbers.js"; import { type MasterPhrases, masterWords } from "../domain/analysis/phrases.js"; import { type Advice, @@ -304,7 +304,7 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView { } element(details, "p", { className: "explain-note", - text: ADVICE_TEXTS.moveNote(advice.ply), + text: ADVICE_TEXTS.moveNote(pliesText(rankingPly(advice))), }); } else { // Les lignes de votre décision, en gras : doubler mène à l'une des diff --git a/app/tests/engine-analysis.test.ts b/app/tests/engine-analysis.test.ts index ddb1241..02f76f5 100644 --- a/app/tests/engine-analysis.test.ts +++ b/app/tests/engine-analysis.test.ts @@ -35,6 +35,35 @@ test("ouverture 3-1 : 24/23 13/10 est une erreur très mauvaise, 8/5 6/5 le meil Math.abs(analysis.equityLoss - (analysis.played.equity - analysis.best.equity)) < 1e-6, ); assert.ok(analysis.equityLoss < -SKILL_THRESHOLDS.veryBad); + // Le classement de l'analyse, comme gnubg de bureau + // (tests/reference/analysis/move-opening-31-error.json) : le meilleur + // coup et le coup joué, une erreur, réévalués à 2 plis ; les suivants + // restés à 1 pli, puis à 0, écartés par les filtres. + assert.deepEqual(analysis.ranking[0], analysis.best); + assert.deepEqual(analysis.ranking[1], analysis.played); + assert.deepEqual( + analysis.ranking.map((move) => move.ply), + [2, 2, 1, 1, 1, 0, 0, 0, 0, 0], + ); +}); + +test("ouverture 3-1 jouée 8/5 6/5 : un seul coup passe le filtre, tout reste à 1 pli", async () => { + const engine = await loadNodeEngine(); + const analysis = await engine.analyseMove( + START_POSITION, + centered, + [3, 1], + "4HPwATCwZ/ABMA", + 2, + ); + assert.ok(analysis !== null); + assert.equal(analysis.rank, 1); + assert.deepEqual(analysis.ranking[0], analysis.best); + assert.deepEqual(analysis.best, analysis.played); + assert.deepEqual( + analysis.ranking.slice(0, 5).map((move) => move.ply), + [1, 1, 1, 1, 1], + ); }); test("sans coup possible, il n'y a rien à analyser", async () => { diff --git a/app/tests/game.test.ts b/app/tests/game.test.ts index 77ede5f..901657f 100644 --- a/app/tests/game.test.ts +++ b/app/tests/game.test.ts @@ -73,7 +73,6 @@ function fakeEngine(overrides: Partial = {}): Engine { }; }, analyseMove: async () => null, - rankMoves: async () => [], analyseCube: async () => ({ analysed: false, equities: null, diff --git a/app/tests/prompts.test.ts b/app/tests/prompts.test.ts index 3583492..fe7b40c 100644 --- a/app/tests/prompts.test.ts +++ b/app/tests/prompts.test.ts @@ -239,7 +239,97 @@ test("prompt d'un coup : la position, les identifiants et le top 3 du moteur, m assert.ok(shown.includes(`${row.move.notation}|${formatEquity(row.loss)}`)); } assert.ok(prompt.includes(`1. ${advice.best.notation}`), prompt); - assert.ok(prompt.includes("analyse à 2 plis"), prompt); + assert.match(prompt, /\(analyse à [12] plis?\)/); + } +}); + +/** Une équité écrite (« −0,212 ») proche d'une équité de gnubg de bureau : + * à l'arrondi près, plus la tolérance des positions de référence (0,002 ; + * tests/reference/README.md), le moteur WebAssembly ne calculant pas au + * bit près comme gnubg de bureau (ADR 0005). */ +function assertNear(text: string, value: number, message: string): void { + const written = Number(text.replace("−", "-").replace("+", "").replace(",", ".")); + assert.ok(Math.abs(written - value) <= 0.0005 + 0.002, `${text} ≠ ${value}\n${message}`); +} + +/** Le niveau du titre du classement, et chaque ligne du classement : coup, + * équité, écart, niveau propre (null : celui du titre). */ +function rankingOfPrompt(prompt: string) { + const title = /^Classement de GNU Backgammon \(analyse à (\d) plis?\)/m.exec(prompt); + assert.ok(title, prompt); + const lines = [ + ...prompt.matchAll( + /^(\d+)\. (.+?)( \(mon coup\))? : équité (\S+), écart (\S+?)(?:, évalué à (\d) plis?)?$/gm, + ), + ].map((m) => ({ + rank: Number(m[1]), + notation: m[2], + mine: m[3] !== undefined, + equity: m[4], + loss: m[5], + ply: m[6] === undefined ? null : Number(m[6]), + })); + return { ply: Number(title[1]), lines }; +} + +test("prompt d'un coup : le niveau du titre est celui des lignes, les équités celles de l'analyse", async () => { + const engine = await loadNodeEngine(); + // gnubg de bureau, analyse de partie à 2 plis (tests/reference/analysis/). + const reference = (id: string) => + JSON.parse(readFileSync(new URL(`tests/reference/analysis/${id}.json`, root), "utf8")) + .plies["2"]; + for (const [dice, move, id] of [ + // Erreur : gnubg réévalue à 2 plis le meilleur coup et le coup joué ; + // les autres restent au niveau où ses filtres les ont laissés. + [[3, 1], "24/23 13/10", "move-opening-31-error"], + // Meilleur coup : un seul coup passe le filtre de 1 pli, gnubg + // s'arrête là, tout le classement est à 1 pli. + [[3, 1], "8/5 6/5", "move-opening-31-best"], + [[6, 5], "13/8 13/7", "move-opening-65-error"], + ] as const) { + const played = await playOpening(engine, dice, move); + const advice = await adviceOn(engine, played); + assert.ok(advice.kind === "move"); + played.game.recordAdvice(advice); + const position = await positionBefore(engine, played.game.journal, played.eventId); + const prompt = decisionPrompt(templates, phrases, { + journal: played.game.journal, + advice, + position, + }); + const { ply, lines } = rankingOfPrompt(prompt); + const expected = reference(id); + + // Le titre dit le niveau du meilleur coup, celui que gnubg de bureau + // donne ; une ligne sans niveau propre est à ce niveau, une ligne + // avec un niveau propre, à un niveau inférieur. + assert.equal(ply, expected.ranking[0].ply, prompt); + assert.equal(ply, advice.best.ply); + assert.ok(lines.length >= 3, prompt); + for (const line of lines) { + const reference = expected.ranking.find( + (m: { move: string }) => m.move === line.notation, + ); + assert.ok(reference, `${line.notation} absent du classement de gnubg`); + assert.equal(line.ply ?? ply, reference.ply, `${line.notation}\n${prompt}`); + assert.ok(line.ply === null || line.ply < ply, prompt); + assertNear(line.equity, reference.equity, `${line.notation}\n${prompt}`); + } + + // Les équités et l'écart du meilleur coup et du vôtre sont ceux de + // l'analyse, que disent la feuille du maître et l'écran d'analyse. + const best = lines[0]; + const mine = lines.find((line) => line.mine); + assert.ok(best && mine, prompt); + assert.equal(best.notation, advice.best.notation); + assert.equal(best.equity, formatEquity(advice.best.equity)); + assert.equal(best.loss, formatEquity(0)); + assert.equal(mine.notation, advice.played.notation); + assert.equal(mine.rank, advice.rank); + assert.equal(mine.equity, formatEquity(advice.played.equity)); + assert.equal(mine.loss, formatEquity(advice.equityLoss)); + assertNear(mine.loss, expected.equity_loss, prompt); + assert.ok(shownRanking(advice).includes(`${mine.notation}|${mine.loss}`)); } }); diff --git a/docs/adr/0007-architecture-et-conventions-de-code.md b/docs/adr/0007-architecture-et-conventions-de-code.md index f16099b..1d7fe97 100644 --- a/docs/adr/0007-architecture-et-conventions-de-code.md +++ b/docs/adr/0007-architecture-et-conventions-de-code.md @@ -184,3 +184,21 @@ a plus besoin (`app/src/ui/review-runner.ts`). La bibliothèque gagne `saveAnalyses`, qui enregistre le journal analysé d'une partie sans en faire la partie en cours ni changer la date de sa dernière action (l'ordre de « Mes parties »). + +### 2026-11-11 — Version 2 des conseils : le classement de l'analyse + +Le classement des coups d'un conseil (`ranking`) venait d'un second appel au +moteur, comme la commande « hint » de gnubg, alors que le coup joué et le +meilleur coup venaient de l'analyse de partie. Les deux listes diffèrent : +quand le coup joué est une erreur, l'analyse réévalue le meilleur coup et le +coup joué au niveau demandé, ce que « hint » ne fait pas quand un seul coup +passe ses filtres (ouverture 3-1 jouée 24/23 13/10 : 8/5 6/5 à +0,200 à +2 plis dans l'analyse, +0,204 à 1 pli dans « hint »). Le classement vient +désormais de l'analyse même (`engine_analyse_move`, champ `ranking`), comme +le panneau d'analyse de gnubg de bureau. + +- `ADVICE_VERSION` passe à 2 (`app/src/domain/game/advice.ts`). Selon la + règle de l'amendement du 2026-11-10, un conseil de la version 1 est oublié + à la reprise et recalculé en arrière-plan ; les analyses de l'ordinateur + (`review.computer`), du même format, aussi. +- Ni les événements (version 2) ni le schéma IndexedDB ne changent. diff --git a/docs/prompts/README.md b/docs/prompts/README.md index 1758b5b..4a2a905 100644 --- a/docs/prompts/README.md +++ b/docs/prompts/README.md @@ -54,5 +54,5 @@ Repères communs à plusieurs gabarits : | `{diagramme}` | le plateau en texte : vos pions X, ceux de l'ordinateur O, dans votre numérotation | | `{score}` | « partie libre » ou le score du match | | `{videau}` | sa valeur et qui le possède | -| `{niveau_eval}` | le niveau d'évaluation de GNU Backgammon, « analyse à 2 plis » | +| `{niveau_eval}` | le niveau d'évaluation de GNU Backgammon, « analyse à 2 plis » ; pour un classement de coups, celui auquel le moteur a évalué le meilleur coup (voir [coup.md](coup.md)) | | `{langue}` | la langue de la réponse : « français » | diff --git a/docs/prompts/coup.md b/docs/prompts/coup.md index 47fc1a5..d54b028 100644 --- a/docs/prompts/coup.md +++ b/docs/prompts/coup.md @@ -12,7 +12,15 @@ Repères propres à ce gabarit : | `{coup_joue}` | votre coup, dans la notation de GNU Backgammon | | `{appreciation}` | le mot du maître : « Bon coup », « Imprécision », « Erreur » ou « Grosse erreur » | | `{rang}` | « le meilleur coup » ou « le 4e choix sur 15 coups possibles » | -| `{classement}` | le classement du moteur : les trois meilleurs coups, et le vôtre s'il n'en est pas, avec leur équité et leur écart au meilleur, comme dans « Voir les chiffres du moteur » | +| `{classement}` | le classement de l'analyse de votre coup : les trois meilleurs coups, et le vôtre s'il n'en est pas, avec leur équité et leur écart au meilleur, comme dans « Voir les chiffres du moteur » | + +Le niveau du titre (`{niveau_eval}`) est celui auquel GNU Backgammon a +évalué le meilleur coup. C'est d'ordinaire le niveau de l'analyse (2 plis), +mais le moteur s'arrête plus tôt quand un seul coup passe ses filtres : à +l'ouverture 3-1 jouée 8/5 6/5, tout le classement est à 1 pli. Les coups que +ses filtres ont écartés gardent l'évaluation d'un niveau inférieur, et leur +ligne le dit (« évalué à 1 pli »). Les équités et l'écart de votre coup sont +ceux de l'analyse, que disent la feuille du maître et l'écran d'analyse. ## Gabarit