summaryrefslogtreecommitdiff
path: root/src/eval_tune/extract_king_features.py
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-09-13 23:18:07 -0700
committerScott Gasch <[email protected]>2026-09-13 23:18:07 -0700
commit4ff9170fd96e3da049c747bf5215e908a94ff145 (patch)
tree6c8e6f846b42518d84357bb3b83014feddc83202 /src/eval_tune/extract_king_features.py
parent78d1889fe38db1fd45b58509c8b3bd535ce691b7 (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/extract_king_features.py')
-rw-r--r--src/eval_tune/extract_king_features.py186
1 files changed, 186 insertions, 0 deletions
diff --git a/src/eval_tune/extract_king_features.py b/src/eval_tune/extract_king_features.py
new file mode 100644
index 0000000..509f823
--- /dev/null
+++ b/src/eval_tune/extract_king_features.py
@@ -0,0 +1,186 @@
+#!/usr/bin/env python3
+"""Feature extraction driver: takes the mined FENs in mate_training_data.csv
+and runs each through the engine's `evalkingdump` command (KING_DEFECT_DUMP
+build only) to pull _EvalKing's raw per-defect breakdown, joining it back
+onto each row's label/metadata to produce the final regression-ready CSV.
+
+Sharded across N engine subprocesses (one `script` file each, run in
+parallel) since 86k FENs through a single serial engine invocation would
+be needlessly slow on this 24-core box -- same reasoning as
+shard_mine.py's PGN sharding.
+
+Each engine invocation processes its shard via `script <file>` containing
+alternating `setboard <fen>` / `evalkingdump` lines; evalkingdump always
+emits exactly two `KINGDEFECTDUMP color=...` lines (black then white, in
+that fixed loop order) per position, unconditionally -- so the Nth pair
+of KINGDEFECTDUMP lines in the shard's output corresponds to the Nth row
+of that shard's input, and we pick out whichever color each row's
+"mated_color_or_sampled_color" column names.
+"""
+import argparse
+import csv
+import os
+import re
+import subprocess
+import sys
+import time
+
+KV_RE = re.compile(r"(\S+)=(\S+)")
+
+# Fixed schema, since an off-board ring square (king near an edge/corner)
+# emits only "sqN_offboard=1" while an on-board square emits 9 separate
+# keys -- different king positions otherwise produce entirely different
+# column sets, which would silently misalign rows if we inferred columns
+# from whichever row happened to be parsed first (caught by a per-shard
+# "feature key mismatch" warning during testing).
+PIECE_NAMES = ("pawn", "minor", "rook", "queen")
+PER_SQUARE_ON_BOARD_KEYS = [f"atk_{p}" for p in PIECE_NAMES] + \
+ ["undef", "def", "xray", "flight"]
+GLOBAL_KEYS = ["filestorm", "filestormopenfile", "filestormpawn",
+ "queenproximity", "initloc", "countertotal",
+ "safetyvalue", "attackerarmyscaler", "castled",
+ "minorsathome", "misscastleshort", "misscastlelong"]
+
+FEATURE_KEYS = []
+for _sq in range(8):
+ FEATURE_KEYS.append(f"sq{_sq}_offboard")
+ for _k in PER_SQUARE_ON_BOARD_KEYS:
+ FEATURE_KEYS.append(f"sq{_sq}_{_k}")
+FEATURE_KEYS += GLOBAL_KEYS
+
+
+def parse_kingdefectdump_line(line):
+ """Returns (color, {key: value_str}) from a 'KINGDEFECTDUMP color=... ...'
+ line, normalized to FEATURE_KEYS' fixed schema -- off-board squares get
+ offboard=1 and all per-square on-board keys defaulted to 0 rather than
+ left absent."""
+ parts = line.strip().split(None, 1)
+ assert parts[0] == "KINGDEFECTDUMP"
+ raw_kv = dict(KV_RE.findall(parts[1]))
+ color = raw_kv.pop("color")
+
+ kv = {}
+ for sq in range(8):
+ offboard_key = f"sq{sq}_offboard"
+ if offboard_key in raw_kv:
+ kv[offboard_key] = raw_kv[offboard_key]
+ for k in PER_SQUARE_ON_BOARD_KEYS:
+ kv[f"sq{sq}_{k}"] = "0"
+ else:
+ kv[offboard_key] = "0"
+ for k in PER_SQUARE_ON_BOARD_KEYS:
+ full_key = f"sq{sq}_{k}"
+ kv[full_key] = raw_kv[full_key]
+ for k in GLOBAL_KEYS:
+ kv[k] = raw_kv[k]
+ assert set(kv.keys()) == set(FEATURE_KEYS), \
+ f"parsed keys don't match fixed schema: {set(kv) ^ set(FEATURE_KEYS)}"
+ return color, kv
+
+
+def run_shard(engine_path, rows, shard_dir, shard_idx):
+ script_path = os.path.join(shard_dir, f"extract_{shard_idx}.script")
+ out_log = os.path.join(shard_dir, f"extract_{shard_idx}.out")
+ with open(script_path, "w") as f:
+ f.write("force\n")
+ f.write("nopost\n")
+ for row in rows:
+ f.write(f"setboard {row['fen']}\n")
+ f.write("evalkingdump\n")
+
+ cmd = [engine_path, "--batch", "--command",
+ f"script {script_path}"]
+ with open(out_log, "w") as logf:
+ rc = subprocess.run(cmd, stdout=logf, stderr=subprocess.STDOUT).returncode
+ if rc != 0:
+ raise RuntimeError(f"shard {shard_idx} engine exited {rc}, see {out_log}")
+
+ dumps = []
+ with open(out_log) as f:
+ for line in f:
+ if line.startswith("KINGDEFECTDUMP"):
+ dumps.append(parse_kingdefectdump_line(line))
+
+ if len(dumps) != 2 * len(rows):
+ raise RuntimeError(
+ f"shard {shard_idx}: expected {2 * len(rows)} KINGDEFECTDUMP "
+ f"lines (2 per row), got {len(dumps)} -- a setboard likely "
+ f"failed silently and evalkingdump re-used a stale position. "
+ f"See {out_log}.")
+
+ out_rows = []
+ for i, row in enumerate(rows):
+ black_color, black_kv = dumps[2 * i]
+ white_color, white_kv = dumps[2 * i + 1]
+ assert black_color == "black" and white_color == "white", \
+ f"row {i}: unexpected color order {black_color}/{white_color}"
+ target = row["mated_color_or_sampled_color"]
+ kv = black_kv if target == "black" else white_kv
+ out_row = dict(row)
+ out_row.update(kv)
+ out_rows.append(out_row)
+
+ os.remove(script_path)
+ os.remove(out_log)
+ return out_rows
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("training_csv")
+ ap.add_argument("out_csv")
+ ap.add_argument("--engine", default="../typhoon_kingdump")
+ ap.add_argument("--num-shards", type=int, default=8)
+ ap.add_argument("--work-dir", default=None)
+ args = ap.parse_args()
+
+ engine_path = os.path.abspath(args.engine)
+ if not os.path.isfile(engine_path):
+ print(f"Engine not found: {engine_path}", file=sys.stderr)
+ sys.exit(1)
+
+ with open(args.training_csv, newline="") as f:
+ rows = list(csv.DictReader(f))
+ print(f"Loaded {len(rows)} rows from {args.training_csv}", file=sys.stderr)
+
+ work_dir = args.work_dir or os.path.dirname(os.path.abspath(args.out_csv))
+ os.makedirs(work_dir, exist_ok=True)
+
+ n = args.num_shards
+ shard_size = (len(rows) + n - 1) // n
+ shards = [rows[i:i + shard_size] for i in range(0, len(rows), shard_size)]
+
+ t0 = time.time()
+ all_out_rows = [[] for _ in shards]
+
+ import concurrent.futures
+ with concurrent.futures.ThreadPoolExecutor(max_workers=n) as pool:
+ futures = {}
+ for idx, shard_rows in enumerate(shards):
+ fut = pool.submit(run_shard, engine_path, shard_rows, work_dir, idx)
+ futures[fut] = idx
+ for fut in concurrent.futures.as_completed(futures):
+ idx = futures[fut]
+ out_rows = fut.result()
+ all_out_rows[idx] = out_rows
+ print(f"Shard {idx} done ({len(out_rows)} rows)", file=sys.stderr)
+
+ print(f"All shards done in {time.time() - t0:.1f}s", file=sys.stderr)
+
+ base_cols = ["label", "mated_color_or_sampled_color", "n_back",
+ "game_index", "control_source"]
+ fieldnames = base_cols + FEATURE_KEYS
+
+ with open(args.out_csv, "w", newline="") as f:
+ w = csv.DictWriter(f, fieldnames=fieldnames)
+ w.writeheader()
+ for out_rows in all_out_rows:
+ for row in out_rows:
+ w.writerow({k: row.get(k) for k in fieldnames})
+
+ total = sum(len(r) for r in all_out_rows)
+ print(f"Wrote {total} feature rows to {args.out_csv}", file=sys.stderr)
+
+
+if __name__ == "__main__":
+ main()