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
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
|
#!/usr/bin/env python3
"""Shard a PGN file across N single-threaded typhoon engine instances,
score every position in every game at a fixed search depth, and
reassemble an annotated PGN with { +score/depth PV } comments matching
the self-play annotation format already used in
../pgn/self_play_games.pgn.
Reusable across depths: --depth is a plain CLI argument, not baked into
any file name or code path, so re-running at sd12/sd16/etc. is just a
different --depth and --outdir.
Usage:
python3 annotate.py --input ../pgn/twic_filtered.pgn \
--depth 10 --shards 16 --hash 32m --outdir runs/sd10_twic
See the module-level functions below for the four stages: shard, build
engine scripts, run engines in parallel, parse + reassemble.
"""
import argparse
import re
import subprocess
import sys
import time
from pathlib import Path
import chess
import chess.pgn
from quiet_common import position_is_quiet
TELLOTHERS_RE = re.compile(
r"^tellothers d(\d+), ([+-]?[\d.]+), [\d.]+s,\s*\d+knps, PV=(.*)$"
)
def load_games(input_paths, max_games=None):
"""Load games, skipping ones with an unknown/unterminated result
('*') -- can't derive a WDL training label without a real outcome.
Short-agreed-draw games are not separately filtered here: the
>40-ply / >=2400-Elo filter already applied when twic_filtered.pgn
was built (eval_tune/filter_pgn.py) rules those out by construction
(confirmed empirically: no games below 40 plies in a 5000-game
sample)."""
games = []
for path in input_paths:
with open(path, encoding="utf-8", errors="replace") as f:
while max_games is None or len(games) < max_games:
game = chess.pgn.read_game(f)
if game is None:
break
if game.headers.get("Result", "*") == "*":
continue
games.append(game)
if max_games is not None and len(games) >= max_games:
break
return games
def shard_games(games, n_shards):
shards = [[] for _ in range(n_shards)]
for i, g in enumerate(games):
shards[i % n_shards].append(g)
return shards
def positions_for_game(game, prefilter_quiet=False):
"""Yield the FEN *before* each move is played, skipping any ply at
or after the game is already over (no legal moves to search).
If prefilter_quiet, also skip any ply whose position fails the
quiet-position test (quiet_common.position_is_quiet) -- checked
against the move actually played in this game, since that's the
only move available before a search has run. This is the same
check/exclude criteria as quiet_filter.py's post-hoc filter, but
SEE-checked against the *played* move rather than the engine's own
PV first move -- see quiet_common.py's module docstring for why
those are different training distributions, not interchangeable.
ply numbering (pi, used by write_shard_script/annotate_shard/
build_games.py to re-align results) still counts only the plies
actually yielded, so this must be called with the same
prefilter_quiet value everywhere a run's ply indices are
recomputed (build_games.py in particular)."""
board = game.board()
for move in game.mainline_moves():
if board.is_game_over():
break
if not prefilter_quiet or position_is_quiet(board, move):
yield board.fen()
board.push(move)
def write_shard_script(shard_games_list, script_path, depth, prefilter_quiet=False):
"""Write the engine command script for one shard, plus the parallel
index recording which (game_idx_in_shard, ply_idx) each 'go' line
corresponds to -- the engine processes 'go' commands strictly in
script order with no interleaving, so a flat index list zips back
onto the flat sequence of tellothers output lines."""
index = []
with open(script_path, "w") as out:
for gi, game in enumerate(shard_games_list):
for pi, fen in enumerate(positions_for_game(game, prefilter_quiet)):
out.write(f"setboard {fen}\n")
out.write(f"sd {depth}\n")
out.write("go\n")
index.append((gi, pi))
return index
def run_shard(engine_path, script_path, out_path, hash_size, cpus):
cmd = [
str(engine_path),
"--cpus", str(cpus),
"--hash", hash_size,
"--logfile", "-",
"--batch",
"--command",
f"force; book name /nonexistent.book.bin; script {script_path}",
]
with open(out_path, "w") as out:
return subprocess.Popen(cmd, stdout=out, stderr=subprocess.STDOUT)
def parse_shard_output(out_path):
"""Return a list of (depth, score, pv_string) in script order."""
results = []
with open(out_path, encoding="utf-8", errors="replace") as f:
for line in f:
m = TELLOTHERS_RE.match(line.rstrip("\n"))
if m:
depth, score, pv = m.groups()
results.append((int(depth), float(score), pv.strip()))
return results
def annotate_shard(shard_games_list, index, results):
"""Attach comments to each game's mainline nodes in place. index and
results are both in script order and the same length; index maps
each result back to (game_idx_in_shard, ply_idx)."""
by_game = {}
for (gi, pi), res in zip(index, results):
by_game.setdefault(gi, {})[pi] = res
for gi, game in enumerate(shard_games_list):
scores = by_game.get(gi, {})
node = game
pi = 0
while node.variations:
node = node.variations[0]
if pi in scores:
depth, score, pv = scores[pi]
node.comment = f"{score:+.2f}/{depth} {pv}"
pi += 1
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--input", nargs="+", required=True,
help="one or more input PGN files")
ap.add_argument("--depth", type=int, required=True,
help="fixed search depth (sd N) for every position")
ap.add_argument("--shards", type=int, default=16)
ap.add_argument("--hash", default="32m",
help="hash table size per shard engine, e.g. 32m")
ap.add_argument("--cpus", type=int, default=1,
help="--cpus passed to each shard's engine")
ap.add_argument("--engine", default="../src/typhoon")
ap.add_argument("--outdir", required=True)
ap.add_argument("--max-games", type=int, default=None,
help="limit to the first N games total, for smoke testing")
ap.add_argument("--prefilter-quiet", action="store_true",
help="skip in-check positions and SEE-positive-capture "
"played moves before ever sending them to the "
"engine, instead of searching everything and "
"filtering after the fact (see quiet_filter.py / "
"quiet_filter_games.py). Changes the quiet "
"criterion's move source from the engine's own PV "
"to the move actually played -- see quiet_common.py.")
args = ap.parse_args()
outdir = Path(args.outdir)
outdir.mkdir(parents=True, exist_ok=True)
engine_path = Path(args.engine).resolve()
if not engine_path.exists():
sys.exit(f"engine not found: {engine_path}")
print(f"Loading games from {args.input} ...")
games = load_games(args.input, args.max_games)
print(f" {len(games)} games loaded")
shards = shard_games(games, args.shards)
if args.prefilter_quiet:
print("Pre-filtering: skipping in-check / SEE-positive-played-move "
"positions before search.")
print(f"Writing {args.shards} shard scripts at sd {args.depth} ...")
indices = []
for i, shard in enumerate(shards):
script_path = outdir / f"shard_{i:02d}.script"
idx = write_shard_script(shard, script_path, args.depth,
args.prefilter_quiet)
indices.append(idx)
print(f" shard {i:02d}: {len(shard)} games, {len(idx)} positions")
print("Launching shard engines in parallel ...")
t0 = time.time()
procs = []
for i in range(args.shards):
script_path = outdir / f"shard_{i:02d}.script"
out_path = outdir / f"shard_{i:02d}.out"
if indices[i]:
procs.append((i, run_shard(engine_path, script_path, out_path,
args.hash, args.cpus)))
# Wait for the slowest shard.
for i, p in procs:
rc = p.wait()
if rc != 0:
print(f" WARNING: shard {i:02d} exited with code {rc}", file=sys.stderr)
elapsed = time.time() - t0
total_positions = sum(len(idx) for idx in indices)
print(f"All shards done in {elapsed:.1f}s "
f"({total_positions / elapsed:.2f} positions/sec aggregate)")
print("Parsing output and reassembling annotated PGN ...")
final_path = outdir / "annotated.pgn"
total_annotated = 0
with open(final_path, "w") as out:
for i, shard in enumerate(shards):
out_path = outdir / f"shard_{i:02d}.out"
results = parse_shard_output(out_path) if indices[i] else []
if len(results) != len(indices[i]):
print(f" WARNING: shard {i:02d} expected {len(indices[i])} "
f"results, got {len(results)} -- some positions may be "
f"unannotated", file=sys.stderr)
annotate_shard(shard, indices[i], results)
total_annotated += len(results)
for game in shard:
print(game, file=out, end="\n\n")
print(f"Wrote {final_path} ({len(games)} games, "
f"{total_annotated} annotated positions)")
if __name__ == "__main__":
main()
|