Files
Ustagammon/tests/reference/README.md
T

19 KiB

Positions de référence

Ce dossier contient des positions de backgammon évaluées par le GNU Backgammon de bureau, compilé dans Docker au commit figé par l'ADR 0005. Le moteur embarqué d'Ustagammon (et son engine-cli) doit retrouver ces résultats, à une tolérance près. Ce document est le contrat du format : engine-cli produit pour une position un objet de même structure que la partie plies d'un fichier de résultat.

Fichier Rôle
positions.json Positions d'entrée, versionnées, écrites à la main
results/<id>.json Résultats de gnubg, un fichier par position, générés
analyses.json Analyses d'entrée : coups joués, décisions de videau, niveaux de jeu
analysis/<id>.json Résultats de l'analyse de partie de gnubg, générés (section « Analyses »)
generate.py Script exécuté dans gnubg qui produit results/ et analysis/
compare.py Compare le moteur embarqué à results/ et analysis/ (dernière section)

Commandes :

make reference-image   # construit l'image Docker du gnubg de référence
make reference         # régénère results/ et analysis/ (une quarantaine de secondes)
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 identiques (git diff --exit-code tests/reference).

Référence utilisée

  • gnubg : GNU Backgammon 1.08.003, commit 14b67984737ded0f6de43d63dcb789f729d9b06b, compilé par docker/gnubg-reference/Dockerfile (Debian trixie, sans interface graphique, SIMD SSE2 forcé, drapeaux par défaut de gnubg -O3 -ffast-math).
  • Données : gnubg.weights du dépôt, et gnubg.wd, gnubg_os0.bd, gnubg_ts0.bd produits dans l'image par les outils officiels (makeweights, makebearoff -o 6 -s 7999999, makebearoff -t 6x6). Leurs sommes SHA-256 sont dans l'image, /usr/local/share/gnubg/donnees.sha256 ; le moteur embarqué doit utiliser exactement ces fichiers.
  • Table d'équité de match : Kazaross-XG2 (met/Kazaross-XG2.xml), la table par défaut de gnubg.
  • Autres réglages : ceux par défaut de gnubg. En particulier les castors (beavers) sont autorisés en partie libre (ils ne figurent pas dans le Match ID) et ignorés en match, et le filtre de coups est le filtre « Normal ».

Positions d'entrée : positions.json

