Références : analyses de coups, de videau, chance et niveaux, comparées à gnubg

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-03 10:23:18 +01:00
co-authored by Claude Opus 5.5
parent 34c3eaeca5
commit 242a2433d2
41 changed files with 3883 additions and 5 deletions
+254
View File
@@ -161,6 +161,259 @@ def check_settings():
raise SystemExit("table d'équité de match inattendue")
# --------------------------------------------------------------------------
# Analyses (analyses.json -> analysis/<id>.json) : coup joué, décision de
# videau, chance d'un jet et niveaux de jeu. Les valeurs viennent de l'analyse
# de partie de gnubg (« analyse game ») sur une courte partie rejouée ici :
# la position, puis le coup ou la décision. Voir README.md.
# Appréciations et chances : noms de gnubg (skillString et luckString de
# gnubgmodule.c ; absents du dictionnaire quand rien n'est à signaler) vers
# ceux d'engine-cli.
SKILLS = {None: "none", "doubtful": "doubtful", "bad": "bad", "very bad": "very_bad"}
LUCKS = {None: "none", "verybad": "very_unlucky", "bad": "unlucky", "good": "lucky",
"verygood": "very_lucky"}
# Niveaux de jeu : réglages prédéfinis de gnubg (aecSettings, eval.c
# l. 318-328), en (plis, bruit) ; l'élagage est actif à partir d'1 pli, le
# bruit est déterministe. Ceux qui sont vérifiés : 3 et 4 plis sont trop
# lents pour la suite de tests.
LEVEL_SETTINGS = {
"beginner": (0, 0.06), "casual": (0, 0.05), "intermediate": (0, 0.04),
"advanced": (0, 0.015), "0ply": (0, 0.0), "1ply": (1, 0.0), "2ply": (2, 0.0),
"3ply": (3, 0.0), "4ply": (4, 0.0),
}
CHECKED_LEVELS = ("beginner", "casual", "intermediate", "advanced", "0ply", "1ply", "2ply")
RANKING_KEPT = 10 # coups gardés dans le classement d'une analyse (plus le coup joué)
def level_context(name):
plies, noise = LEVEL_SETTINGS[name]
return {"cubeful": 1, "plies": plies, "deterministic": 1,
"prune": 1 if plies > 0 else 0, "noise": noise}
def start_game(case):
"""Nouvelle partie entre deux joueurs humains (gnubg ne joue rien de
lui-même), placée sur la position du cas, cache vidé."""
gnubg.command("set player 0 human")
gnubg.command("set player 1 human")
gnubg.command("new session")
gnubg.command("clear cache")
gnubg.command("set gnubgid %s:%s" % (case["position_id"], case["match_id"]))
read_back = (gnubg.positionid(), gnubg.matchid())
if read_back != (case["position_id"], case["match_id"]):
raise SystemExit("%s : gnubg relit %s" % (case["id"], read_back))
def set_analysis(ply):
"""Réglages de l'analyse de partie : niveau ply sans bruit, comme le
réglage prédéfini de gnubg ; filtre de coups et analyse de la chance par
défaut (« Normal », ecLuck)."""
for kind in ("chequerplay", "cubedecision"):
prefix = "set analysis %s evaluation " % kind
gnubg.command("set analysis %s type evaluation" % kind)
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 analyse_game():
"""Analyse la partie en cours ; renvoie ses enregistrements et ses
statistiques par joueur (« X », « O »). Le cache est vidé avant : les
évaluations à 2 plis faites pour choisir un coup y laissent des
résultats à 0 pli des réseaux d'élagage, que l'analyse à 0 pli
reprendrait (voir analyse_move)."""
gnubg.command("clear cache")
gnubg.command("analyse game")
game = gnubg.match(analysis=1, verbose=1, statistics=1, boards=1)["games"][-1]
return game["game"], game["stats"]
def last_record(records, action):
found = [r for r in records if r.get("action") == action]
if not found:
raise SystemExit("aucun enregistrement « %s » dans la partie analysée" % action)
return found[-1]
def swapped(board):
"""La même position vue de l'autre joueur (gnubg.board() donne le joueur
au trait en second)."""
return (board[1], board[0])
def set_dice(case):
"""Les dés du cas, qui priment sur ceux du Match ID."""
gnubg.command("set dice %d %d" % tuple(case["dice"]))
def best_move():
"""Le meilleur coup selon gnubg (findbestmove, réglages d'évaluation
courants), en notation de gnubg. Pas « hint » : gnubg garderait son
analyse dans l'enregistrement du coup, et « analyse game » la
reprendrait au lieu de calculer au niveau demandé."""
board = gnubg.board()
return gnubg.movetupletostring(gnubg.findbestmove(), board)
def play_move(case):
"""Joue le coup du cas, ou le meilleur selon gnubg s'il n'est pas donné
(cas qui ne servent qu'à la chance du jet)."""
gnubg.command("move " + (case.get("played") or best_move()))
def analyse_move(case, ply):
start_game(case)
set_dice(case)
before = gnubg.board()
play_move(case)
# Sans analyse du videau : gnubg la ferait juste avant la chance du jet,
# et à 2 plis elle laisse dans son cache d'évaluation des résultats à
# 0 pli calculés avec les réseaux d'élagage, que l'analyse de la chance
# (0 pli, sans élagage) reprend ensuite : la chance dépendrait alors de ce
# qui a été calculé avant (écart observé : 5e-3 sur move-middlegame-close).
gnubg.command("set analysis cube off")
# Après le coup, l'adversaire est au trait : la position vue de celui qui
# vient de jouer est la position retournée.
played_id = gnubg.positionid(swapped(gnubg.board()))
set_analysis(ply)
records, stats = analyse_game()
record = last_record(records, "move")
analysis = record["analysis"]
moves, index = analysis["moves"], analysis["imove"]
for m in moves:
if m["type"] != "eval":
raise SystemExit("%s : coup évalué autrement que prévu : %r" % (case["id"], m))
def notation(move):
return gnubg.movetupletostring(move, before)
kept = moves[:max(RANKING_KEPT, index + 1)]
level = {
"played_move": notation(record["move"]),
"played_position_id": played_id,
"move_count": len(moves),
"rank": index + 1,
"played_equity": f32(moves[index]["score"]),
"best_move": notation(moves[0]["move"]),
"best_equity": f32(moves[0]["score"]),
# Perte calculée par gnubg pour ses statistiques (rChequerSkill),
# comptée en positif.
"equity_loss": f32(-stats[record["player"]]["moves"]["error-skill"]),
"skill": SKILLS[analysis.get("skill")],
"ranking": [{"move": notation(m["move"]), "equity": f32(m["score"])} for m in kept],
}
luck = {"value": f32(analysis["luck-value"]), "rating": LUCKS[analysis.get("luck")]}
return level, luck
def analyse_cube(case, ply):
start_game(case)
gnubg.command("set analysis cube on")
set_analysis(ply)
# Décision de videau de gnubg au même niveau, avant toute action.
decision = cube_decision(ply)
action = case["action"]
if action == "no_double":
# Ne pas doubler, c'est jeter les dés : n'importe quel jet et le
# meilleur coup, l'analyse du videau portant sur la position avant.
gnubg.command("set dice 2 1")
gnubg.command("move " + best_move())
records, stats = analyse_game()
record = last_record(records, "move")
analysed = "nd-cubeful-eq" in record["analysis"]
skill = record["analysis"].get("cube-skill")
else:
# gnubg n'analyse un double qu'une fois la réponse donnée : après
# « double », l'adversaire prend, ce qui ne change rien à l'analyse
# du double.
gnubg.command("double")
gnubg.command("drop" if action == "pass" else "take")
records, stats = analyse_game()
analysed = "nd-cubeful-eq" in last_record(records, "double").get("analysis", {})
record = last_record(records, {"double": "double", "take": "take", "pass": "drop"}[action])
skill = record.get("analysis", {}).get("skill")
return {
"analysed": analysed,
"cube": decision if analysed else None,
"equity_loss": f32(-stats[record["player"]]["cube"]["error-skill"]),
"skill": SKILLS[skill],
}
def level_move(case, name):
"""Le coup que joue l'ordinateur de gnubg réglé à ce niveau."""
start_game(case)
set_dice(case)
before = gnubg.board()
turn = gnubg.posinfo()["turn"]
plies, noise = LEVEL_SETTINGS[name]
prefix = "set player %d chequerplay evaluation " % turn
gnubg.command("set player %d gnubg" % turn)
gnubg.command(prefix + "plies %d" % plies)
gnubg.command(prefix + "noise %g" % noise)
gnubg.command(prefix + "deterministic on")
gnubg.command(prefix + "cubeful on")
gnubg.command(prefix + "prune " + ("on" if plies > 0 else "off"))
gnubg.command("play")
record = last_record(gnubg.match(analysis=0, boards=1)["games"][-1]["game"], "move")
gnubg.command("set player %d human" % turn)
return {"move": gnubg.movetupletostring(record["move"], before)}
def level_cube(case, name):
"""Décision de videau de gnubg avec les réglages de ce niveau."""
start_game(case)
r = gnubg.cfevaluate(gnubg.board(), cube_info(), level_context(name))
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 write_analysis(out_dir, result):
with open(os.path.join(out_dir, result["id"] + ".json"), "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
f.write("\n")
print("analyse écrite :", result["id"])
def generate_analyses(commit):
with open(os.path.join(FOLDER, "analyses.json"), encoding="utf-8") as f:
cases = json.load(f)
out_dir = os.path.join(FOLDER, "analysis")
os.makedirs(out_dir, exist_ok=True)
# Pas d'affichage du plateau ; pas de nouvelle partie ni de jet de dés
# automatiques, qui ajouteraient des enregistrements après le dernier.
gnubg.command("set display off")
gnubg.command("set automatic game off")
gnubg.command("set automatic roll off")
for kind in ("moves", "cubes", "levels"):
for case in cases[kind]:
result = dict(case, type=kind[:-1], gnubg_commit=commit, met=MET)
if kind == "moves":
result["plies"] = {}
for ply in PLIES:
result["plies"][str(ply)], result["luck"] = analyse_move(case, ply)
elif kind == "cubes":
result["plies"] = {str(ply): analyse_cube(case, ply) for ply in PLIES}
else:
result["levels"] = {}
for name in CHECKED_LEVELS:
plies, noise = LEVEL_SETTINGS[name]
level = {"plies": plies, "noise": noise}
level.update(level_move(case, name) if case["dice"] else level_cube(case, name))
result["levels"][name] = level
write_analysis(out_dir, result)
def main():
with open(os.path.join(FOLDER, "positions.json"), encoding="utf-8") as f:
positions = json.load(f)["positions"]
@@ -195,6 +448,7 @@ def main():
json.dump(result, f, ensure_ascii=False, indent=2)
f.write("\n")
print("référence écrite :", pos["id"])
generate_analyses(commit)
main()