#!/usr/bin/env python3 """ One-off (but reusable) maintenance tool: retroactively rename the White/Black PGN header value for games matching a given Event + Date list, in place, without touching anything else in the file (moves, comments, other headers byte-for-byte unchanged). Why this exists, 2026-10-16: self_play_games.pgn is reused as NNUE training data (see docs/NNUE.md). match_play.py recorded every candidate engine as literally "candidate" regardless of what that candidate actually was, so the 2026-10-15/2026-10-16 batches (an NNUE candidate vs the HCE baseline, scores on the NNUE side compressed/ uncalibrated -- see the engine-vs-engine eval discussion that same day) are indistinguishable from any other "candidate" batch by header alone. match_play.py now takes --candidate-name/--baseline-name to avoid this going forward; this script fixes the games written before that existed. Deliberately stream-edits the file line by line rather than round- tripping through python-chess's PGN writer -- re-serializing ~190k games risks reformatting move/comment text (whitespace, line wrapping) that downstream positional parsers (extract_positions.py) may depend on being byte-identical to how it was originally written. This script only ever rewrites a `[White "..."]`/`[Black "..."]` header line, and only when that game's own `[Date ...]`/`[Event ...]` headers already matched the target. Usage: python3 relabel_selfplay_pgn.py \\ --dates 2026-10-15 2026-10-16 \\ --event "typhoon self-play match" \\ --old-name candidate --new-name candidate_nnue_loose \\ [--dry-run] Writes to a sibling temp file and atomically replaces the original only after a full pass succeeds -- a failure partway through never leaves the original file half-rewritten. """ import argparse import os import re import sys HEADER_RE = re.compile(r'^\[(\w+)\s+"((?:[^"\\]|\\.)*)"\]\s*$') def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("pgn_file") ap.add_argument("--dates", nargs="+", required=True, help="Date header values (as PGN prints them, e.g. " "2026-10-15) whose games are in scope") ap.add_argument("--event", required=True, help="Event header value a game must also match") ap.add_argument("--old-name", required=True, help="White/Black header value to rename") ap.add_argument("--new-name", required=True, help="replacement header value") ap.add_argument("--dry-run", action="store_true", help="report what would change, write nothing") args = ap.parse_args() target_dates = set(args.dates) tmp_path = args.pgn_file + ".relabel_tmp" cur_date = None cur_event = None in_scope = False # true once we know this game's headers match games_seen = 0 games_touched = 0 renames = 0 with open(args.pgn_file, "r", encoding="utf-8", errors="surrogateescape") as fin, \ open(tmp_path, "w", encoding="utf-8", errors="surrogateescape") as fout: for line in fin: m = HEADER_RE.match(line) if m: tag, value = m.group(1), m.group(2) if tag == "Event": # New header block -> new game starting. games_seen += 1 cur_event = value cur_date = None in_scope = False elif tag == "Date": cur_date = value in_scope = (cur_event == args.event and cur_date in target_dates) elif tag in ("White", "Black") and in_scope and value == args.old_name: renames += 1 games_touched += 1 if tag == "White" else 0 # count once/game is enough if not args.dry_run: line = f'[{tag} "{args.new_name}"]\n' fout.write(line) if args.dry_run: os.remove(tmp_path) print(f"[dry run] {games_seen} games scanned, {renames} header " f"values would be renamed ({args.old_name!r} -> " f"{args.new_name!r}) for Event={args.event!r} Date in " f"{sorted(target_dates)}") return os.replace(tmp_path, args.pgn_file) print(f"{games_seen} games scanned, {renames} header values renamed " f"({args.old_name!r} -> {args.new_name!r}) for Event={args.event!r} " f"Date in {sorted(target_dates)}") if __name__ == "__main__": sys.exit(main())