Fusion de main dans moteur-analyse ; plan 1a clos

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-03 15:06:09 +01:00
co-authored by Claude Opus 5.5
44 changed files with 1264 additions and 740 deletions
+152
View File
@@ -0,0 +1,152 @@
// benchmark.mjs -- mesure du moteur WebAssembly sous Node, pour `make bench`
// (engine-wasm/bench.mjs). Elle servait aussi à la page de mesure du site,
// retirée avec les pages de test du moteur (ADR 0009, amendement du
// 2026-11-03).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Sur les positions de référence (tests/reference/positions.json) : temps du
// meilleur coup à 0 et 2 plis pour les positions avec dés, temps de la
// décision de videau à 2 plis pour les autres. Le rapport compare aux seuils
// de l'étape 0b (ADR 0009).
// Seuils de l'étape 0b. Un temps respecte son seuil si son maximum, sur
// toutes les positions, est en dessous (la médiane est donnée en plus).
export const TIME_THRESHOLDS = [
{ key: "move0", label: "Meilleur coup, 0 pli", limitMs: 300 },
{ key: "move2", label: "Meilleur coup, 2 plis", limitMs: 3000 },
{ key: "cube2", label: "Décision de videau, 2 plis", limitMs: 3000 },
// Étape 1a : l'analyse du coup joué, pour le conseil du maître, calculée
// en arrière-plan ; même repère que le meilleur coup (ADR 0009).
{ key: "analysis2", label: "Analyse du coup joué, 2 plis", limitMs: 3000 },
];
// Taille totale à télécharger, compressée : moins de 40 Mo.
export const DOWNLOAD_LIMIT_BYTES = 40e6;
// Fichiers téléchargés pour faire tourner le moteur dans l'application
// (hors code de l'application elle-même, quelques dizaines de Ko), chemins
// relatifs à la racine du site (web/ ; wasm/ est build/wasm/).
export const DOWNLOADED_FILES = [
"index.html",
"app.css",
"engine-worker.mjs",
"engine-wasm.mjs",
"wasm/engine.mjs",
"wasm/engine.wasm",
"wasm/engine.data",
];
// engine : objet avec bestMove(positionId, matchId, dice, ply),
// cubeDecision(positionId, matchId, ply) et analyseMove(positionId, matchId,
// dice, playedPositionId, ply), qui renvoient (ou promettent) { result, ms }.
// Le coup analysé est le meilleur coup à 0 pli : celui qu'un joueur aurait
// pu jouer, analysé à 2 plis comme pour le conseil du maître.
// positions : le tableau « positions » de positions.json.
// onProgress(done, total) : appelé après chaque calcul.
// Renvoie, pour chaque clé de TIME_THRESHOLDS, la liste des
// { id, ms } mesurés.
export async function measureTimes(engine, positions, onProgress = () => {}) {
const tasks = [];
const played = new Map();
for (const p of positions) {
if (p.type === "move") {
tasks.push(["move0", p, async () => {
const answer = await engine.bestMove(p.position_id, p.match_id, p.dice, 0);
// Sans coup possible, n'importe quelle position fait l'affaire.
played.set(p.id, answer.result.best_move?.position_id ?? p.position_id);
return answer;
}]);
tasks.push(["move2", p, () => engine.bestMove(p.position_id, p.match_id, p.dice, 2)]);
tasks.push(["analysis2", p, () => engine.analyseMove(p.position_id, p.match_id, p.dice,
played.get(p.id), 2)]);
} else {
tasks.push(["cube2", p, () => engine.cubeDecision(p.position_id, p.match_id, 2)]);
}
}
const times = Object.fromEntries(TIME_THRESHOLDS.map((t) => [t.key, []]));
for (const [i, [key, position, compute]] of tasks.entries()) {
const { ms } = await compute();
times[key].push({ id: position.id, ms });
onProgress(i + 1, tasks.length);
}
return times;
}
function median(values) {
const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
}
// Nombres à la française : virgule décimale.
function decimal(value, digits) {
return value.toFixed(digits).replace(".", ",");
}
function megabytes(bytes) {
return decimal(bytes / 1e6, 1) + " Mo";
}
function verdict(ok) {
return ok ? "OK" : "DÉPASSÉ";
}
// Résumé d'une série de temps : { count, medianMs, maxMs, maxId, ok }.
export function summarize(times) {
return TIME_THRESHOLDS.map((threshold) => {
const list = times[threshold.key];
const worst = list.reduce((a, b) => (b.ms > a.ms ? b : a));
return {
...threshold,
count: list.length,
medianMs: median(list.map((t) => t.ms)),
maxMs: worst.ms,
maxId: worst.id,
ok: worst.ms < threshold.limitMs,
};
});
}
// Rapport texte, à coller dans la conversation.
// environment : description de l'appareil (navigateur ou Node) ;
// files : [{ name, bytes, gzipBytes }] ; loadMs : chargement du moteur ;
// memoryBytes : mémoire WebAssembly après la mesure ; times : measureTimes.
export function formatReport({ environment, files, loadMs, memoryBytes, times }) {
const total = files.reduce((sum, f) => sum + f.bytes, 0);
const totalGzip = files.reduce((sum, f) => sum + f.gzipBytes, 0);
const sizeOk = totalGzip < DOWNLOAD_LIMIT_BYTES;
const summary = summarize(times);
const allOk = sizeOk && summary.every((s) => s.ok);
const lines = [
"Ustagammon : mesure du moteur WebAssembly (étape 0b)",
"Date : " + new Date().toISOString(),
"Environnement : " + environment,
"",
"Chargement du moteur : " + Math.round(loadMs) + " ms",
"Mémoire WebAssembly après la mesure : " + megabytes(memoryBytes),
"Taille téléchargée : " + megabytes(total) + " non compressée, "
+ megabytes(totalGzip) + " compressée gzip ; seuil "
+ megabytes(DOWNLOAD_LIMIT_BYTES) + " compressée : " + verdict(sizeOk),
"",
];
for (const s of summary) {
lines.push(s.label + " (" + s.count + " positions) : médiane "
+ decimal(s.medianMs, 1) + " ms, maximum " + decimal(s.maxMs, 1) + " ms ("
+ s.maxId + ") ; seuil " + s.limitMs + " ms : " + verdict(s.ok));
}
lines.push("", "Bilan : " + (allOk ? "tous les seuils sont respectés."
: "au moins un seuil est dépassé."), "", "Détail des fichiers :");
for (const f of files) {
lines.push(" " + f.name + " : " + f.bytes + " octets, " + f.gzipBytes + " compressé gzip");
}
lines.push("", "Détail des temps (ms) :");
for (const s of summary) {
lines.push(" " + s.label + " : " + times[s.key]
.map((t) => t.id + " " + Math.round(t.ms)).join(", "));
}
return lines.join("\n") + "\n";
}