Files
Pierre MARTINandClaude Opus 5.5 b199698b11 Positions de référence évaluées par gnubg (0 et 2 plis)
32 positions (ouvertures, milieu, prime, blitz, course, sortie, videau,
match, Crawford, Jacoby), générées par make reference ; format documenté
dans tests/reference/README.md, vérifié par tests/test_reference.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:57:14 +01:00

201 lines
7.4 KiB
Python

# Générateur des résultats de référence d'Ustagammon.
#
# Ce script s'exécute DANS gnubg (scripting Python intégré), dans l'image
# Docker ustagammon/gnubg-reference : voir `make reference` et README.md.
# Il lit positions.json et écrit un fichier results/<id>.json par position.
#
# Rien n'est calculé ici : toutes les valeurs viennent de gnubg. Le script ne
# fait que piloter gnubg, vérifier ses réglages et mettre en forme.
import json
import os
import struct
import gnubg
FOLDER = os.environ.get("REFERENCE_DIR", "/reference")
PLIES = (0, 2) # niveaux en plis gnubg : 0 = évaluation statique du réseau
MAX_MOVES = 10 # coups gardés dans le classement (tous s'il y en a moins)
MET = "Kazaross-XG2" # table d'équité de match par défaut de gnubg
# Noms des décisions de videau : énumération cubedecision de eval.h, dans
# l'ordre (gnubg renvoie l'indice).
CUBE_DECISIONS = [
"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",
]
def f32(x):
"""Écriture décimale la plus courte qui redonne exactement le float32
calculé par gnubg (gnubg travaille en simple précision)."""
target = struct.pack("<f", x)
for digits in range(1, 10):
text = "%.*g" % (digits, x)
if struct.pack("<f", float(text)) == target:
return float(text)
raise ValueError(x)
def probabilities(p):
return {
"win": f32(p[0]),
"win_gammon": f32(p[1]),
"win_backgammon": f32(p[2]),
"lose_gammon": f32(p[3]),
"lose_backgammon": f32(p[4]),
}
def eval_context(ply):
"""Réglage prédéfini « 0ply » ou « 2ply » de gnubg (aecSettings, eval.c)."""
return {"cubeful": 1, "plies": ply, "deterministic": 1,
"prune": 1 if ply > 0 else 0, "noise": 0.0}
def set_chequer_settings(ply):
"""Règle l'évaluation utilisée par la commande hint de gnubg."""
prefix = "set evaluation chequerplay evaluation "
gnubg.command("set evaluation chequerplay type evaluation")
gnubg.command(prefix + "plies %d" % ply)
gnubg.command(prefix + "cubeful on")
gnubg.command(prefix + "prune " + ("on" if ply > 0 else "off"))
gnubg.command(prefix + "deterministic on")
gnubg.command(prefix + "noise 0")
def load_position(pos):
"""Installe la position et vide le cache d'évaluation : chaque calcul se
fait comme sur un moteur neuf. Vérifie que gnubg relit les mêmes IDs."""
gnubg.command("clear cache")
gnubg.command("set gnubgid %s:%s" % (pos["position_id"], pos["match_id"]))
read_back = (gnubg.positionid(), gnubg.matchid())
if read_back != (pos["position_id"], pos["match_id"]):
raise SystemExit("%s : gnubg relit %s" % (pos["id"], read_back))
def cube_info():
"""Contexte du videau de la position courante, tel que gnubg l'utilise
pour ses propres analyses (GetMatchStateCubeInfo). gnubg.cubeinfo()
déduit les castors du nombre de castors déjà joués dans la partie (0) ;
gnubg, lui, les autorise en partie libre (réglage par défaut nBeavers = 3)
et les ignore en match."""
ci = gnubg.cubeinfo()
ci["beavers"] = 1 if ci["matchto"] == 0 else 0
return ci
def game_context(pos):
ci = cube_info()
dice = gnubg.posinfo()["dice"]
dice = list(dice) if dice[0] else None
if dice != pos["dice"]:
raise SystemExit("%s : dés %s dans le Match ID, %s dans positions.json"
% (pos["id"], dice, pos["dice"]))
me = ci["move"]
owner = ci["cubeowner"]
return {
"dice": dice,
"match_length": ci["matchto"],
"score_player": ci["score"][me],
"score_opponent": ci["score"][1 - me],
"crawford": bool(ci["crawford"]),
"jacoby": bool(ci["jacoby"]),
"beavers": bool(ci["beavers"]),
"cube_value": ci["cube"],
"cube_owner": "centered" if owner < 0 else "player" if owner == me else "opponent",
}
def evaluate(ply):
r = gnubg.evaluate(gnubg.board(), cube_info(), eval_context(ply))
return {"probabilities": probabilities(r[:5]), "equity": f32(r[5])}
def rank_moves(pos, ply):
set_chequer_settings(ply)
load_position(pos) # efface aussi l'analyse que gnubg garde en mémoire
hint = gnubg.hint()
moves = hint["hint"] if hint else []
ranking = []
for m in moves[:MAX_MOVES]:
ctx = m.get("context", {})
if m["type"] != "eval" or ctx["noise"] != 0 or not ctx["cubeful"]:
raise SystemExit("%s : coup évalué autrement que prévu : %r" % (pos["id"], m))
ranking.append({
"rank": m["movenum"],
"move": m["move"],
"ply": ctx["plies"],
"equity": f32(m["equity"]),
"equity_diff": f32(m["eqdiff"]),
"probabilities": probabilities(m["details"]["probs"]),
})
return {"legal_move_count": len(moves), "ranking": ranking}
def cube_decision(ply):
r = gnubg.cfevaluate(gnubg.board(), cube_info(), eval_context(ply))
return {
"no_double": f32(r[1]),
"double_take": f32(r[2]),
"double_pass": f32(r[3]),
"optimal": f32(r[0]),
"decision": CUBE_DECISIONS[r[4]],
"decision_text": r[5],
}
def check_settings():
"""Vérifie les réglages par défaut dont dépendent les résultats."""
# Filtre « Normal » pour 2 plis : (niveau, coups acceptés, en plus, écart).
expected = [(0, 0, 12, 0.24), (1, 0, 6, 0.12)]
two_ply = [(f["level"], f["acceptmoves"], f["extramoves"], round(f["threshold"], 6))
for f in gnubg.getevalhintfilter() if f["ply"] == 2]
if two_ply != expected:
raise SystemExit("filtre de coups inattendu : %r" % (two_ply,))
if MET not in gnubg.show("matchequitytable"):
raise SystemExit("table d'équité de match inattendue")
def main():
with open(os.path.join(FOLDER, "positions.json"), encoding="utf-8") as f:
positions = json.load(f)["positions"]
with open("/usr/local/share/gnubg/commit") as f:
commit = f.read().strip()
check_settings()
out_dir = os.path.join(FOLDER, "results")
os.makedirs(out_dir, exist_ok=True)
for pos in positions:
load_position(pos)
result = {
"id": pos["id"],
"type": pos["type"],
"categories": pos["categories"],
"description": pos["description"],
"position_id": pos["position_id"],
"match_id": pos["match_id"],
"gnubg_commit": commit,
"met": MET,
"context": game_context(pos),
"plies": {},
}
for ply in PLIES:
load_position(pos)
level = {"evaluation": evaluate(ply)}
if pos["type"] == "move":
level["moves"] = rank_moves(pos, ply)
else:
level["cube"] = cube_decision(ply)
result["plies"][str(ply)] = level
with open(os.path.join(out_dir, pos["id"] + ".json"), "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
f.write("\n")
print("référence écrite :", pos["id"])
main()