Fusion de la MR !8 : étape 0a.4, comparaison aux références

This commit was merged in pull request #8.
This commit is contained in:
2026-11-02 18:04:52 +00:00
13 changed files with 462 additions and 52 deletions
+4 -3
View File
@@ -34,12 +34,13 @@ Les contenus pédagogiques (leçons, textes) sont sous licence CC BY-SA 4.0.
(`docker/gnubg-reference/`, `tests/reference/`). Les résultats de
`tests/reference/results/` et les données `gnubg.wd`, `gnubg_os0.bd`,
`gnubg_ts0.bd` sont produits par ce programme.
- **jq** (licence MIT), lecture des JSON dans `tests/test_reference.sh` ;
utilisé sur le PC, pas dans Docker.
- **Image `ustagammon/build-c`** (`docker/build-c/Dockerfile`), construite sur
Debian trixie : gcc (GPLv3 avec exception d'exécution), make (GPLv3),
GLib (LGPL 2.1 ou ultérieure, uniquement pour les outils `makeweights` et
`makebearoff` lancés au build ; le moteur embarqué n'en contient rien).
`makebearoff` lancés au build ; le moteur embarqué n'en contient rien),
**Python 3** (licence PSF, compatible GPL), qui exécute le comparateur
`tests/reference/compare.py`, et **jq** (licence MIT), qui lit les JSON
dans `tests/test_reference.sh`. Aucun des deux n'est utilisé sur le PC.
## Remerciements
+1 -1
View File
@@ -17,7 +17,7 @@ GHERKIN_IMAGE := ustagammon/gherkin:2
REFERENCE_IMAGE := ustagammon/gnubg-reference:14b6798
# Chaîne de compilation C (docker/build-c/Dockerfile) : changer l'étiquette
# à chaque modification du Dockerfile, pour que make setup la reconstruise.
BUILD_C_IMAGE := ustagammon/build-c:1
BUILD_C_IMAGE := ustagammon/build-c:2
export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE
+7 -5
View File
@@ -1,14 +1,16 @@
# Chaîne de compilation C d'Ustagammon (moteur embarqué et engine-cli).
# Construite une fois par `make setup` ; le build tourne dedans, jamais avec
# les outils de l'hôte (ADR 0004).
# Chaîne de compilation C d'Ustagammon (moteur embarqué et engine-cli), et
# outils des tests du moteur. Construite une fois par `make setup` ; le build
# et ces tests tournent dedans, jamais avec les outils de l'hôte (ADR 0004).
#
# - gcc et make : compilation du moteur et d'engine-cli ;
# - libglib2.0-dev : uniquement pour les outils officiels de gnubg lancés au
# build (makeweights, makebearoff), qui produisent les données du moteur.
# Le moteur embarqué lui-même n'utilise pas GLib (shim engine/compat/).
# Le moteur embarqué lui-même n'utilise pas GLib (shim engine/compat/) ;
# - python3 : comparateur du moteur aux références (tests/reference/compare.py) ;
# - jq : contrôle du format des références (tests/test_reference.sh).
FROM debian:trixie
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
gcc libc6-dev make pkg-config libglib2.0-dev \
gcc libc6-dev make pkg-config libglib2.0-dev python3 jq \
&& rm -rf /var/lib/apt/lists/*
@@ -28,3 +28,21 @@ par `make setup`. Premier cas : ShellCheck (GPLv3), image
- `make lint` demande d'avoir lancé `make setup` une fois ; il le dit
explicitement sinon.
- Chaque nouvelle image est signalée avec sa licence dans `CREDITS.md`.
## Amendements
### 2026-11-02 — Outils validés pour l'étape 0
Validé par Pierre. Tous tournent dans Docker et servent seulement à
construire ou tester ; rien n'est embarqué dans l'application.
| Outil | Licence | Image | Usage |
| --- | --- | --- | --- |
| ShellCheck | GPLv3 | `koalaman/shellcheck-alpine:v0.10.0` | Lint des scripts shell |
| Node.js, `@cucumber/gherkin` | MIT | `ustagammon/gherkin` (`docker/gherkin/`) | Validation des scénarios |
| Debian trixie, GNU Backgammon | logiciels libres, GPLv3+ | `ustagammon/gnubg-reference` | gnubg de bureau de référence |
| gcc, make | GPLv3 | `ustagammon/build-c` (`docker/build-c/`) | Compilation du moteur |
| GLib | LGPL 2.1+ | `ustagammon/build-c` | Outils officiels de génération des données (`makeweights`, `makebearoff`) |
| Python 3 | PSF | `ustagammon/build-c` | Comparateur des références |
| jq | MIT | `ustagammon/build-c` | Lecture du JSON dans les tests |
| Emscripten | MIT / NCSA | `emscripten/emsdk:3.1.64` | Compilation du moteur en WebAssembly (étape 0b) |
+3 -2
View File
@@ -10,11 +10,12 @@ Légende : 🔄 en cours · ⏳ à venir · ⏸ conditionnel
## Étape 0 — Prototype technique
Acquis : socle du dépôt, organisation (ADR 0001, 0003, 0006), source du moteur
(ADR 0005 : gnubg officiel, commit 14b6798).
(ADR 0005 : gnubg officiel, commit 14b6798), gnubg de référence et 32 positions
de référence (0a.2), moteur embarqué et `engine-cli` (0a.3), comparaison
automatique aux références, tolérance 0,002 (0a.4).
| Sous-étape | Contenu | État | Plan |
| --- | --- | --- | --- |
| 0a.4 | `make test` compare `engine-cli` aux références | ⏳ | |
| 0b | Moteur en WebAssembly, page de test dans un Web Worker, `make deploy-web`, `make bench` | ⏳ | |
| Mesures | Pierre relève les mesures sur le Fairphone 4 | ⏳ | |
| Décision | Socle PWA ou Android natif, consigné dans un ADR — validation de Pierre | ⏳ | |
+98 -8
View File
@@ -13,6 +13,11 @@
* legal POSITION_ID MATCH_ID DÉS coups légaux, sans évaluation
* cube POSITION_ID MATCH_ID décision de videau
* eval POSITION_ID MATCH_ID évaluation de la position
* reference POSITION_ID MATCH_ID [DÉS]
* un niveau au format des positions de
* référence (tests/reference/README.md) :
* évaluation, puis classement des coups
* avec DÉS, décision de videau sans
*
* --ply : niveau de gnubg, de 0 (défaut) à 4.
* --data : dossier des données ; par défaut « data » à côté du programme.
@@ -31,6 +36,13 @@
#define EXIT_ENGINE_ERROR 1
#define EXIT_USAGE_ERROR 2
/* Format des nombres : 9 chiffres significatifs redonnent exactement le
* float32 calculé par gnubg. */
#define NUMBER "%.9g"
/* Coups gardés dans le classement de la commande reference (comme hint). */
#define REFERENCE_MAX_MOVES 10
/* Arguments de la ligne de commande, une fois lus. */
typedef struct {
int ply;
@@ -90,8 +102,8 @@ usage_error(const char *message)
static void
print_probabilities(const float p[ENGINE_NUM_PROBABILITIES])
{
printf("{\"win\": %.6f, \"win_gammon\": %.6f, \"win_backgammon\": %.6f, "
"\"lose_gammon\": %.6f, \"lose_backgammon\": %.6f}",
printf("{\"win\": " NUMBER ", \"win_gammon\": " NUMBER ", \"win_backgammon\": " NUMBER ", "
"\"lose_gammon\": " NUMBER ", \"lose_backgammon\": " NUMBER "}",
p[ENGINE_WIN], p[ENGINE_WIN_GAMMON], p[ENGINE_WIN_BACKGAMMON],
p[ENGINE_LOSE_GAMMON], p[ENGINE_LOSE_BACKGAMMON]);
}
@@ -101,7 +113,7 @@ print_move(const engine_move *m, int with_evaluation)
{
printf("{\"move\": \"%s\", \"position_id\": \"%s\"", m->notation, m->position_id);
if (with_evaluation) {
printf(", \"ply\": %d, \"equity\": %.6f, \"probabilities\": ", m->ply, m->equity);
printf(", \"ply\": %d, \"equity\": " NUMBER ", \"probabilities\": ", m->ply, m->equity);
print_probabilities(m->probabilities);
}
printf("}");
@@ -116,6 +128,25 @@ print_match(const engine_match *m)
m->player_on_roll, m->crawford ? "true" : "false", m->jacoby ? "true" : "false");
}
/* Champs d'une décision de videau, sans les accolades. */
static void
print_cube_fields(const engine_cube *c)
{
printf("\"no_double\": " NUMBER ", \"double_take\": " NUMBER ", \"double_pass\": " NUMBER
", \"optimal\": " NUMBER ", \"decision\": \"%s\", \"decision_text\": \"%s\"",
c->no_double, c->double_take, c->double_pass, c->optimal, c->decision,
c->decision_text);
}
/* Début d'un niveau de référence : {"evaluation": {...} (objet laissé ouvert). */
static void
print_reference_evaluation(const engine_evaluation *e)
{
printf("{\"evaluation\": {\"probabilities\": ");
print_probabilities(e->probabilities);
printf(", \"equity\": " NUMBER "}", e->cubeless_equity);
}
/* Lit des dés au format « 31 » ; renvoie 0 si le format est faux. */
static int
parse_dice(const char *text, int *die1, int *die2)
@@ -204,7 +235,7 @@ run_position_command(const options *opt)
print_match(&match);
printf(", \"probabilities\": ");
print_probabilities(e.probabilities);
printf(", \"cubeless_equity\": %.6f, \"cubeful_equity\": %.6f}\n",
printf(", \"cubeless_equity\": " NUMBER ", \"cubeful_equity\": " NUMBER "}\n",
e.cubeless_equity, e.cubeful_equity);
} else {
engine_cube c;
@@ -216,8 +247,65 @@ run_position_command(const options *opt)
print_match(&match);
printf(", \"probabilities\": ");
print_probabilities(c.probabilities);
printf(", \"no_double\": %.6f, \"double_take\": %.6f, \"double_pass\": %.6f, "
"\"decision\": \"%s\"}\n", c.no_double, c.double_take, c.double_pass, c.decision);
printf(", ");
print_cube_fields(&c);
printf("}\n");
}
return EXIT_SUCCESS;
}
/*
* reference : un niveau d'un fichier de résultat de tests/reference/, de
* même structure que plies["<n>"] (voir tests/reference/README.md).
*/
static int
run_reference_command(const options *opt)
{
const char *position_id = opt->args[0], *match_id = opt->args[1];
engine_evaluation e;
engine_status status;
int die1, die2, count, i;
if (opt->arg_count != 2 && opt->arg_count != 3)
return usage_error("arguments attendus : POSITION_ID MATCH_ID [DÉS]");
if (opt->arg_count == 3 && !parse_dice(opt->args[2], &die1, &die2))
return usage_error("dés attendus sous la forme 31");
status = engine_evaluate(position_id, match_id, opt->ply, &e);
if (status != ENGINE_OK)
return print_engine_error(status);
if (opt->arg_count == 3) {
engine_move *moves = malloc(sizeof *moves * ENGINE_MAX_MOVES);
if (!moves)
return print_error("out_of_memory", "mémoire insuffisante", EXIT_ENGINE_ERROR);
status = engine_best_moves(position_id, match_id, die1, die2, opt->ply,
moves, ENGINE_MAX_MOVES, &count);
if (status != ENGINE_OK) {
free(moves);
return print_engine_error(status);
}
print_reference_evaluation(&e);
printf(", \"moves\": {\"legal_move_count\": %d, \"ranking\": [", count);
for (i = 0; i < count && i < REFERENCE_MAX_MOVES; i++) {
printf("%s{\"rank\": %d, \"move\": \"%s\", \"ply\": %d, \"equity\": " NUMBER
", \"equity_diff\": " NUMBER ", \"probabilities\": ",
i ? ", " : "", i + 1, moves[i].notation, moves[i].ply, moves[i].equity,
moves[i].equity - moves[0].equity);
print_probabilities(moves[i].probabilities);
printf("}");
}
printf("]}}\n");
free(moves);
} else {
engine_cube c;
status = engine_cube_decision(position_id, match_id, opt->ply, &c);
if (status != ENGINE_OK)
return print_engine_error(status);
print_reference_evaluation(&e);
printf(", \"cube\": {");
print_cube_fields(&c);
printf("}}\n");
}
return EXIT_SUCCESS;
}
@@ -266,7 +354,7 @@ parse_options(int argc, char **argv, options *opt)
}
}
if (!opt->command)
return "commande attendue : bestmove, moves, legal, cube ou eval";
return "commande attendue : bestmove, moves, legal, cube, eval ou reference";
return NULL;
}
@@ -298,8 +386,10 @@ main(int argc, char **argv)
result = run_moves_command(&opt);
else if (strcmp(opt.command, "eval") == 0 || strcmp(opt.command, "cube") == 0)
result = run_position_command(&opt);
else if (strcmp(opt.command, "reference") == 0)
result = run_reference_command(&opt);
else
result = usage_error("commande inconnue : bestmove, moves, legal, cube ou eval");
result = usage_error("commande inconnue : bestmove, moves, legal, cube, eval ou reference");
engine_shutdown();
return result;
+26 -12
View File
@@ -25,6 +25,10 @@
/* Nombre de castors autorisés en partie libre : valeur par défaut de gnubg. */
#define DEFAULT_BEAVERS 3
/* Seuil d'erreur « douteuse » de gnubg (arSkillLevel[SKILL_DOUBTFUL] dans
* gnubg.c), que la commande « hint » passe à FindnSaveBestMoves. */
#define DOUBTFUL_THRESHOLD 0.03f
/* Fichier de table d'équité de match : celui de gnubg de bureau par défaut. */
#define MET_FILE "Kazaross-XG2.xml"
@@ -236,7 +240,8 @@ fill_move(engine_move *out, const move *m, const position *pos, int evaluated)
out->ply = evaluated ? (int) m->esMove.ec.nPlies : -1;
for (i = 0; i < ENGINE_NUM_PROBABILITIES; i++)
out->probabilities[i] = evaluated ? m->arEvalMove[i] : 0.0f;
out->equity = evaluated ? normalized_equity(m->rScore, &pos->cube) : 0.0f;
/* ScoreMove a déjà converti rScore en équité normalisée en match. */
out->equity = evaluated ? m->rScore : 0.0f;
}
engine_status
@@ -282,10 +287,17 @@ engine_best_moves(const char *position_id, const char *match_id, int die1, int d
if (status != ENGINE_OK)
return status;
/* Comme la commande « hint » de gnubg, avec les filtres « normaux ». */
if (FindnSaveBestMoves(&list, die1, die2, (ConstTanBoard) pos.board, NULL, FALSE, 0.0f,
&pos.cube, &context, aaamfMoveFilterSettings[0]) < 0)
/*
* Comme la commande « hint » de gnubg (hint_move dans gnubg.c), avec le
* filtre « Normal » : fAnalyse vrai (un coup évalué moins profondément
* ne peut pas passer devant un coup mieux évalué), puis RefreshMoveList,
* qui classe d'abord par niveau d'évaluation et départage les égalités.
*/
if (FindnSaveBestMoves(&list, die1, die2, (ConstTanBoard) pos.board, NULL, TRUE,
DOUBTFUL_THRESHOLD, &pos.cube, &context,
aaamfMoveFilterSettings[0]) < 0)
return ENGINE_ERROR_INTERNAL;
RefreshMoveList(&list, NULL);
status = ENGINE_OK;
if (list.cMoves > (unsigned int) max_moves)
@@ -327,14 +339,14 @@ engine_evaluate(const char *position_id, const char *match_id, int ply,
return ENGINE_OK;
}
/* Noms anglais des décisions, dans l'ordre de l'énumération cubedecision. */
/* Noms des décisions, dans l'ordre de l'énumération cubedecision (eval.h). */
static const char *const cube_decision_names[] = {
"double_take", "double_pass", "no_double_take", "too_good_take", "too_good_pass",
"double_beaver", "no_double_beaver", "redouble_take", "redouble_pass",
"no_redouble_take", "too_good_redouble_take", "too_good_redouble_pass",
"no_redouble_beaver", "no_double_dead_cube", "no_redouble_dead_cube",
"not_available", "optional_double_take", "optional_redouble_take",
"optional_double_beaver", "optional_double_pass", "optional_redouble_pass"
"DOUBLE_TAKE", "DOUBLE_PASS", "NODOUBLE_TAKE", "TOOGOOD_TAKE", "TOOGOOD_PASS",
"DOUBLE_BEAVER", "NODOUBLE_BEAVER", "REDOUBLE_TAKE", "REDOUBLE_PASS",
"NO_REDOUBLE_TAKE", "TOOGOODRE_TAKE", "TOOGOODRE_PASS",
"NO_REDOUBLE_BEAVER", "NODOUBLE_DEADCUBE", "NO_REDOUBLE_DEADCUBE",
"NOT_AVAILABLE", "OPTIONAL_DOUBLE_TAKE", "OPTIONAL_REDOUBLE_TAKE",
"OPTIONAL_DOUBLE_BEAVER", "OPTIONAL_DOUBLE_PASS", "OPTIONAL_REDOUBLE_PASS"
};
engine_status
@@ -343,7 +355,7 @@ engine_cube_decision(const char *position_id, const char *match_id, int ply, eng
position pos;
evalcontext context;
float output[2][NUM_ROLLOUT_OUTPUTS];
float equities[4];
float equities[NUM_CUBEFUL_OUTPUTS];
cubedecision decision;
engine_status status;
int i;
@@ -366,6 +378,8 @@ engine_cube_decision(const char *position_id, const char *match_id, int ply, eng
cube->no_double = equities[OUTPUT_NODOUBLE];
cube->double_take = equities[OUTPUT_TAKE];
cube->double_pass = equities[OUTPUT_DROP];
cube->optimal = equities[OUTPUT_OPTIMAL];
cube->decision = cube_decision_names[decision];
cube->decision_text = GetCubeRecommendation(decision);
return ENGINE_OK;
}
+11 -6
View File
@@ -89,9 +89,13 @@ typedef struct {
float no_double; /* équité si pas de double */
float double_take; /* équité si double et prend */
float double_pass; /* équité si double et passe */
const char *decision; /* décision de gnubg, en anglais : « no_double_take »,
* « double_take », « double_pass »,
* « too_good_pass », « not_available »… */
float optimal; /* équité après les meilleures décisions des deux
* joueurs */
const char *decision; /* nom de la décision dans l'énumération
* cubedecision de gnubg : « NODOUBLE_TAKE »,
* « DOUBLE_TAKE », « DOUBLE_PASS »,
* « TOOGOOD_PASS », « NOT_AVAILABLE »… */
const char *decision_text; /* libellé anglais de gnubg, ex. « Double, take » */
} engine_cube;
/* Description lisible d'un Match ID décodé. */
@@ -132,9 +136,10 @@ engine_status engine_legal_moves(const char *position_id, const char *match_id,
engine_move *moves, int max_moves, int *count);
/*
* Coups légaux classés du meilleur au moins bon, au niveau ply. Comme dans
* gnubg, les coups écartés par les filtres gardent l'évaluation du niveau
* inférieur (voir leur champ ply).
* Coups légaux classés du meilleur au moins bon, au niveau ply, exactement
* comme la commande « hint » de gnubg (filtre de coups « Normal »). Les coups
* écartés par les filtres gardent l'évaluation du niveau inférieur (voir
* leur champ ply) et sont classés après ceux évalués plus profondément.
*/
engine_status engine_best_moves(const char *position_id, const char *match_id,
int die1, int die2, int ply,
+86 -6
View File
@@ -12,13 +12,15 @@ d'un fichier de résultat.
| `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 |
| `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["<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.
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.
+169
View File
@@ -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()
+6 -6
View File
@@ -2,8 +2,8 @@
# Tests du moteur embarqué, via build/engine-cli (étape 0a.3) :
# meilleurs coups d'ouverture connus, nombre de coups légaux, bornes et
# symétrie des probabilités, décision de videau évidente, identifiants
# invalides. La comparaison systématique au gnubg de bureau viendra ensuite
# (tests/reference/, étape 0a.4).
# invalides. La comparaison systématique au gnubg de bureau est faite par
# tests/test_engine_reference.sh.
#
# Lance `make engine` (sans effet si tout est à jour).
@@ -34,7 +34,7 @@ echouer() {
# Valeur d'une clé JSON numérique ou textuelle (première occurrence).
valeur() {
grep -oE "\"$1\": (\"[^\"]*\"|[-0-9.]+)" <<<"$2" | head -n1 | sed -E 's/^"[^"]*": "?//; s/"$//'
grep -oE "\"$1\": (\"[^\"]*\"|[-0-9.eE+]+)" <<<"$2" | head -n1 | sed -E 's/^"[^"]*": "?//; s/"$//'
}
# Évalue une condition numérique avec awk ; ex. : vrai "0 <= 0.5 && 0.5 <= 1"
@@ -104,19 +104,19 @@ verifier_probabilites "coup 64 à 2 plis" "$($cli --ply 2 bestmove "$depart" "$l
for match_id in "$libre_j0" "$crawford"; do
decision="$(valeur decision "$($cli cube "$depart" "$match_id")")"
case "$decision" in
no_double* | no_redouble* | not_available) ;;
NODOUBLE_* | NO_REDOUBLE_* | NOT_AVAILABLE) ;;
*) echouer "videau au départ ($match_id) : « $decision » au lieu de pas de double" ;;
esac
done
# Partie Crawford : le videau n'est pas disponible.
decision="$(valeur decision "$($cli cube "$depart" "$crawford")")"
[ "$decision" = "no_double_dead_cube" ] || [ "$decision" = "not_available" ] \
[ "$decision" = "NODOUBLE_DEADCUBE" ] || [ "$decision" = "NOT_AVAILABLE" ] \
|| echouer "videau en partie Crawford : « $decision »"
# À l'inverse, la course gagnée d'avance est un double/passe (règle Jacoby :
# jouer pour le gammon ne rapporte rien, videau au centre).
decision="$(valeur decision "$($cli cube "$course" "$libre_j0")")"
[ "$decision" = "double_pass" ] || echouer "videau en course gagnée : « $decision »"
[ "$decision" = "DOUBLE_PASS" ] || echouer "videau en course gagnée : « $decision »"
# 5. Erreurs : JSON, code d'erreur précis et code de sortie non nul.
attendre_erreur() {
+21
View File
@@ -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 "$@"
+12 -3
View File
@@ -2,12 +2,21 @@
# Vérifie la cohérence des positions de référence (tests/reference/) :
# JSON valide, au moins 30 positions, chaque catégorie présente, un résultat
# par position aux niveaux 0 et 2 plis avec tous les champs du format
# (voir tests/reference/README.md). Ne lance pas Docker ni gnubg.
# (voir tests/reference/README.md). Ne lance pas gnubg : jq tourne dans
# l'image de la chaîne C (ADR 0004), dans laquelle le script se relance.
set -euo pipefail
cd "$(dirname "$0")/reference"
cd "$(dirname "$0")/.."
command -v jq >/dev/null || { echo "jq est requis : sudo apt install jq"; exit 1; }
if [ -z "${USTAGAMMON_IN_DOCKER:-}" ]; then
image="${BUILD_C_IMAGE:?lancer via make test}"
docker image inspect "$image" >/dev/null 2>&1 \
|| { echo "Chaîne C absente : lancer d'abord make setup"; exit 1; }
exec docker run --rm --user "$(id -u):$(id -g)" -e USTAGAMMON_IN_DOCKER=1 \
-v "$PWD:/src:ro" -w /src "$image" bash tests/test_reference.sh
fi
cd tests/reference
erreurs=0
signaler() {