From 856e4d64bd7ddb1d5ff5cdb600d3eab23841c3f2 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 14:04:14 +0100 Subject: [PATCH 1/7] =?UTF-8?q?Plan=20de=20l'=C3=A9tape=200a.4=20:=20compa?= =?UTF-8?q?raison=20aux=20r=C3=A9f=C3=A9rences?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/feuille-de-route.md | 2 +- .../2026-11-02-comparaison-references.md | 76 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-11-02-comparaison-references.md diff --git a/docs/feuille-de-route.md b/docs/feuille-de-route.md index f4c7a8b..12f4f08 100644 --- a/docs/feuille-de-route.md +++ b/docs/feuille-de-route.md @@ -14,7 +14,7 @@ Acquis : socle du dépôt, organisation (ADR 0001, 0003, 0006), source du moteur | Sous-étape | Contenu | État | Plan | | --- | --- | --- | --- | -| 0a.4 | `make test` compare `engine-cli` aux références | ⏳ | | +| 0a.4 | `make test` compare `engine-cli` aux références | 🔄 | `plans/2026-11-02-comparaison-references.md` | | 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/docs/plans/2026-11-02-comparaison-references.md b/docs/plans/2026-11-02-comparaison-references.md new file mode 100644 index 0000000..9e85935 --- /dev/null +++ b/docs/plans/2026-11-02-comparaison-references.md @@ -0,0 +1,76 @@ +# Comparer le moteur embarqué aux références de gnubg de bureau + +- **Date** : 2026-11-02 +- **Étape** : 0a.4 +- **Statut** : en cours +- **Exécutant** : sous-agent general-purpose, dans un worktree isolé + +## Objectif + +`make test` vérifie automatiquement que le moteur embarqué (`build/engine-cli`) +retrouve les résultats de gnubg de bureau sur les 32 positions de +`tests/reference/`, aux niveaux 0 et 2 plis, avec une tolérance mesurée et +justifiée. + +## Consignes + +1. **Sortie d'`engine-cli` alignée sur le contrat** de + `tests/reference/README.md` : une sous-commande (par exemple + `engine-cli reference [dés]`, ou une option) produit, + pour une position et un niveau, un objet de même structure que la partie + `plies[""]` d'un fichier de résultat (`evaluation`, `moves` ou `cube`). + Le classement des coups suit exactement la méthode de la commande `hint` de + gnubg (documentée dans le README du format) : nombre de coups, notation, + pli réel de chaque coup. Garder `engine-cli` simple : réutiliser les + fonctions d'`engine.h`, compléter `engine.h` si nécessaire. +2. **Comparateur** : un script lisible (Python 3 ou shell avec `jq`) qui, pour + chaque position et chaque niveau, appelle `engine-cli`, compare au résultat + de référence et affiche un rapport clair (position, niveau, champ, valeur + attendue, valeur obtenue, écart). Il tourne **dans Docker** (ajouter ce qu'il + faut à `docker/build-c/Dockerfile` et changer l'étiquette de l'image dans le + `Makefile`), jamais avec les outils du PC (ADR 0004). +3. **Critères de concordance** : + - même nombre de coups légaux ; + - même meilleur coup, sauf si gnubg lui-même sépare les deux premiers coups + de moins que la tolérance (cas d'égalité : le dire dans le rapport) ; + - même pli réel du meilleur coup ; + - équités et probabilités à la tolérance près, pour les coups communs ; + - même décision de videau, et équités du videau à la tolérance près. +4. **Tolérance mesurée, pas devinée** : mesurer l'écart maximal observé sur + les 32 positions, l'expliquer (gnubg de bureau en SSE2 avec `-ffast-math`, + moteur embarqué en C générique), et mesurer aussi l'écart d'un build du + moteur sans `-ffast-math`. Fixer la tolérance avec une marge raisonnable et + la justifier dans `tests/reference/README.md`. +5. **`tests/test_engine_reference.sh`**, lancé par `make test` : lance + `make engine` si nécessaire puis le comparateur ; échoue avec le rapport si + un critère n'est pas tenu. +6. **`tests/test_reference.sh`** n'utilise plus `jq` sur le PC : passer par + l'image Docker de la chaîne C (ADR 0004), et mettre `CREDITS.md` à jour. + +Contraintes : +- respecter `CLAUDE.md` et les ADR 0004, 0005 et 0007 : simplicité, identifiants + en anglais, commentaires en français, aucune installation hors Docker, + `-j2` ; +- ne pas modifier les références (`tests/reference/results/`) ni les fichiers + de `engine/gnubg/` ; si une divergence vient d'un vrai défaut du moteur + embarqué, corriger dans `engine/compat/` ou `engine/src`, et le dire ; +- ne rien masquer : si une position ne concorde pas, le rapport doit le + montrer, avec la cause trouvée ou les hypothèses ; +- `make lint` et `make test` doivent passer ; commits petits, en français, avec + la ligne `Co-Authored-By: Claude Opus 5.5 ` ; +- ne pas pousser ni créer de merge request. + +Rapport attendu : ce qui a été fait, la tolérance retenue et sa justification +(écarts mesurés avec et sans `-ffast-math`), les éventuelles divergences et +leur cause, les commandes de vérification. + +## Critères de réussite + +- `make test` compare les 32 positions aux deux niveaux et passe. +- La tolérance est documentée et justifiée par des mesures. +- Une divergence volontairement introduite (par exemple une référence + modifiée dans une copie) fait échouer le test avec un rapport lisible. + +## Résultat + +À remplir à la clôture. From be6a4b44462c747d57056d5d6924b52e7e11694f Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 14:18:10 +0100 Subject: [PATCH 2/7] Moteur : classement exact de hint et commande engine-cli reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - engine_best_moves suit hint_move de gnubg : fAnalyse vrai, seuil « douteux », puis RefreshMoveList (classement par niveau d'évaluation et départage des égalités). - Correction : en match, l'équité des coups était convertie deux fois par mwc2eq (ScoreMove la convertit déjà). - engine_cube : ajout de optimal et decision_text ; decision prend le nom de l'énumération cubedecision de gnubg, comme les références. - engine-cli : commande reference (un niveau au format de tests/reference/) et nombres écrits avec 9 chiffres significatifs. Co-Authored-By: Claude Opus 5.5 --- engine-cli/main.c | 106 +++++++++++++++++++++++++++++++++++++--- engine/engine.c | 38 +++++++++----- engine/include/engine.h | 17 ++++--- tests/test_engine.sh | 12 ++--- 4 files changed, 141 insertions(+), 32 deletions(-) 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/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() { From ae166d51e5aea9c4e133b87ea2aeb7cf96d088d7 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 14:18:10 +0100 Subject: [PATCH 3/7] =?UTF-8?q?Cha=C3=AEne=20C=20:=20Python=203=20et=20jq?= =?UTF-8?q?=20;=20test=5Freference.sh=20tourne=20dans=20Docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jq n'est plus utilisé sur le PC (ADR 0004) : tests/test_reference.sh se relance dans l'image ustagammon/build-c:2, qui contient aussi Python 3 pour le comparateur du moteur. Co-Authored-By: Claude Opus 5.5 --- CREDITS.md | 7 ++++--- Makefile | 2 +- docker/build-c/Dockerfile | 12 +++++++----- tests/test_reference.sh | 15 ++++++++++++--- 4 files changed, 24 insertions(+), 12 deletions(-) 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/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() { From 83cb1a6f83c7ae781840b8afb4fa0abafe9d28e7 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 14:18:10 +0100 Subject: [PATCH 4/7] =?UTF-8?q?Tests=20:=20comparaison=20du=20moteur=20emb?= =?UTF-8?q?arqu=C3=A9=20aux=20r=C3=A9f=C3=A9rences=20de=20gnubg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/test_engine_reference.sh lance tests/reference/compare.py sur les 32 positions aux niveaux 0 et 2 plis. Tolérance de 0,002 mesurée et justifiée dans tests/reference/README.md : écart maximal de 9,5e-4, avec ou sans -ffast-math ; la cause est la sigmoïde approchée (_mm_rcp_ps) de gnubg de bureau en SSE2, qu'un build SSE2 du moteur reproduit au bit près. Co-Authored-By: Claude Opus 5.5 --- tests/reference/README.md | 92 ++++++++++++++++-- tests/reference/compare.py | 169 +++++++++++++++++++++++++++++++++ tests/test_engine_reference.sh | 21 ++++ 3 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 tests/reference/compare.py create mode 100644 tests/test_engine_reference.sh diff --git a/tests/reference/README.md b/tests/reference/README.md index 0593be5..f7f68ea 100644 --- a/tests/reference/README.md +++ b/tests/reference/README.md @@ -12,13 +12,15 @@ d'un fichier de résultat. | `positions.json` | Positions d'entrée, versionnées, écrites à la main | | `results/.json` | Résultats de gnubg, un fichier par position, générés | | `generate.py` | Script exécuté dans gnubg qui produit `results/` | +| `compare.py` | Compare le moteur embarqué à `results/` (dernière section) | Commandes : ``` make reference-image # construit l'image Docker du gnubg de référence make reference # régénère results/ (une vingtaine de secondes) -make test # vérifie la cohérence (tests/test_reference.sh) +make test # vérifie la cohérence (tests/test_reference.sh) et + # compare le moteur embarqué (tests/test_engine_reference.sh) ``` `make reference` est déterministe : deux exécutions donnent des fichiers @@ -222,8 +224,86 @@ Sortie de `gnubg.cfevaluate` (`GeneralCubeDecisionE` puis ## Comparaison avec le moteur embarqué -Une concordance au bit près n'est pas attendue (ADR 0005) : `-ffast-math` et -le jeu d'instructions (SSE2 ici, NEON sur téléphone, C générique en -WebAssembly) changent les derniers chiffres. Les tests de concordance devront -donc comparer les nombres avec une tolérance, et accepter qu'entre deux coups -d'équités presque égales l'ordre s'inverse. +`make test` compare le moteur embarqué à ces résultats +(`tests/test_engine_reference.sh`). Pour chaque position et chaque niveau, le +comparateur `compare.py` (Python 3, dans l'image Docker de la chaîne C) lance + +``` +build/engine-cli --ply N reference POSITION_ID MATCH_ID [DÉS] +``` + +qui produit un objet de même structure que `plies[""]` : `evaluation`, +puis `moves` (avec les dés) ou `cube` (sans). Le classement des coups suit la +méthode de `hint` décrite plus haut (`engine_best_moves` dans +`engine/engine.c`). Pour voir tous les nombres comparés : +`BUILD_C_IMAGE=ustagammon/build-c:2 tests/test_engine_reference.sh --details`. + +Une concordance au bit près n'est pas attendue (ADR 0005) : le jeu +d'instructions (SSE2 pour gnubg de bureau, C générique pour le moteur +embarqué sur PC et en WebAssembly, NEON sur téléphone) change les derniers +chiffres. Les nombres sont donc comparés avec une tolérance. + +### Critères + +Pour chaque position et chaque niveau : + +- même nombre de coups légaux ; +- même meilleur coup, sauf si gnubg lui-même sépare ses deux premiers coups + de moins que la tolérance : c'est alors une égalité, signalée par une + remarque dans le rapport, sans échec ; +- même pli réel du meilleur coup ; +- pour chaque coup présent dans les deux classements (10 premiers), équité + et cinq probabilités à la tolérance près ; +- évaluation de la position (cinq probabilités et équité) à la tolérance + près ; +- même décision de videau (`decision` et `decision_text`), et les quatre + équités du videau à la tolérance près. + +Le rapport donne, pour chaque écart hors critères, la position et le niveau, +le champ, la valeur attendue (gnubg), la valeur obtenue (moteur) et l'écart ; +il donne toujours l'écart maximal observé. + +### Tolérance : 0,002 + +Une même tolérance absolue s'applique aux probabilités et aux équités +(équités ramenées à un videau de 1). Elle vient de mesures faites le +2026-11-02 sur les 32 positions aux deux niveaux (2 598 nombres comparés) : + +| Moteur comparé à gnubg de bureau | 99 % des écarts sous | Écart maximal | +| --- | --- | --- | +| Moteur embarqué tel que compilé (C générique, `-O3 -ffast-math`) | 1,6e-4 | 9,5e-4 | +| Le même sans `-ffast-math` | 1,6e-4 | 9,5e-4 | +| Le même compilé en SSE2 comme gnubg de bureau (essai, non retenu) | 0 | 0 (float32 identiques) | + +Ce que ces mesures montrent : + +- **`-ffast-math` n'est pas en cause côté moteur embarqué** : avec ou sans, + le moteur en C générique donne les mêmes classements, et ses nombres + diffèrent entre eux de 1,1e-6 au plus. +- **La cause est le code SIMD de gnubg de bureau.** En SSE2 avec + `-ffast-math`, la sigmoïde du réseau de neurones (`sigmoid_positive_ps` + dans `lib/neuralnetsse.c`) calcule `1/x` avec l'instruction approchée + `_mm_rcp_ps` (environ 12 bits de précision), alors que la version en C + générique (`sigmoid` dans `lib/sigmoid.h`) fait une vraie division. L'essai + le prouve : le même moteur compilé en SSE2 (`-DUSE_SIMD_INSTRUCTIONS + -DHAVE_SSE -DUSE_SSE2`) redonne les références au bit près, sur les 2 604 + nombres comparés (classements identiques, donc un peu plus de coups + communs). +- **L'écart reste en général de l'ordre de 1e-4**, sauf un cas : + `opening-64-match` à 2 plis, coup `8/2 6/2`, écart de 9,5e-4 alors que les + autres coups de la même position restent sous 1e-5. Une recherche à 2 plis + fait des choix discrets (meilleure réponse pour chaque jet, élagage) : quand + deux choix sont presque à égalité, un écart de 1e-4 peut en faire basculer + un, et l'écart final grandit d'autant. + +La tolérance de 0,002 vaut environ deux fois l'écart maximal mesuré, pour +absorber d'autres bascules de ce genre sans masquer un vrai défaut : elle +reste quinze fois plus petite que le seuil à partir duquel gnubg qualifie un +coup de « douteux » (0,03) et correspond à deux unités du dernier chiffre +qu'affiche gnubg (trois décimales). Les vrais défauts (mauvais niveau, +mauvais contexte de videau, mauvaise conversion d'équité) donnent des écarts +d'un ou plusieurs centièmes. + +Sur téléphone (NEON) les écarts seront à mesurer de la même façon : avec +`-ffast-math`, gnubg y remplace aussi la division par une approximation +(`vrecpeq_f32` et une itération de Newton), différente de celle de SSE2. diff --git a/tests/reference/compare.py b/tests/reference/compare.py new file mode 100644 index 0000000..6e1f90a --- /dev/null +++ b/tests/reference/compare.py @@ -0,0 +1,169 @@ +"""Compare le moteur embarqué aux résultats de gnubg de bureau. + +Pour chaque fichier de résultats/ et chaque niveau, lance +`engine-cli --ply N reference POSITION_ID MATCH_ID [DÉS]` et vérifie les +critères de concordance décrits dans README.md (section « Comparaison avec +le moteur embarqué »). Affiche un rapport et sort en erreur si un critère +n'est pas tenu. + +Usage (dans l'image Docker ustagammon/build-c, voir +tests/test_engine_reference.sh) : + + python3 tests/reference/compare.py [--cli build/engine-cli] + [--results tests/reference/results] [--tolerance 0.002] [--details] +""" + +import argparse +import glob +import json +import os +import subprocess +import sys + +# Écart maximal accepté sur une probabilité ou une équité : voir la +# justification dans README.md. +TOLERANCE = 0.002 + +PROBABILITIES = ("win", "win_gammon", "win_backgammon", "lose_gammon", "lose_backgammon") +CUBE_EQUITIES = ("no_double", "double_take", "double_pass", "optimal") + + +class Report: + """Collecte les écarts d'une comparaison.""" + + def __init__(self, tolerance): + self.tolerance = tolerance + self.failures = [] # critères non tenus + self.notes = [] # remarques qui ne font pas échouer + self.lines = [] # tous les nombres comparés (--details) + self.max_gap = 0.0 + self.max_gap_where = "" + + def number(self, where, field, expected, actual): + gap = abs(actual - expected) + line = (where, field, "%.9g" % expected, "%.9g" % actual, "%.2e" % gap) + self.lines.append(line) + if gap > self.max_gap: + self.max_gap = gap + self.max_gap_where = "%s %s" % (where, field) + if gap > self.tolerance: + self.failures.append(line) + + def same(self, where, field, expected, actual): + if expected != actual: + self.failures.append((where, field, str(expected), str(actual), "différent")) + + +def run_engine(cli, position, ply): + command = [cli, "--ply", str(ply), "reference", position["position_id"], + position["match_id"]] + dice = position["context"]["dice"] + if dice: + command.append("%d%d" % tuple(dice)) + output = subprocess.run(command, capture_output=True, text=True) + if output.returncode != 0: + raise SystemExit("%s a échoué : %s" % (" ".join(command), output.stdout + output.stderr)) + return json.loads(output.stdout) + + +def compare_probabilities(report, where, expected, actual): + for name in PROBABILITIES: + report.number(where, name, expected[name], actual[name]) + + +def compare_moves(report, where, expected, actual): + report.same(where, "legal_move_count", expected["legal_move_count"], + actual["legal_move_count"]) + reference = expected["ranking"] + engine = actual["ranking"] + if not reference or not engine: + report.same(where, "ranking", [m["move"] for m in reference], [m["move"] for m in engine]) + return + + # Meilleur coup : le même, sauf si gnubg lui-même ne sépare ses deux + # premiers coups que de moins que la tolérance (égalité). + best, engine_best = reference[0]["move"], engine[0]["move"] + if best != engine_best: + tied = len(reference) > 1 and -reference[1]["equity_diff"] <= report.tolerance + if tied: + report.notes.append("%s : meilleur coup %s pour gnubg, %s pour le moteur ; " + "égalité, gnubg ne les sépare que de %.2e" + % (where, best, engine_best, -reference[1]["equity_diff"])) + else: + report.same(where, "meilleur coup", best, engine_best) + + # Coups présents dans les deux classements : même pli pour le meilleur + # coup de gnubg, équités et probabilités à la tolérance près pour tous. + by_move = {m["move"]: m for m in engine} + for m in reference: + other = by_move.get(m["move"]) + if other is None: + continue + here = "%s %s" % (where, m["move"]) + if m is reference[0]: + report.same(here, "ply", m["ply"], other["ply"]) + report.number(here, "equity", m["equity"], other["equity"]) + compare_probabilities(report, here, m["probabilities"], other["probabilities"]) + + +def compare_cube(report, where, expected, actual): + report.same(where, "decision", expected["decision"], actual["decision"]) + report.same(where, "decision_text", expected["decision_text"], actual["decision_text"]) + for name in CUBE_EQUITIES: + report.number(where, name, expected[name], actual[name]) + + +def compare_position(report, cli, path): + with open(path, encoding="utf-8") as f: + position = json.load(f) + for ply, expected in sorted(position["plies"].items()): + actual = run_engine(cli, position, int(ply)) + where = "%s à %s pli(s)" % (position["id"], ply) + compare_probabilities(report, where + " évaluation", + expected["evaluation"]["probabilities"], + actual["evaluation"]["probabilities"]) + report.number(where + " évaluation", "equity", expected["evaluation"]["equity"], + actual["evaluation"]["equity"]) + if "moves" in expected: + compare_moves(report, where, expected["moves"], actual["moves"]) + else: + compare_cube(report, where, expected["cube"], actual["cube"]) + return len(position["plies"]) + + +def print_table(lines): + header = ("position et niveau", "champ", "attendu", "obtenu", "écart") + rows = [header] + lines + widths = [max(len(row[i]) for row in rows) for i in range(len(header))] + for row in rows: + print(" " + " ".join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip()) + + +def main(): + here = os.path.dirname(os.path.abspath(__file__)) + parser = argparse.ArgumentParser(description="Compare engine-cli aux références de gnubg.") + parser.add_argument("--cli", default="build/engine-cli") + parser.add_argument("--results", default=os.path.join(here, "results")) + parser.add_argument("--tolerance", type=float, default=TOLERANCE) + parser.add_argument("--details", action="store_true", + help="affiche tous les nombres comparés") + args = parser.parse_args() + + report = Report(args.tolerance) + paths = sorted(glob.glob(os.path.join(args.results, "*.json"))) + levels = sum(compare_position(report, args.cli, path) for path in paths) + + if args.details: + print_table(report.lines) + for note in report.notes: + print(" Remarque : " + note) + print(" %d positions, %d niveaux, %d nombres comparés ; écart maximal %.2e (%s), " + "tolérance %.0e." % (len(paths), levels, len(report.lines), report.max_gap, + report.max_gap_where, args.tolerance)) + if report.failures: + print(" %d écart(s) hors critères :" % len(report.failures)) + print_table(report.failures) + sys.exit(1) + + +main() diff --git a/tests/test_engine_reference.sh b/tests/test_engine_reference.sh new file mode 100644 index 0000000..87b80df --- /dev/null +++ b/tests/test_engine_reference.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Compare le moteur embarqué (build/engine-cli) aux résultats de gnubg de +# bureau sur toutes les positions de tests/reference/, aux niveaux 0 et 2 +# plis. Critères et tolérance : tests/reference/README.md, section +# « Comparaison avec le moteur embarqué ». Le comparateur +# (tests/reference/compare.py) tourne dans l'image de la chaîne C (ADR 0004). +# +# Lance `make engine` (sans effet si tout est à jour). Les arguments sont +# passés au comparateur, par exemple --details pour voir tous les nombres. + +set -euo pipefail +cd "$(dirname "$0")/.." + +image="${BUILD_C_IMAGE:?lancer via make test}" + +# Lancé depuis `make test` : repartir d'un make indépendant du make parent. +unset MAKEFLAGS MFLAGS MAKELEVEL +make engine >/dev/null + +docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/src:ro" -w /src "$image" \ + python3 tests/reference/compare.py "$@" From 15939802ceb55d4217f1bf39c3158b9723ab5bee Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 14:19:18 +0100 Subject: [PATCH 5/7] =?UTF-8?q?Plan=200a.4=20:=20r=C3=A9sultat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../2026-11-02-comparaison-references.md | 48 ++++++++++++++++++- tests/reference/README.md | 2 +- tests/test_engine_reference.sh | 0 3 files changed, 47 insertions(+), 3 deletions(-) mode change 100644 => 100755 tests/test_engine_reference.sh diff --git a/docs/plans/2026-11-02-comparaison-references.md b/docs/plans/2026-11-02-comparaison-references.md index 9e85935..4dc2aec 100644 --- a/docs/plans/2026-11-02-comparaison-references.md +++ b/docs/plans/2026-11-02-comparaison-references.md @@ -2,7 +2,7 @@ - **Date** : 2026-11-02 - **Étape** : 0a.4 -- **Statut** : en cours +- **Statut** : terminé - **Exécutant** : sous-agent general-purpose, dans un worktree isolé ## Objectif @@ -73,4 +73,48 @@ leur cause, les commandes de vérification. ## Résultat -À remplir à la clôture. +Terminé le 2026-11-02, branche `comparaison-travail`. `make lint` et +`make test` passent (7/7) ; les 32 positions concordent aux deux niveaux +avec la tolérance retenue, sans aucune égalité de meilleur coup à signaler. + +**Fait.** + +- `engine-cli --ply N reference POSITION_ID MATCH_ID [DÉS]` produit un niveau + au format de `tests/reference/` (`evaluation`, puis `moves` ou `cube`). + Nombres écrits avec 9 chiffres significatifs (float32 exact). +- `tests/reference/compare.py` (Python 3) et `tests/test_engine_reference.sh` + (lancé par `make test`) : comparaison dans l'image `ustagammon/build-c:2`, + qui reçoit Python 3 et jq. `tests/test_reference.sh` se relance dans cette + image : plus de jq sur le PC. `CREDITS.md` à jour. +- Critères, tolérance et mesures documentés dans `tests/reference/README.md`. + +**Défauts du moteur embarqué trouvés et corrigés** (`engine/engine.c`) : + +- en match, l'équité des coups classés était convertie deux fois par + `mwc2eq` (`ScoreMove` la convertit déjà) ; +- `engine_best_moves` n'appliquait pas tout `hint` : `fAnalyse` était faux + et `RefreshMoveList` absent (classement par niveau d'évaluation, puis + départage des égalités). + +Changements d'interface : `engine_cube` gagne `optimal` et `decision_text` ; +`decision` prend le nom de l'énumération de gnubg (`DOUBLE_TAKE`…), comme +les références (`tests/test_engine.sh` adapté). + +**Tolérance : 0,002**, sur probabilités et équités. Mesures sur 2 598 +nombres : écart maximal 9,5e-4 et 99 % des écarts sous 1,6e-4, que le moteur +soit compilé avec ou sans `-ffast-math` (les deux builds diffèrent entre eux +de 1,1e-6 au plus). La cause est la sigmoïde de gnubg de bureau en SSE2 avec +`-ffast-math`, qui calcule `1/x` par `_mm_rcp_ps` (environ 12 bits) ; preuve : +le moteur compilé en SSE2 pour l'essai redonne les références au bit près. +L'écart maximal vient d'un seul coup (`opening-64-match`, 2 plis, `8/2 6/2`), +où l'écart de 1e-4 fait basculer un choix discret de la recherche. + +**Critère de divergence vérifié** : sur une copie de `results/` avec une +équité décalée de 0,01, un nombre de coups légaux, une décision de videau et +un meilleur coup modifiés, `compare.py --results ` échoue avec un +tableau de 10 écarts lisibles. + +**Suites possibles** : mesurer de même sur téléphone (NEON, autre +approximation de la division) ; envisager un build SSE2 d'`engine-cli` sur PC +si l'on veut une concordance au bit près (non retenu : le moteur de +l'application tournera en C générique ou en NEON). diff --git a/tests/reference/README.md b/tests/reference/README.md index f7f68ea..3474fa2 100644 --- a/tests/reference/README.md +++ b/tests/reference/README.md @@ -236,7 +236,7 @@ qui produit un objet de même structure que `plies[""]` : `evaluation`, puis `moves` (avec les dés) ou `cube` (sans). Le classement des coups suit la méthode de `hint` décrite plus haut (`engine_best_moves` dans `engine/engine.c`). Pour voir tous les nombres comparés : -`BUILD_C_IMAGE=ustagammon/build-c:2 tests/test_engine_reference.sh --details`. +`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 diff --git a/tests/test_engine_reference.sh b/tests/test_engine_reference.sh old mode 100644 new mode 100755 From 4dba402b74f66ab2ce31f3a5aad3872004f87972 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 18:55:13 +0100 Subject: [PATCH 6/7] =?UTF-8?q?=C3=89tape=200a.4=20close=20:=20plan=20supp?= =?UTF-8?q?rim=C3=A9,=20feuille=20de=20route=20=C3=A0=20jour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/feuille-de-route.md | 5 +- .../2026-11-02-comparaison-references.md | 120 ------------------ 2 files changed, 3 insertions(+), 122 deletions(-) delete mode 100644 docs/plans/2026-11-02-comparaison-references.md diff --git a/docs/feuille-de-route.md b/docs/feuille-de-route.md index 12f4f08..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 | 🔄 | `plans/2026-11-02-comparaison-references.md` | | 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/docs/plans/2026-11-02-comparaison-references.md b/docs/plans/2026-11-02-comparaison-references.md deleted file mode 100644 index 4dc2aec..0000000 --- a/docs/plans/2026-11-02-comparaison-references.md +++ /dev/null @@ -1,120 +0,0 @@ -# Comparer le moteur embarqué aux références de gnubg de bureau - -- **Date** : 2026-11-02 -- **Étape** : 0a.4 -- **Statut** : terminé -- **Exécutant** : sous-agent general-purpose, dans un worktree isolé - -## Objectif - -`make test` vérifie automatiquement que le moteur embarqué (`build/engine-cli`) -retrouve les résultats de gnubg de bureau sur les 32 positions de -`tests/reference/`, aux niveaux 0 et 2 plis, avec une tolérance mesurée et -justifiée. - -## Consignes - -1. **Sortie d'`engine-cli` alignée sur le contrat** de - `tests/reference/README.md` : une sous-commande (par exemple - `engine-cli reference [dés]`, ou une option) produit, - pour une position et un niveau, un objet de même structure que la partie - `plies[""]` d'un fichier de résultat (`evaluation`, `moves` ou `cube`). - Le classement des coups suit exactement la méthode de la commande `hint` de - gnubg (documentée dans le README du format) : nombre de coups, notation, - pli réel de chaque coup. Garder `engine-cli` simple : réutiliser les - fonctions d'`engine.h`, compléter `engine.h` si nécessaire. -2. **Comparateur** : un script lisible (Python 3 ou shell avec `jq`) qui, pour - chaque position et chaque niveau, appelle `engine-cli`, compare au résultat - de référence et affiche un rapport clair (position, niveau, champ, valeur - attendue, valeur obtenue, écart). Il tourne **dans Docker** (ajouter ce qu'il - faut à `docker/build-c/Dockerfile` et changer l'étiquette de l'image dans le - `Makefile`), jamais avec les outils du PC (ADR 0004). -3. **Critères de concordance** : - - même nombre de coups légaux ; - - même meilleur coup, sauf si gnubg lui-même sépare les deux premiers coups - de moins que la tolérance (cas d'égalité : le dire dans le rapport) ; - - même pli réel du meilleur coup ; - - équités et probabilités à la tolérance près, pour les coups communs ; - - même décision de videau, et équités du videau à la tolérance près. -4. **Tolérance mesurée, pas devinée** : mesurer l'écart maximal observé sur - les 32 positions, l'expliquer (gnubg de bureau en SSE2 avec `-ffast-math`, - moteur embarqué en C générique), et mesurer aussi l'écart d'un build du - moteur sans `-ffast-math`. Fixer la tolérance avec une marge raisonnable et - la justifier dans `tests/reference/README.md`. -5. **`tests/test_engine_reference.sh`**, lancé par `make test` : lance - `make engine` si nécessaire puis le comparateur ; échoue avec le rapport si - un critère n'est pas tenu. -6. **`tests/test_reference.sh`** n'utilise plus `jq` sur le PC : passer par - l'image Docker de la chaîne C (ADR 0004), et mettre `CREDITS.md` à jour. - -Contraintes : -- respecter `CLAUDE.md` et les ADR 0004, 0005 et 0007 : simplicité, identifiants - en anglais, commentaires en français, aucune installation hors Docker, - `-j2` ; -- ne pas modifier les références (`tests/reference/results/`) ni les fichiers - de `engine/gnubg/` ; si une divergence vient d'un vrai défaut du moteur - embarqué, corriger dans `engine/compat/` ou `engine/src`, et le dire ; -- ne rien masquer : si une position ne concorde pas, le rapport doit le - montrer, avec la cause trouvée ou les hypothèses ; -- `make lint` et `make test` doivent passer ; commits petits, en français, avec - la ligne `Co-Authored-By: Claude Opus 5.5 ` ; -- ne pas pousser ni créer de merge request. - -Rapport attendu : ce qui a été fait, la tolérance retenue et sa justification -(écarts mesurés avec et sans `-ffast-math`), les éventuelles divergences et -leur cause, les commandes de vérification. - -## Critères de réussite - -- `make test` compare les 32 positions aux deux niveaux et passe. -- La tolérance est documentée et justifiée par des mesures. -- Une divergence volontairement introduite (par exemple une référence - modifiée dans une copie) fait échouer le test avec un rapport lisible. - -## Résultat - -Terminé le 2026-11-02, branche `comparaison-travail`. `make lint` et -`make test` passent (7/7) ; les 32 positions concordent aux deux niveaux -avec la tolérance retenue, sans aucune égalité de meilleur coup à signaler. - -**Fait.** - -- `engine-cli --ply N reference POSITION_ID MATCH_ID [DÉS]` produit un niveau - au format de `tests/reference/` (`evaluation`, puis `moves` ou `cube`). - Nombres écrits avec 9 chiffres significatifs (float32 exact). -- `tests/reference/compare.py` (Python 3) et `tests/test_engine_reference.sh` - (lancé par `make test`) : comparaison dans l'image `ustagammon/build-c:2`, - qui reçoit Python 3 et jq. `tests/test_reference.sh` se relance dans cette - image : plus de jq sur le PC. `CREDITS.md` à jour. -- Critères, tolérance et mesures documentés dans `tests/reference/README.md`. - -**Défauts du moteur embarqué trouvés et corrigés** (`engine/engine.c`) : - -- en match, l'équité des coups classés était convertie deux fois par - `mwc2eq` (`ScoreMove` la convertit déjà) ; -- `engine_best_moves` n'appliquait pas tout `hint` : `fAnalyse` était faux - et `RefreshMoveList` absent (classement par niveau d'évaluation, puis - départage des égalités). - -Changements d'interface : `engine_cube` gagne `optimal` et `decision_text` ; -`decision` prend le nom de l'énumération de gnubg (`DOUBLE_TAKE`…), comme -les références (`tests/test_engine.sh` adapté). - -**Tolérance : 0,002**, sur probabilités et équités. Mesures sur 2 598 -nombres : écart maximal 9,5e-4 et 99 % des écarts sous 1,6e-4, que le moteur -soit compilé avec ou sans `-ffast-math` (les deux builds diffèrent entre eux -de 1,1e-6 au plus). La cause est la sigmoïde de gnubg de bureau en SSE2 avec -`-ffast-math`, qui calcule `1/x` par `_mm_rcp_ps` (environ 12 bits) ; preuve : -le moteur compilé en SSE2 pour l'essai redonne les références au bit près. -L'écart maximal vient d'un seul coup (`opening-64-match`, 2 plis, `8/2 6/2`), -où l'écart de 1e-4 fait basculer un choix discret de la recherche. - -**Critère de divergence vérifié** : sur une copie de `results/` avec une -équité décalée de 0,01, un nombre de coups légaux, une décision de videau et -un meilleur coup modifiés, `compare.py --results ` échoue avec un -tableau de 10 écarts lisibles. - -**Suites possibles** : mesurer de même sur téléphone (NEON, autre -approximation de la division) ; envisager un build SSE2 d'`engine-cli` sur PC -si l'on veut une concordance au bit près (non retenu : le moteur de -l'application tournera en C générique ou en NEON). From 4b8e2f73ccd8babfd5c879f99813201b50500929 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 19:04:24 +0100 Subject: [PATCH 7/7] =?UTF-8?q?ADR=200004=20amend=C3=A9=20:=20outils=20de?= =?UTF-8?q?=20l'=C3=A9tape=200=20valid=C3=A9s=20par=20Pierre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ...0004-outils-de-developpement-dans-docker.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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) |