training / experiments /exp_mate_capability.py
cazyundee's picture
mate-capability suite + analysis scripts
a7cdc6a verified
Raw History Blame Contribute Delete
9.36 kB
#!/usr/bin/env python3
"""
Can the model mate AT ALL, when the mate is within reach?
This separates three explanations of the conversion failure:
H-conv-3 CAPABILITY : the network cannot represent/execute a mating net
H-conv-2 CREDIT : it could, but outcome-only returns over ~200-ply
games give the mating moves almost no credit
H-conv-4 EXPLORATION : it could, but rarely samples mating lines
DESIGN
Build positions with a KNOWN forced mate in N (N = 1, 2, 3), verified by
exhaustive search using the rules engine only (no engine evaluation is used
as a training signal anywhere -- this is measurement, not supervision).
Then measure, for each N and each recurrent depth:
* mate-in-1 accuracy: does the policy's top legal move deliver mate?
* play-out conversion: from the position, does the model actually win?
A model that scores near 0 on mate-in-1 is capability-limited (H-conv-3).
A model that solves mate-in-1 but fails play-out conversion from won middlegame
positions is credit/exploration-limited (H-conv-2 / H-conv-4).
Positions are generated by random self-play-like walks and then filtered with a
depth-limited search over LEGAL MOVES ONLY. The search is used to *label* test
positions, never to train and never to select the model's move.
"""
from __future__ import annotations
import argparse
import json
import os
import random
import sys
import chess
import torch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from tinychess.arena import wilson_interval
from tinychess.batching import encode_batch
from tinychess.checkpoint import load_model_only
def mate_in_one_moves(board: chess.Board):
"""All legal moves that deliver immediate checkmate."""
out = []
for mv in board.legal_moves:
board.push(mv)
if board.is_checkmate():
out.append(mv)
board.pop()
return out
def forced_mate_in(board: chess.Board, n: int) -> bool:
"""True if side to move has a forced mate in <= n moves (n plies pairs).
Pure rules-engine minimax over legal moves. Used ONLY to label evaluation
positions; never used as a training target or to choose the model's move.
"""
if board.is_checkmate():
return False
if n <= 0:
return False
for mv in board.legal_moves:
board.push(mv)
if board.is_checkmate():
board.pop()
return True
if board.is_stalemate() or board.is_insufficient_material():
board.pop()
continue
# opponent must have no escape
escaped = False
for reply in board.legal_moves:
board.push(reply)
ok = forced_mate_in(board, n - 1)
board.pop()
if not ok:
escaped = True
break
board.pop()
if not escaped:
return True
return False
def gen_mate_positions(n_mate: int, count: int, seed=0, max_tries=400000):
"""Random walks filtered to positions with a forced mate in exactly n_mate."""
rng = random.Random(seed)
found, tries = [], 0
while len(found) < count and tries < max_tries:
tries += 1
b = chess.Board()
for _ in range(rng.randint(10, 70)):
ms = list(b.legal_moves)
if not ms:
break
b.push(rng.choice(ms))
if b.is_game_over() or b.legal_moves.count() > 30:
continue
if n_mate == 1:
if mate_in_one_moves(b):
found.append(b.fen())
else:
if mate_in_one_moves(b):
continue # want *exactly* n
if forced_mate_in(b, n_mate):
found.append(b.fen())
return found, tries
def top_move(model, board, steps):
pb = encode_batch([board])
with torch.no_grad():
out = model(**pb.model_args(), steps=steps)
logits = out["logits"][0]
idx = int(logits.argmax())
moves = pb.real_moves[0]
return moves[idx] if idx < len(moves) else None
def random_control(fens, n, rng):
"""Baseline: pick a uniformly random legal move. Gives the chance rate."""
hit = 0
for f in fens:
b = chess.Board(f)
mv = rng.choice(list(b.legal_moves))
if n == 1:
hit += int(mv in mate_in_one_moves(b))
else:
b.push(mv)
ok = False
if b.is_checkmate():
ok = True
elif not b.is_game_over():
ok = all(not _escape(b, n) for _ in [0])
b.pop()
hit += int(ok)
return hit
def _escape(b, n):
for reply in b.legal_moves:
b.push(reply)
r = forced_mate_in(b, n - 1)
b.pop()
if not r:
return True
return False
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--ckpts", required=True,
help="comma-separated name=path pairs")
ap.add_argument("--depths", default="1,2,4,8")
ap.add_argument("--n-positions", type=int, default=60)
ap.add_argument("--mate-in", default="1,2")
ap.add_argument("--seed", type=int, default=0)
ap.add_argument("--out", default="runs/mate_capability.json")
a = ap.parse_args()
depths = [int(x) for x in a.depths.split(",")]
mates = [int(x) for x in a.mate_in.split(",")]
suites = {}
for n in mates:
fens, tries = gen_mate_positions(n, a.n_positions, seed=a.seed + n)
suites[n] = fens
print(f"[suite] mate-in-{n}: {len(fens)} positions (from {tries} tries)")
rng = random.Random(12345)
controls = {}
for n, fens in suites.items():
if fens:
h = random_control(fens, n, rng)
ci = wilson_interval(h, 0, len(fens))
controls[f"mate_in_{n}"] = {"solved": h, "n": len(fens),
"acc": round(h / len(fens), 4),
"ci95": [round(ci[0], 3), round(ci[1], 3)],
"mean_legal": round(sum(
chess.Board(f).legal_moves.count()
for f in fens) / len(fens), 1)}
print(f"[control] random-legal mate-in-{n}: {h}/{len(fens)} "
f"(mean {controls[f'mate_in_{n}']['mean_legal']} legal moves)")
res = {"args": vars(a), "suite_sizes": {k: len(v) for k, v in suites.items()},
"random_control": controls, "models": {}}
for spec in a.ckpts.split(","):
name, path = spec.split("=", 1)
model, cfg = load_model_only(path)
trained = int(cfg.max_steps)
if max(depths) > trained and hasattr(model, "core"):
import torch.nn as nn
old = model.core.step_emb.data
new = torch.zeros(max(depths) + 1, old.shape[1])
new[:old.shape[0]] = old
new[old.shape[0]:] = old[-1]
model.core.step_emb = nn.Parameter(new)
model.cfg.max_steps = max(depths)
model.core.cfg.max_steps = max(depths)
entry = {"path": path, "params": model.param_report()["total"],
"trained_max_steps": trained, "by_depth": {}}
print(f"\n=== {name} ({entry['params']:,} params, trained {trained} steps) ===")
for d in depths:
row = {}
for n, fens in suites.items():
if not fens:
continue
hit = 0
for f in fens:
b = chess.Board(f)
mv = top_move(model, b, d)
if mv is None:
continue
if n == 1:
if mv in mate_in_one_moves(b):
hit += 1
else:
b.push(mv)
# after our move, is a forced mate in n-1 still on?
still = False
if not b.is_game_over():
ok = True
for reply in b.legal_moves:
b.push(reply)
r = forced_mate_in(b, n - 1)
b.pop()
if not r:
ok = False
break
still = ok
elif b.is_checkmate():
still = True
b.pop()
hit += int(still)
ci = wilson_interval(hit, 0, len(fens))
row[f"mate_in_{n}"] = {"solved": hit, "n": len(fens),
"acc": round(hit / len(fens), 4),
"ci95": [round(ci[0], 3), round(ci[1], 3)]}
print(f" depth={d:2d} mate-in-{n}: {hit}/{len(fens)} "
f"= {hit/len(fens):.3f} CI[{ci[0]:.2f},{ci[1]:.2f}]")
entry["by_depth"][str(d)] = row
res["models"][name] = entry
os.makedirs(os.path.dirname(os.path.abspath(a.out)), exist_ok=True)
json.dump(res, open(a.out, "w"), indent=2)
print(f"\n[saved] {a.out}")
if __name__ == "__main__":
main()