diff --git a/CREDITS.md b/CREDITS.md index 5d28556..358f87d 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -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 diff --git a/Makefile b/Makefile index b31292c..3163d3f 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/docker/build-c/Dockerfile b/docker/build-c/Dockerfile index 95d841d..8722451 100644 --- a/docker/build-c/Dockerfile +++ b/docker/build-c/Dockerfile @@ -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/* diff --git a/docs/adr/0004-outils-de-developpement-dans-docker.md b/docs/adr/0004-outils-de-developpement-dans-docker.md index 953dded..4c89128 100644 --- a/docs/adr/0004-outils-de-developpement-dans-docker.md +++ b/docs/adr/0004-outils-de-developpement-dans-docker.md @@ -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) | diff --git a/docs/feuille-de-route.md b/docs/feuille-de-route.md index f4c7a8b..c63633b 100644 --- a/docs/feuille-de-route.md +++ b/docs/feuille-de-route.md @@ -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 | ⏳ | | diff --git a/engine-cli/main.c b/engine-cli/main.c index 871bfbb..dddcb9b 100644 --- a/engine-cli/main.c +++ b/engine-cli/main.c @@ -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[""] (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; diff --git a/engine/engine.c b/engine/engine.c index 7b1b1bc..1b6a157 100644 --- a/engine/engine.c +++ b/engine/engine.c @@ -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; } diff --git a/engine/include/engine.h b/engine/include/engine.h index 32c6889..39cbaeb 100644 --- a/engine/include/engine.h +++ b/engine/include/engine.h @@ -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, diff --git a/tests/reference/README.md b/tests/reference/README.md index 0593be5..3474fa2 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 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. 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.sh b/tests/test_engine.sh index f5f9294..c09f1e6 100644 --- a/tests/test_engine.sh +++ b/tests/test_engine.sh @@ -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() { diff --git a/tests/test_engine_reference.sh b/tests/test_engine_reference.sh new file mode 100755 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 "$@" diff --git a/tests/test_reference.sh b/tests/test_reference.sh index 8e9a58b..dd5bfed 100644 --- a/tests/test_reference.sh +++ b/tests/test_reference.sh @@ -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() {