Étape 0a.4 : comparaison du moteur aux références de gnubg #8
+4
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
@@ -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() {
|
||||
|
||||
Executable
+21
@@ -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
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user