diff options
| author | Scott Gasch <[email protected]> | 2026-09-13 23:18:07 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-09-13 23:18:07 -0700 |
| commit | 4ff9170fd96e3da049c747bf5215e908a94ff145 (patch) | |
| tree | 6c8e6f846b42518d84357bb3b83014feddc83202 /src/eval_tune/mine_king_mates.py | |
| parent | 78d1889fe38db1fd45b58509c8b3bd535ce691b7 (diff) | |
Add king-safety mate-mining regression pipeline; retune file-storm/
queen-proximity king-safety weights and add a back-flight bonus from it.
Mines real human-game checkmates and matched sound-draw controls
(eval_tune/mine_king_mates.py, shard_mine.py, extract_king_features.py,
match_controls.py, train_king_safety_model.py), then fits a logistic
regression against _EvalKing's raw per-defect breakdown (engine-side
support: chess.h's KING_DEFECT_DUMP_INFO, eval.c's KING_DEFECT_DUMP
instrumentation, command.c's evalkingdump command, all gated behind the
KING_DEFECT_DUMP=1 build flag) to see which defects actually predict
getting mated. See eval_tune/king_safety_data/HANDOFF.md for the full
methodology and findings.
Two rounds of retuning landed from this data so far, both validated via
sd10 curated-suite comparison plus per-position Crafty corroboration
(tests/ECM.md) and gated self-play:
- KingFileDefects/KingStormingPawnDefects (_ComputeFileStormDefects)
bumped ~1.75-2x relative to the rest of the ring-scan defects -- the
regression's most dominant, most stable tier-1 predictors, previously
weighted the same as every other hand-picked defect.
- KING_QUEEN_PROXIMITY_DANGER rescaled from -8/count to -32/count, and
a new KING_BACK_FLIGHT_BONUS (+1/square) term added for empty,
unattacked ring squares on the side of the king away from the
attacker -- a real, non-collinear protective signal the current
EmptyUnattackedSquare table didn't credit at all. Both calibrated by
unstandardizing the regression's coefficients and anchoring against
KING_MISSING_ONE_CASTLE_OPTION's already-live -23 value (same
direct-EVAL_TERM unit family).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01P9EDZVcqu6E2bNRm4zDrTf
Diffstat (limited to 'src/eval_tune/mine_king_mates.py')
| -rw-r--r-- | src/eval_tune/mine_king_mates.py | 315 |
1 files changed, 315 insertions, 0 deletions
diff --git a/src/eval_tune/mine_king_mates.py b/src/eval_tune/mine_king_mates.py new file mode 100644 index 0000000..7b5866b --- /dev/null +++ b/src/eval_tune/mine_king_mates.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""Mine middlegame checkmate positions + matched controls for king-safety +defect regression. See CLAUDE.md / conversation context: _EvalKing's +counted "defects" are summed into one scalar and run through one lookup +table with no per-defect weighting; this pulls training data to fit +relative weights instead of hand-picking them. + +Output: a CSV of (fen, label, mated_color, n_back, game_id) rows. FEN +extraction only -- no engine call here. The actual per-defect feature +values get pulled from these FENs by the engine-side dump command +(separate step). +""" +import argparse +import chess +import chess.pgn +import csv +import random +import sys + +# Matches eval.c's DO_KING_SAFETY_THRESHOLD minus the VALUE_KING baseline +# _EvalKing adds internally: real threshold is "enemy has at least a +# rook+bishop of non-pawn, non-king material" before king safety is even +# scored at all. No point mining data _EvalKing would ignore. +MATING_MATERIAL_THRESHOLD_CP = 800 + +PIECE_VALUES_CP = { + chess.PAWN: 0, # excluded: "non-pawn material" by definition + chess.KNIGHT: 300, + chess.BISHOP: 300, + chess.ROOK: 500, + chess.QUEEN: 975, + chess.KING: 0, # excluded: baseline, not a mating-material contributor +} + + +def non_pawn_material_cp(board, color): + total = 0 + for piece_type in (chess.KNIGHT, chess.BISHOP, chess.ROOK, chess.QUEEN): + total += len(board.pieces(piece_type, color)) * PIECE_VALUES_CP[piece_type] + return total + + +# Mirrors _EvalKing's own middlegame/endgame boundary exactly (eval.c: +# "u = pos->uNonPawnCount[WHITE][0] + pos->uNonPawnCount[BLACK][0]; if (u < +# 8)") -- total non-pawn pieces, including both kings, across both sides. +# Below this, _EvalKing itself stops caring about ring-square/file-storm +# defects and switches to endgame-only terms (centralize king, escort +# passer). Checking the attacker's material alone (non_pawn_material_cp) +# is NOT sufficient on its own: a lone queen mating a bare king trivially +# clears that floor but is a basic mating technique, not a middlegame +# king-safety collapse -- caught by manually sampling FENs from the mined +# set (several K+Q-vs-lone-K endings slipped through material-only +# filtering). +def total_non_pawn_piece_count(board): + count = 2 # both kings + for color in (chess.WHITE, chess.BLACK): + for piece_type in (chess.KNIGHT, chess.BISHOP, chess.ROOK, chess.QUEEN): + count += len(board.pieces(piece_type, color)) + return count + + +MIDDLEGAME_MIN_TOTAL_NON_PAWN_PIECES = 8 + + +def is_middlegame(board): + return total_non_pawn_piece_count(board) >= MIDDLEGAME_MIN_TOTAL_NON_PAWN_PIECES + + +def mine_game(game, n_backs, min_ply_from_start=20): + """Yields (fen, mated_color, n_back) for a game ending in checkmate.""" + board = game.board() + moves = list(game.mainline_moves()) + if not moves: + return + # Replay to the end to confirm checkmate and find the mated side. + for mv in moves: + board.push(mv) + if not board.is_checkmate(): + return + mated_color = board.turn # side to move at mate = side that got mated + total_plies = len(moves) + + for n_back in n_backs: + idx = total_plies - n_back + if idx < min_ply_from_start: + continue + replay = game.board() + for mv in moves[:idx]: + replay.push(mv) + # Only meaningful if the mated side's king safety was even "in + # scope" for _EvalKing at this point -- attacker must have + # mating material. + attacker = not mated_color + if non_pawn_material_cp(replay, attacker) < MATING_MATERIAL_THRESHOLD_CP: + continue + if not is_middlegame(replay): + continue + yield replay.fen(), mated_color, n_back + + +def is_draw(game): + return game.headers.get("Result", "") == "1/2-1/2" + + +def mine_controls(game, n_needed, rng, min_ply=20, max_ply_from_end=15): + """Yields (fen, color) control samples from a non-mate game (or a mate + game's earlier-game portion), gated on the same material threshold, + kept well clear of the game's ending so a real collapse (if any) isn't + accidentally captured as a "stable" control. + + Draw games should be called with a larger max_ply_from_end than + decisive/resignation games (see is_draw callers in main()) -- a draw + only vouches for a king position if the position stayed quiet for a + real stretch *after* the sample point too, not just "the game + eventually drew" (e.g. a middlegame attack that later got defused into + a simplified drawn endgame doesn't vouch for the earlier position). + + min_ply must be large enough that development is realistically done + (move ~10, ply 20) -- NOT just "not literally move 1". A large chunk + of the pool's draws are short draws-by-agreement close to + filter_pgn.py's own 40-ply floor; combined with a big max_ply_from_end + and a too-small min_ply, the only plies satisfying BOTH constraints on + a short game land in the early opening, where minorsathome/development + features are legitimately still nonzero for completely ordinary + reasons -- not a real king-safety defect. Confirmed as the actual bug + behind an earlier version's controls showing minorsathome>=2 in ~85% + of samples: the engine's own computation was verified correct on a + real developed middlegame FEN, so the contamination was 100% a + sampling-window problem, not an eval bug. + + A game too short to satisfy both min_ply and max_ply_from_end is + skipped entirely rather than forced into an early sample -- fewer, + honest control samples beat many contaminated ones.""" + moves = list(game.mainline_moves()) + total_plies = len(moves) + end_guard = max_ply_from_end + if total_plies < min_ply + end_guard: + return + candidates = list(range(min_ply, max(min_ply, total_plies - end_guard))) + if not candidates: + return + # BUG #1 (found while investigating why controls skewed early-game): + # shuffling `candidates` then iterating `sorted(candidates)` completely + # undid the shuffle, so every game always visited its window in + # ascending ply order and grabbed the *earliest* plies satisfying + # per_game_budget -- systematically starving late-middlegame coverage + # in any game with a wide candidate window, regardless of min_ply. + # + # BUG #2 (found immediately after "fixing" #1 with a random subsample + # + sort): stopping the ascending-order loop as soon as `produced >= + # n_needed` still biases toward whichever subsample members happen to + # be smallest, since ascending iteration always finds the low end of + # the subsample first. Measured: even after subsampling, sampled plies + # still clustered at the 20th-40th percentile of game length instead + # of spreading across the whole game. + # + # Fix for both: replay ascending through an oversampled random subset + # ONCE (required -- python-chess boards are only walkable forward) to + # collect every candidate result that passes the material/middlegame + # filters, then pick which ones to actually keep via `rng.sample` on + # that result LIST -- decoupling "must replay in order" from "which + # results count" is what actually gives uniform ply coverage. + num_to_try = min(len(candidates), max(n_needed * 4, 20)) + sample_indices = sorted(rng.sample(candidates, num_to_try)) + replay = game.board() + ply = 0 + valid_results = [] + for idx in sample_indices: + while ply < idx: + replay.push(moves[ply]) + ply += 1 + if not is_middlegame(replay): + continue + for color in (chess.WHITE, chess.BLACK): + attacker = not color + if non_pawn_material_cp(replay, attacker) >= MATING_MATERIAL_THRESHOLD_CP: + valid_results.append((replay.fen(), color)) + + if len(valid_results) > n_needed: + valid_results = rng.sample(valid_results, n_needed) + for fen, color in valid_results: + yield fen, color + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("pgn_file") + ap.add_argument("out_csv") + ap.add_argument("--n-backs", type=int, nargs="+", default=[4, 6, 8, 10]) + ap.add_argument("--max-games", type=int, default=None, + help="Stop after scanning this many games (0/None = all)") + ap.add_argument("--control-ratio", type=float, default=1.0, + help="Controls per positive, roughly") + ap.add_argument("--draw-only-controls", action="store_true", + help="Don't fall back to decisive/resignation games " + "for controls even if draws don't fill the quota") + ap.add_argument("--draw-end-guard", type=int, default=24, + help="Plies of quiet play required *after* a draw " + "control sample, stricter than decisive games " + "since a draw only vouches for a position that " + "stayed sound past the sample point too") + ap.add_argument("--decisive-end-guard", type=int, default=15) + ap.add_argument("--min-ply", type=int, default=20, + help="Earliest ply eligible for any sample (positive " + "or control) -- must be well past normal opening " + "development (ply 20 = move 10), not just >0. " + "See mine_controls' docstring for why this " + "matters a lot for short draws.") + ap.add_argument("--seed", type=int, default=42) + ap.add_argument("--byte-start", type=int, default=0, + help="Seek here before reading (shard support -- " + "must be a real game-start offset, e.g. from " + "shard_mine.py, not an arbitrary byte)") + ap.add_argument("--byte-end", type=int, default=None, + help="Stop once past this offset (shard support)") + args = ap.parse_args() + + rng = random.Random(args.seed) + positives = [] + draw_pool_games = [] + decisive_pool_games = [] + + with open(args.pgn_file, encoding="utf-8", errors="replace") as f: + if args.byte_start: + f.seek(args.byte_start) + n_scanned = 0 + while True: + if args.max_games and n_scanned >= args.max_games: + break + if args.byte_end is not None and f.tell() >= args.byte_end: + break + game = chess.pgn.read_game(f) + if game is None: + break + n_scanned += 1 + if n_scanned % 20000 == 0: + print(f"...scanned {n_scanned} games, " + f"{len(positives)} positives so far", file=sys.stderr) + try: + found_any = False + for fen, mated_color, n_back in mine_game( + game, args.n_backs, min_ply_from_start=args.min_ply): + positives.append((fen, 1, chess.COLOR_NAMES[mated_color], + n_back, n_scanned)) + found_any = True + if not found_any: + if is_draw(game): + draw_pool_games.append(game) + else: + decisive_pool_games.append(game) + except Exception as e: + print(f"skip game {n_scanned}: {e}", file=sys.stderr) + continue + + print(f"Scanned {n_scanned} games -> {len(positives)} positive samples " + f"from checkmate games. Control pools: {len(draw_pool_games)} " + f"draws, {len(decisive_pool_games)} decisive/other.", + file=sys.stderr) + + n_controls_needed = int(len(positives) * args.control_ratio) + controls = [] + per_game_budget = 2 + + # Draws first -- a position two comparable players couldn't crack is + # stronger "this was sound" evidence than a resignation, where the + # king position may simply never have been tested by that opponent. + rng.shuffle(draw_pool_games) + for game in draw_pool_games: + if len(controls) >= n_controls_needed: + break + try: + for fen, color in mine_controls(game, per_game_budget, rng, + min_ply=args.min_ply, + max_ply_from_end=args.draw_end_guard): + controls.append((fen, 0, chess.COLOR_NAMES[color], -1, None, "draw")) + except Exception: + continue + + n_from_draws = len(controls) + print(f"Collected {n_from_draws} control samples from draws.", + file=sys.stderr) + + if not args.draw_only_controls and len(controls) < n_controls_needed: + rng.shuffle(decisive_pool_games) + for game in decisive_pool_games: + if len(controls) >= n_controls_needed: + break + try: + for fen, color in mine_controls(game, per_game_budget, rng, + min_ply=args.min_ply, + max_ply_from_end=args.decisive_end_guard): + controls.append((fen, 0, chess.COLOR_NAMES[color], -1, None, "decisive")) + except Exception: + continue + print(f"Collected {len(controls) - n_from_draws} additional control " + f"samples from decisive/resignation games (quota not met by " + f"draws alone).", file=sys.stderr) + + print(f"Collected {len(controls)} control samples total.", file=sys.stderr) + + with open(args.out_csv, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["fen", "label", "mated_color_or_sampled_color", "n_back", + "game_index", "control_source"]) + for row in positives: + w.writerow(row + (None,)) + for row in controls: + w.writerow(row) + + print(f"Wrote {len(positives) + len(controls)} rows to {args.out_csv}", + file=sys.stderr) + + +if __name__ == "__main__": + main() |
