1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
|
#!/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 <pgn-file> \\
--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())
|