{
  "positions": [
    {
      "id": "opening-31",
      "type": "move",
      "categories": ["opening"],
      "description": "Position de départ, premier coup 3-1, partie libre.",
      "position_id": "4HPwATDgc/ABMA",
      "match_id": "cIkFAAAAAAAA",
      "dice": [3, 1]
    }
  ]
}
  • id : identifiant unique, sert de nom de fichier dans results/.
  • type : "move" (dés jetés : quel coup jouer) ou "cube" (dés pas encore jetés : faut-il doubler).
  • categories : étiquettes de couverture. Valeurs utilisées : opening, middlegame, prime, blitz, race, bearoff, edge_case, cube_no_double, cube_double_take, cube_double_pass, cube_redouble, cube_too_good, match, crawford, post_crawford, jacoby.
  • position_id, match_id : la position au format GNU Backgammon. Le Match ID porte tout le contexte : dés, joueur au trait, videau et propriétaire, longueur du match, score, Crawford, règle Jacoby.
  • dice : les dés, redondants avec le Match ID (le générateur vérifie qu'ils concordent) ; null pour une décision de videau.

Pour ajouter une position : obtenir ses identifiants dans gnubg (ils s'affichent sous le plateau, ou show gnubgid), l'ajouter à positions.json, puis make reference.

Résultats : results/<id>.json

{
  "id": "opening-31",
  "type": "move",
  "categories": ["opening"],
  "description": "…",
  "position_id": "4HPwATDgc/ABMA",
  "match_id": "cIkFAAAAAAAA",
  "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
  "met": "Kazaross-XG2",
  "context": {
    "dice": [3, 1],
    "match_length": 0,
    "score_player": 0,
    "score_opponent": 0,
    "crawford": false,
    "jacoby": true,
    "beavers": true,
    "cube_value": 1,
    "cube_owner": "centered"
  },
  "plies": {
    "0": { "evaluation": { … }, "moves": { … } },
    "2": { "evaluation": { … }, "moves": { … } }
  }
}

context est le contexte tel que gnubg l'a relu dans le Match ID : match_length vaut 0 en partie libre ; score_player est le score du joueur au trait ; cube_owner vaut centered, player (le joueur au trait) ou opponent ; beavers dit si les castors comptent (partie libre seulement). Voir results/opening-31.json pour un fichier complet.

Niveaux : plies

Les clés "0" et "2" sont des niveaux comptés en plis à la manière de gnubg : 0 pli = évaluation statique du réseau de neurones, 2 plis = deux demi-coups de recherche. Ce sont les réglages prédéfinis « 0ply » et « 2ply » de gnubg (aecSettings dans eval.c), sans bruit : cubeful, déterministe, élagage (prune) désactivé à 0 pli et activé à 2 plis.

Conventions communes

  • Point de vue : tout est donné du point de vue du joueur au trait (celui qui a les dés ou qui doit décider de doubler). Une équité positive lui est favorable.
  • Probabilités (probabilities), entre 0 et 1, issues des sorties du réseau de gnubg :
    • win : probabilité de gagner la partie ;
    • win_gammon : de gagner au moins un gammon (backgammons compris) ;
    • win_backgammon : de gagner un backgammon ;
    • lose_gammon : de perdre au moins un gammon (backgammons compris) ;
    • lose_backgammon : de perdre un backgammon. La probabilité de perdre est 1 - win.
  • Équités : en points pour un videau à 1. En match, gnubg convertit sa probabilité de gagner le match en « équité monétaire normalisée » (mwc2eq) ; c'est cette valeur qui est donnée.
  • Nombres : gnubg calcule en float32 ; chaque nombre est écrit sous la forme décimale la plus courte qui redonne exactement ce float32.

evaluation : la position avant le jet des dés

"evaluation": {
  "probabilities": { "win": 0.52483594, "win_gammon": 0.14950693,
                     "win_backgammon": 0.006851064, "lose_gammon": 0.124798514,
                     "lose_backgammon": 0.0052878163 },
  "equity": 0.07594353
}

Sortie de gnubg.evaluate : les cinq probabilités et l'équité sans videau (cubeless) correspondante, au score du match, pour la position avant que le joueur au trait jette les dés (même pour une position de type move).

moves : classement des coups (type move)

"moves": {
  "legal_move_count": 16,
  "ranking": [
    { "rank": 1, "move": "8/5 6/5", "ply": 1, "equity": 0.20373425,
      "equity_diff": 0.0, "probabilities": { … } },
    …
  ]
}
  • legal_move_count : nombre de coups légaux au sens de gnubg, c'est-à-dire de positions d'arrivée distinctes (deux suites de déplacements qui mènent à la même position comptent une fois). 0 si aucun coup n'est possible.
  • ranking : les 10 meilleurs coups (tous s'il y en a moins), dans l'ordre de la commande hint de gnubg :
    • move : notation de gnubg (FormatMove), points comptés du point de vue du joueur au trait, bar et off, * pour une frappe, (2) pour un déplacement répété ;
    • ply : niveau auquel ce coup a réellement été évalué. À 2 plis, gnubg évalue d'abord tous les coups à 0 pli puis ne garde pour les plis suivants que les meilleurs (filtre « Normal » : à 0 pli les 12 meilleurs au plus dans un écart de 0,24 ; à 1 pli les 6 meilleurs au plus dans un écart de 0,12). Les coups éliminés gardent une évaluation à 0 ou 1 pli. Si un seul coup survit à un filtre, gnubg s'arrête là : c'est le cas de l'exemple ci-dessus (3-1 d'ouverture à 2 plis), où 8/5 6/5 reste seul après le filtre de 1 pli et garde donc "ply": 1, comme dans l'affichage « Cubeful 1-ply » de gnubg. Les coups forcés restent à 0 pli ;
    • equity : équité avec videau (cubeful) après ce coup ;
    • equity_diff : equity moins celle du premier coup (0 ou négatif) ;
    • probabilities : les cinq probabilités après ce coup, toujours du point de vue du joueur qui vient de jouer.

Le classement reproduit exactement hint : FindnSaveBestMoves avec fAnalyse vrai, puis RefreshMoveList qui départage les équités égales.

cube : décision de videau (type cube)

"cube": {
  "no_double": 0.83056116,
  "double_take": 0.9218164,
  "double_pass": 1.0,
  "optimal": 0.9218164,
  "decision": "DOUBLE_TAKE",
  "decision_text": "Double, take"
}

Sortie de gnubg.cfevaluate (GeneralCubeDecisionE puis FindCubeDecision), en équités avec videau ramenées à un videau de valeur 1 (même quand le videau est déjà à 2, voir cube-redouble) ; exemple tiré de cube-double-take :

  • no_double : si le joueur au trait ne double pas ;
  • double_take : s'il double et que l'adversaire prend ;
  • double_pass : s'il double et que l'adversaire passe ; vaut 1 par construction (en match, la probabilité de gagner le match après le passe, tirée de la table d'équité, est normalisée à 1 par mwc2eq) ;
  • optimal : l'équité retenue après les meilleures décisions des deux côtés ;
  • decision : nom de la décision dans l'énumération cubedecision de eval.h de gnubg (NODOUBLE_TAKE, DOUBLE_TAKE, DOUBLE_PASS, TOOGOOD_PASS, REDOUBLE_TAKE, NODOUBLE_DEADCUBE…) ;
  • decision_text : le libellé anglais correspondant de gnubg.

Analyses : analyses.json et analysis/<id>.json

Ce que gnubg dit d'un coup joué, d'une décision de videau et d'un jet de dés (sa commande « analyse game »), et le coup que joue son ordinateur à chaque niveau. Pour chaque cas, generate.py rejoue dans gnubg une courte partie entre deux joueurs humains : la position (set gnubgid), puis le coup ou la décision, puis l'analyse. Rien n'est calculé dans le script : les valeurs viennent des enregistrements de la partie analysée et des statistiques que gnubg en tire.

analyses.json a trois listes :

  • moves : position_id, match_id, dice et played, le coup joué en notation de gnubg (null : le meilleur coup selon gnubg, pour les cas qui ne servent qu'à la chance du jet) ;
  • cubes : position_id, match_id (sans dés) et action, la décision : no_double ou double pour le joueur au trait, take ou pass pour son adversaire ;
  • levels : position_id, match_id et dice (null : décision de videau).

Un fichier de résultat reprend le cas, avec type (move, cube ou level), gnubg_commit et met, puis, aux niveaux d'analyse 0 et 2 plis (clés de plies, réglages prédéfinis de gnubg sans bruit, filtre « Normal ») :

  • coup joué : played_move et played_position_id (position après le coup, vue de celui qui l'a joué), move_count (coups légaux), rank (place du coup joué dans le classement de l'analyse, à partir de 1 ; imove de gnubg plus 1), played_equity, best_move, best_equity, equity_loss (perte du coup joué, 0 ou négative : statistique « error-skill » de gnubg changée de signe), skill (none, doubtful, bad, very_bad) et ranking (les dix premiers coups, et jusqu'au coup joué) ; plus, une fois pour toutes, luck : value (chance du jet) et rating (very_unlucky, unlucky, none, lucky, very_lucky) ;
  • videau : analysed (gnubg analyse-t-il cette décision ? pas à la partie Crawford ni quand l'adversaire possède le videau), cube (la décision de videau de gnubg au même niveau, format de la section précédente, null si non analysée), equity_loss (perte de celui qui décide, statistique « error-skill » changée de signe) et skill ;
  • niveaux (levels, à la place de plies) : pour les niveaux beginner, casual, intermediate, advanced, 0ply, 1ply et 2ply (3 et 4 plis sont trop lents pour la suite de tests), plies et noise (réglages de gnubg), puis move, le coup que joue l'ordinateur de gnubg réglé à ce niveau (commande « play »), ou la décision de videau de gnubg avec ces réglages, bruit compris.

Deux précautions, faute de quoi gnubg donne des valeurs qui dépendent de ce qu'il a calculé avant :

  • le cache d'évaluation est vidé avant chaque analyse, et l'analyse du videau est désactivée pour les coups joués. Une évaluation à 2 plis avec élagage range dans le cache des évaluations à 0 pli faites avec les réseaux d'élagage, sous la même clé que les évaluations à 0 pli ordinaires (la clé d'EvalKey, dans eval.c, n'inclut l'élagage qu'à partir d'1 pli). Une analyse à 0 pli, ou l'analyse de la chance qui suit l'analyse du videau à 2 plis, les reprend : écarts observés de 5e-3 à 2,5e-2. Le moteur embarqué a le même cache ; compare.py l'interroge par un processus neuf à chaque commande ;
  • le coup choisi pour un cas sans played vient de findbestmove et non de « hint », dont gnubg garde l'analyse dans l'enregistrement du coup : « analyse game » la reprendrait au lieu de calculer au niveau demandé.

Comparaison avec le moteur embarqué

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]

La même suite est rejouée sur le moteur compilé en WebAssembly (tests/test_wasm_reference.sh), avec le même comparateur et la même tolérance : --cli "node engine-wasm/engine-cli.mjs" interroge, sous Node dans l'image Emscripten, le même programme compilé en WebAssembly.

Le programme interrogé produit un objet de même structure que plies["<n>"] : 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 bash 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.

Pour chaque analyse (engine-cli analysemove, analysecube, luck, et --level NIVEAU bestmove ou cube) :

  • coup joué : même nombre de coups légaux, même notation du coup joué, même rang sauf égalité (les coups entre les deux rangs ont pour gnubg la même équité que le coup joué, à la tolérance près : remarque), même meilleur coup sauf égalité, et played_equity, best_equity, equity_loss à la tolérance près ;
  • chance : value à la tolérance près ;
  • videau : même analysed, même décision et équités à la tolérance près, equity_loss à la tolérance près ;
  • appréciation (skill) et chance (rating) identiques, sauf si la valeur de gnubg est à moins de la tolérance d'un seuil (remarque) ;
  • niveaux : mêmes réglages que gnubg (engine-cli levels), même coup choisi, ou même décision de videau et équités à 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.

Mesure du 2026-11-03, avec les 37 analyses (2 890 nombres comparés, natif et WebAssembly) : écart maximal 1,64e-3, sur move-jacoby à 2 plis (équité du coup joué 8/2 5/1*, et donc sa perte), même phénomène de bascule à 2 plis ; tous les autres nombres des analyses restent sous 1e-3. La tolérance n'a pas été changée.

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.