summaryrefslogtreecommitdiff
path: root/src/eval_tune/relabel_selfplay_pgn.py
blob: be14e22e7b9a1fa924d5c939c1596b22f6104e1d (plain)
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())