diff --git a/tests/reference/README.md b/tests/reference/README.md index 0593be5..f7f68ea 100644 --- a/tests/reference/README.md +++ b/tests/reference/README.md @@ -12,13 +12,15 @@ d'un fichier de résultat. | `positions.json` | Positions d'entrée, versionnées, écrites à la main | | `results/.json` | Résultats de gnubg, un fichier par position, générés | | `generate.py` | Script exécuté dans gnubg qui produit `results/` | +| `compare.py` | Compare le moteur embarqué à `results/` (dernière section) | Commandes : ``` make reference-image # construit l'image Docker du gnubg de référence make reference # régénère results/ (une vingtaine de secondes) -make test # vérifie la cohérence (tests/test_reference.sh) +make test # vérifie la cohérence (tests/test_reference.sh) et + # compare le moteur embarqué (tests/test_engine_reference.sh) ``` `make reference` est déterministe : deux exécutions donnent des fichiers @@ -222,8 +224,86 @@ Sortie de `gnubg.cfevaluate` (`GeneralCubeDecisionE` puis ## Comparaison avec le moteur embarqué -Une concordance au bit près n'est pas attendue (ADR 0005) : `-ffast-math` et -le jeu d'instructions (SSE2 ici, NEON sur téléphone, C générique en -WebAssembly) changent les derniers chiffres. Les tests de concordance devront -donc comparer les nombres avec une tolérance, et accepter qu'entre deux coups -d'équités presque égales l'ordre s'inverse. +`make test` compare le moteur embarqué à ces résultats +(`tests/test_engine_reference.sh`). Pour chaque position et chaque niveau, le +comparateur `compare.py` (Python 3, dans l'image Docker de la chaîne C) lance + +``` +build/engine-cli --ply N reference POSITION_ID MATCH_ID [DÉS] +``` + +qui produit un objet de même structure que `plies[""]` : `evaluation`, +puis `moves` (avec les dés) ou `cube` (sans). Le classement des coups suit la +méthode de `hint` décrite plus haut (`engine_best_moves` dans +`engine/engine.c`). Pour voir tous les nombres comparés : +`BUILD_C_IMAGE=ustagammon/build-c:2 tests/test_engine_reference.sh --details`. + +Une concordance au bit près n'est pas attendue (ADR 0005) : le jeu +d'instructions (SSE2 pour gnubg de bureau, C générique pour le moteur +embarqué sur PC et en WebAssembly, NEON sur téléphone) change les derniers +chiffres. Les nombres sont donc comparés avec une tolérance. + +### Critères + +Pour chaque position et chaque niveau : + +- même nombre de coups légaux ; +- même meilleur coup, sauf si gnubg lui-même sépare ses deux premiers coups + de moins que la tolérance : c'est alors une égalité, signalée par une + remarque dans le rapport, sans échec ; +- même pli réel du meilleur coup ; +- pour chaque coup présent dans les deux classements (10 premiers), équité + et cinq probabilités à la tolérance près ; +- évaluation de la position (cinq probabilités et équité) à la tolérance + près ; +- même décision de videau (`decision` et `decision_text`), et les quatre + équités du videau à la tolérance près. + +Le rapport donne, pour chaque écart hors critères, la position et le niveau, +le champ, la valeur attendue (gnubg), la valeur obtenue (moteur) et l'écart ; +il donne toujours l'écart maximal observé. + +### Tolérance : 0,002 + +Une même tolérance absolue s'applique aux probabilités et aux équités +(équités ramenées à un videau de 1). Elle vient de mesures faites le +2026-11-02 sur les 32 positions aux deux niveaux (2 598 nombres comparés) : + +| Moteur comparé à gnubg de bureau | 99 % des écarts sous | Écart maximal | +| --- | --- | --- | +| Moteur embarqué tel que compilé (C générique, `-O3 -ffast-math`) | 1,6e-4 | 9,5e-4 | +| Le même sans `-ffast-math` | 1,6e-4 | 9,5e-4 | +| Le même compilé en SSE2 comme gnubg de bureau (essai, non retenu) | 0 | 0 (float32 identiques) | + +Ce que ces mesures montrent : + +- **`-ffast-math` n'est pas en cause côté moteur embarqué** : avec ou sans, + le moteur en C générique donne les mêmes classements, et ses nombres + diffèrent entre eux de 1,1e-6 au plus. +- **La cause est le code SIMD de gnubg de bureau.** En SSE2 avec + `-ffast-math`, la sigmoïde du réseau de neurones (`sigmoid_positive_ps` + dans `lib/neuralnetsse.c`) calcule `1/x` avec l'instruction approchée + `_mm_rcp_ps` (environ 12 bits de précision), alors que la version en C + générique (`sigmoid` dans `lib/sigmoid.h`) fait une vraie division. L'essai + le prouve : le même moteur compilé en SSE2 (`-DUSE_SIMD_INSTRUCTIONS + -DHAVE_SSE -DUSE_SSE2`) redonne les références au bit près, sur les 2 604 + nombres comparés (classements identiques, donc un peu plus de coups + communs). +- **L'écart reste en général de l'ordre de 1e-4**, sauf un cas : + `opening-64-match` à 2 plis, coup `8/2 6/2`, écart de 9,5e-4 alors que les + autres coups de la même position restent sous 1e-5. Une recherche à 2 plis + fait des choix discrets (meilleure réponse pour chaque jet, élagage) : quand + deux choix sont presque à égalité, un écart de 1e-4 peut en faire basculer + un, et l'écart final grandit d'autant. + +La tolérance de 0,002 vaut environ deux fois l'écart maximal mesuré, pour +absorber d'autres bascules de ce genre sans masquer un vrai défaut : elle +reste quinze fois plus petite que le seuil à partir duquel gnubg qualifie un +coup de « douteux » (0,03) et correspond à deux unités du dernier chiffre +qu'affiche gnubg (trois décimales). Les vrais défauts (mauvais niveau, +mauvais contexte de videau, mauvaise conversion d'équité) donnent des écarts +d'un ou plusieurs centièmes. + +Sur téléphone (NEON) les écarts seront à mesurer de la même façon : avec +`-ffast-math`, gnubg y remplace aussi la division par une approximation +(`vrecpeq_f32` et une itération de Newton), différente de celle de SSE2. diff --git a/tests/reference/compare.py b/tests/reference/compare.py new file mode 100644 index 0000000..6e1f90a --- /dev/null +++ b/tests/reference/compare.py @@ -0,0 +1,169 @@ +"""Compare le moteur embarqué aux résultats de gnubg de bureau. + +Pour chaque fichier de résultats/ et chaque niveau, lance +`engine-cli --ply N reference POSITION_ID MATCH_ID [DÉS]` et vérifie les +critères de concordance décrits dans README.md (section « Comparaison avec +le moteur embarqué »). Affiche un rapport et sort en erreur si un critère +n'est pas tenu. + +Usage (dans l'image Docker ustagammon/build-c, voir +tests/test_engine_reference.sh) : + + python3 tests/reference/compare.py [--cli build/engine-cli] + [--results tests/reference/results] [--tolerance 0.002] [--details] +""" + +import argparse +import glob +import json +import os +import subprocess +import sys + +# Écart maximal accepté sur une probabilité ou une équité : voir la +# justification dans README.md. +TOLERANCE = 0.002 + +PROBABILITIES = ("win", "win_gammon", "win_backgammon", "lose_gammon", "lose_backgammon") +CUBE_EQUITIES = ("no_double", "double_take", "double_pass", "optimal") + + +class Report: + """Collecte les écarts d'une comparaison.""" + + def __init__(self, tolerance): + self.tolerance = tolerance + self.failures = [] # critères non tenus + self.notes = [] # remarques qui ne font pas échouer + self.lines = [] # tous les nombres comparés (--details) + self.max_gap = 0.0 + self.max_gap_where = "" + + def number(self, where, field, expected, actual): + gap = abs(actual - expected) + line = (where, field, "%.9g" % expected, "%.9g" % actual, "%.2e" % gap) + self.lines.append(line) + if gap > self.max_gap: + self.max_gap = gap + self.max_gap_where = "%s %s" % (where, field) + if gap > self.tolerance: + self.failures.append(line) + + def same(self, where, field, expected, actual): + if expected != actual: + self.failures.append((where, field, str(expected), str(actual), "différent")) + + +def run_engine(cli, position, ply): + command = [cli, "--ply", str(ply), "reference", position["position_id"], + position["match_id"]] + dice = position["context"]["dice"] + if dice: + command.append("%d%d" % tuple(dice)) + output = subprocess.run(command, capture_output=True, text=True) + if output.returncode != 0: + raise SystemExit("%s a échoué : %s" % (" ".join(command), output.stdout + output.stderr)) + return json.loads(output.stdout) + + +def compare_probabilities(report, where, expected, actual): + for name in PROBABILITIES: + report.number(where, name, expected[name], actual[name]) + + +def compare_moves(report, where, expected, actual): + report.same(where, "legal_move_count", expected["legal_move_count"], + actual["legal_move_count"]) + reference = expected["ranking"] + engine = actual["ranking"] + if not reference or not engine: + report.same(where, "ranking", [m["move"] for m in reference], [m["move"] for m in engine]) + return + + # Meilleur coup : le même, sauf si gnubg lui-même ne sépare ses deux + # premiers coups que de moins que la tolérance (égalité). + best, engine_best = reference[0]["move"], engine[0]["move"] + if best != engine_best: + tied = len(reference) > 1 and -reference[1]["equity_diff"] <= report.tolerance + if tied: + report.notes.append("%s : meilleur coup %s pour gnubg, %s pour le moteur ; " + "égalité, gnubg ne les sépare que de %.2e" + % (where, best, engine_best, -reference[1]["equity_diff"])) + else: + report.same(where, "meilleur coup", best, engine_best) + + # Coups présents dans les deux classements : même pli pour le meilleur + # coup de gnubg, équités et probabilités à la tolérance près pour tous. + by_move = {m["move"]: m for m in engine} + for m in reference: + other = by_move.get(m["move"]) + if other is None: + continue + here = "%s %s" % (where, m["move"]) + if m is reference[0]: + report.same(here, "ply", m["ply"], other["ply"]) + report.number(here, "equity", m["equity"], other["equity"]) + compare_probabilities(report, here, m["probabilities"], other["probabilities"]) + + +def compare_cube(report, where, expected, actual): + report.same(where, "decision", expected["decision"], actual["decision"]) + report.same(where, "decision_text", expected["decision_text"], actual["decision_text"]) + for name in CUBE_EQUITIES: + report.number(where, name, expected[name], actual[name]) + + +def compare_position(report, cli, path): + with open(path, encoding="utf-8") as f: + position = json.load(f) + for ply, expected in sorted(position["plies"].items()): + actual = run_engine(cli, position, int(ply)) + where = "%s à %s pli(s)" % (position["id"], ply) + compare_probabilities(report, where + " évaluation", + expected["evaluation"]["probabilities"], + actual["evaluation"]["probabilities"]) + report.number(where + " évaluation", "equity", expected["evaluation"]["equity"], + actual["evaluation"]["equity"]) + if "moves" in expected: + compare_moves(report, where, expected["moves"], actual["moves"]) + else: + compare_cube(report, where, expected["cube"], actual["cube"]) + return len(position["plies"]) + + +def print_table(lines): + header = ("position et niveau", "champ", "attendu", "obtenu", "écart") + rows = [header] + lines + widths = [max(len(row[i]) for row in rows) for i in range(len(header))] + for row in rows: + print(" " + " ".join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip()) + + +def main(): + here = os.path.dirname(os.path.abspath(__file__)) + parser = argparse.ArgumentParser(description="Compare engine-cli aux références de gnubg.") + parser.add_argument("--cli", default="build/engine-cli") + parser.add_argument("--results", default=os.path.join(here, "results")) + parser.add_argument("--tolerance", type=float, default=TOLERANCE) + parser.add_argument("--details", action="store_true", + help="affiche tous les nombres comparés") + args = parser.parse_args() + + report = Report(args.tolerance) + paths = sorted(glob.glob(os.path.join(args.results, "*.json"))) + levels = sum(compare_position(report, args.cli, path) for path in paths) + + if args.details: + print_table(report.lines) + for note in report.notes: + print(" Remarque : " + note) + print(" %d positions, %d niveaux, %d nombres comparés ; écart maximal %.2e (%s), " + "tolérance %.0e." % (len(paths), levels, len(report.lines), report.max_gap, + report.max_gap_where, args.tolerance)) + if report.failures: + print(" %d écart(s) hors critères :" % len(report.failures)) + print_table(report.failures) + sys.exit(1) + + +main() diff --git a/tests/test_engine_reference.sh b/tests/test_engine_reference.sh new file mode 100644 index 0000000..87b80df --- /dev/null +++ b/tests/test_engine_reference.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Compare le moteur embarqué (build/engine-cli) aux résultats de gnubg de +# bureau sur toutes les positions de tests/reference/, aux niveaux 0 et 2 +# plis. Critères et tolérance : tests/reference/README.md, section +# « Comparaison avec le moteur embarqué ». Le comparateur +# (tests/reference/compare.py) tourne dans l'image de la chaîne C (ADR 0004). +# +# Lance `make engine` (sans effet si tout est à jour). Les arguments sont +# passés au comparateur, par exemple --details pour voir tous les nombres. + +set -euo pipefail +cd "$(dirname "$0")/.." + +image="${BUILD_C_IMAGE:?lancer via make test}" + +# Lancé depuis `make test` : repartir d'un make indépendant du make parent. +unset MAKEFLAGS MFLAGS MAKELEVEL +make engine >/dev/null + +docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/src:ro" -w /src "$image" \ + python3 tests/reference/compare.py "$@"