summaryrefslogtreecommitdiff
path: root/annotate_pgns
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-15 13:11:22 -0700
committerScott Gasch <[email protected]>2026-10-15 13:11:22 -0700
commit993b6eeb2a9ac7b659c473b6883fa53af1ad3a0b (patch)
treec5388824dec15a7691ce9952a6eb4af66e837127 /annotate_pgns
parent53b380c658f828c4c0e096be2763e6b012c23abd (diff)
Phase A NNUE: feature-indexing spec, annotation/training pipeline, dump nnuefeatures
Builds out the NNUE Phase A prototype per docs/NNUE.md: - src/nnuefeatures.c: standalone HalfKAv2_hm feature-index reference implementation (new `dump nnuefeatures` command), sourced from Stockfish's half_ka_v2_hm.{h,cpp} (sf_16 tag), including the 0x88->64 square conversion typhoon's own board representation needs. - annotate_pgns/: sharded TWIC/self-play annotation harness (annotate.py), position extraction, quiet-position filtering (flat and game-shaped, plus an upstream --prefilter-quiet option), train/holdout game-level stratified split, WDL-sigmoid K fitting, the Python HalfKAv2_hm reference encoder (nnue_feature_spec.py) and its bit-for-bit cross-checker against the C side (cross_check_features.py), and a first PyTorch training pipeline (nnue_model.py, nnue_train.py, build_training_tensors.py) -- a 256x2-32-32-1-ish net trained locally on CPU against ~1.85M quiet TWIC positions as a pipeline/framework validation pass. - Finish an in-progress InitMagic/InitLMRTable/InitEval -> InitializeBitboardMagic/InitializeLMRTable/InitializeEval rename: chess.h's prototypes hadn't been updated to match the already-renamed definitions/call sites, leaving 4 implicit-function-declaration warnings. - tests/arasan.epd: new curated EPD suite. - docs/: add the project's design docs (previously untracked). Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'annotate_pgns')
-rw-r--r--annotate_pgns/annotate.py246
-rw-r--r--annotate_pgns/build_games.py124
-rw-r--r--annotate_pgns/build_training_tensors.py117
-rw-r--r--annotate_pgns/check_see_against_engine.py160
-rw-r--r--annotate_pgns/cross_check_features.py122
-rw-r--r--annotate_pgns/extract_positions.py148
-rw-r--r--annotate_pgns/fit_k_and_label.py146
-rw-r--r--annotate_pgns/nnue_feature_spec.py93
-rw-r--r--annotate_pgns/nnue_model.py69
-rw-r--r--annotate_pgns/nnue_train.py208
-rw-r--r--annotate_pgns/quiet_common.py68
-rw-r--r--annotate_pgns/quiet_filter.py124
-rw-r--r--annotate_pgns/quiet_filter_games.py102
-rw-r--r--annotate_pgns/split_holdout.py113
14 files changed, 1840 insertions, 0 deletions
diff --git a/annotate_pgns/annotate.py b/annotate_pgns/annotate.py
new file mode 100644
index 0000000..240b55f
--- /dev/null
+++ b/annotate_pgns/annotate.py
@@ -0,0 +1,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()
diff --git a/annotate_pgns/build_games.py b/annotate_pgns/build_games.py
new file mode 100644
index 0000000..8cac092
--- /dev/null
+++ b/annotate_pgns/build_games.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+"""Reassemble an annotate.py run's flat per-position output back into
+game-shaped containers: one record per source game, holding its
+ordered list of annotated positions.
+
+Why this exists: extract_positions.py's positions.jsonl (fen, score,
+depth, pv, exact per row) is a flat list -- the row order follows each
+shard's script order, which happens to group consecutive rows by game
+(write_shard_script writes one game's positions fully before moving to
+the next), but there's no game_id recorded, so that grouping is
+unrecoverable from positions.jsonl alone. That matters for anything
+that needs to avoid splitting one game's (highly correlated,
+near-duplicate) positions across a train/holdout boundary.
+
+This script recovers it deterministically, without re-running any
+search: re-load the same PGN file with the same max_games (pure
+parsing, no engine involved -- cheap), re-shard it the same way
+annotate.py did (same n_shards, same deterministic round-robin), and
+walk each game's known position count (positions_for_game) to slice
+the already-extracted flat rows back into per-game chunks.
+
+Output shape -- deliberately the same "a game containing an ordered
+list of annotated positions" shape a self-play PGN with per-move
+score/PV comments already has, so a future loader for self-play games
+can produce the same games.jsonl schema and both sources mix freely at
+training-set-assembly time:
+
+ {"game_id": "...", "result": "1-0", "positions": [
+ {"fen": ..., "depth": ..., "score": ..., "score_raw": ...,
+ "pv": ..., "exact": ..., "exact_reason": ...},
+ ...
+ ]}
+
+Usage:
+ python3 build_games.py --rundir runs/sd10_twic_2M \
+ --pgn ../pgn/twic_filtered.pgn --max-games 30000 --shards 16 \
+ --out runs/sd10_twic_2M/games.jsonl
+"""
+import argparse
+import json
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from annotate import load_games, shard_games, positions_for_game
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--rundir", required=True,
+ help="directory holding positions.jsonl (extract_positions.py output)")
+ ap.add_argument("--positions", default=None,
+ help="defaults to <rundir>/positions.jsonl")
+ ap.add_argument("--pgn", nargs="+", required=True,
+ help="same PGN file(s) annotate.py was originally run against")
+ ap.add_argument("--max-games", type=int, required=True,
+ help="same --max-games (or games actually used) as the original run")
+ ap.add_argument("--shards", type=int, default=16,
+ help="same --shards as the original run")
+ ap.add_argument("--prefilter-quiet", action="store_true",
+ help="must match whether the original annotate.py run "
+ "used --prefilter-quiet -- changes positions_for_game's "
+ "per-game ply count, which this script relies on to "
+ "re-slice the flat positions.jsonl")
+ ap.add_argument("--out", required=True)
+ args = ap.parse_args()
+
+ rundir = Path(args.rundir)
+ positions_path = Path(args.positions) if args.positions else rundir / "positions.jsonl"
+
+ print(f"Loading all rows from {positions_path} ...")
+ flat_rows = []
+ with open(positions_path) as f:
+ for line in f:
+ flat_rows.append(json.loads(line))
+ print(f" {len(flat_rows)} rows")
+
+ print(f"Re-loading {args.pgn} (max_games={args.max_games}) to recover game boundaries ...")
+ games = load_games(args.pgn, args.max_games)
+ print(f" {len(games)} games")
+ shards = shard_games(games, args.shards)
+
+ # Per-shard row ranges: shard_NN's rows are a contiguous slice of
+ # flat_rows in the same order extract_positions.py wrote them
+ # (shard 00 first, then 01, ...), each shard's own length equal to
+ # its total position count.
+ shard_lengths = []
+ for shard in shards:
+ shard_lengths.append(sum(
+ 1 for g in shard
+ for _ in positions_for_game(g, args.prefilter_quiet)))
+ if sum(shard_lengths) != len(flat_rows):
+ sys.exit(f"row count mismatch: recomputed shards expect "
+ f"{sum(shard_lengths)} total positions, "
+ f"positions.jsonl has {len(flat_rows)} -- wrong --pgn/"
+ f"--max-games/--shards for this run?")
+
+ n_games_written = 0
+ n_positions_written = 0
+ with open(args.out, "w") as out:
+ row_cursor = 0
+ for shard_idx, shard in enumerate(shards):
+ for game_idx, game in enumerate(shard):
+ n_positions = sum(1 for _ in positions_for_game(game, args.prefilter_quiet))
+ game_rows = flat_rows[row_cursor:row_cursor + n_positions]
+ row_cursor += n_positions
+
+ rec = {
+ "game_id": f"{shard_idx:02d}_{game_idx:05d}",
+ "result": game.headers.get("Result", "*"),
+ "positions": game_rows,
+ }
+ out.write(json.dumps(rec) + "\n")
+ n_games_written += 1
+ n_positions_written += len(game_rows)
+
+ assert row_cursor == len(flat_rows)
+
+ print(f"Wrote {args.out}: {n_games_written} games, "
+ f"{n_positions_written} total positions")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/annotate_pgns/build_training_tensors.py b/annotate_pgns/build_training_tensors.py
new file mode 100644
index 0000000..60e51e6
--- /dev/null
+++ b/annotate_pgns/build_training_tensors.py
@@ -0,0 +1,117 @@
+#!/usr/bin/env python3
+"""Precompute HalfKAv2_hm feature tensors from a game-shaped labeled
+file (quiet_filter_games.py output) into a cached, CSR-style on-disk
+format that nnue_train.py can mmap and sample from directly --
+avoids re-parsing FEN / recomputing SEE / recomputing feature indices
+on every training epoch (the expensive part, done once here).
+
+Each position's active feature indices (per nnue_feature_spec.py,
+already cross-checked bit-for-bit against the C reference -- see
+cross_check_features.py) are stored CSR-style (one flat concatenated
+index array + a row-offset array per perspective), exactly the input
+shape torch.nn.EmbeddingBag expects -- no fixed-width padding needed
+since bag sizes vary (3-32 active features per position).
+
+Skips exact rows (no p_search/r_outcome label -- see NNUE.md: a
+mate/EGTB/draw-claim score is a game-theoretic fact, not something to
+fit a sigmoid-WDL target against).
+
+Usage:
+ python3 build_training_tensors.py \
+ --in runs/sd10_twic_2M/games.train.labeled.quiet.jsonl \
+ --outdir runs/sd10_twic_2M/tensors_train \
+ --lambda 1.0
+"""
+import argparse
+import array
+import json
+import sys
+from pathlib import Path
+
+import numpy as np
+
+from nnue_feature_spec import active_feature_indices, chess
+
+
+def iter_positions(path):
+ with open(path) as f:
+ for line in f:
+ game = json.loads(line)
+ for pos in game["positions"]:
+ yield pos
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--in", dest="infile", required=True)
+ ap.add_argument("--outdir", required=True)
+ ap.add_argument("--lambda", dest="lam", type=float, default=1.0,
+ help="target = lambda*p_search + (1-lambda)*r_outcome "
+ "(Phase A default 1.0, pure p_search -- see NNUE.md)")
+ ap.add_argument("--limit", type=int, default=None,
+ help="stop after this many usable positions (smoke testing)")
+ args = ap.parse_args()
+
+ outdir = Path(args.outdir)
+ outdir.mkdir(parents=True, exist_ok=True)
+
+ # array.array('i') is a packed C int buffer -- avoids the ~28
+ # bytes/element overhead of a Python list of boxed ints, which
+ # matters once nnz reaches tens of millions.
+ white_idx = array.array("i")
+ black_idx = array.array("i")
+ white_off = [0]
+ black_off = [0]
+ stm = [] # 1 = white to move, 0 = black
+ target = []
+
+ n_total = 0
+ n_used = 0
+ n_skipped = 0
+
+ for pos in iter_positions(args.infile):
+ n_total += 1
+ if args.limit is not None and n_used >= args.limit:
+ break
+ if pos.get("exact") or "p_search" not in pos:
+ n_skipped += 1
+ continue
+
+ board = chess.Board(pos["fen"])
+ w = active_feature_indices(board, chess.WHITE)
+ b = active_feature_indices(board, chess.BLACK)
+
+ white_idx.extend(w)
+ white_off.append(white_off[-1] + len(w))
+ black_idx.extend(b)
+ black_off.append(black_off[-1] + len(b))
+
+ stm.append(1 if board.turn == chess.WHITE else 0)
+ p_search = pos["p_search"]
+ r_outcome = pos.get("r_outcome", p_search)
+ target.append(args.lam * p_search + (1.0 - args.lam) * r_outcome)
+
+ n_used += 1
+ if n_used % 200000 == 0:
+ print(f" ...{n_used} positions processed", file=sys.stderr)
+
+ print(f"Read {n_total} rows ({args.infile})")
+ print(f" {n_skipped} skipped (exact / no p_search label)")
+ print(f" {n_used} usable positions")
+
+ np.save(outdir / "white_idx.npy", np.frombuffer(white_idx, dtype=np.int32))
+ np.save(outdir / "white_off.npy", np.array(white_off, dtype=np.int64))
+ np.save(outdir / "black_idx.npy", np.frombuffer(black_idx, dtype=np.int32))
+ np.save(outdir / "black_off.npy", np.array(black_off, dtype=np.int64))
+ np.save(outdir / "stm.npy", np.array(stm, dtype=np.uint8))
+ np.save(outdir / "target.npy", np.array(target, dtype=np.float32))
+
+ nnz_white = len(white_idx)
+ nnz_black = len(black_idx)
+ print(f"Wrote tensors to {outdir}: {n_used} positions, "
+ f"{nnz_white} white / {nnz_black} black nonzero feature entries "
+ f"({nnz_white / max(n_used, 1):.1f} avg features/position)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/annotate_pgns/check_see_against_engine.py b/annotate_pgns/check_see_against_engine.py
new file mode 100644
index 0000000..4f6aab3
--- /dev/null
+++ b/annotate_pgns/check_see_against_engine.py
@@ -0,0 +1,160 @@
+#!/usr/bin/env python3
+"""Cross-check eval_tune/tune_eval_dna.py's Python static_exchange_eval()
+(a classic swap-list SEE that admittedly ignores pins/x-rays/
+discoveries -- see its docstring) against the engine's own real SEE(),
+on real positions from an annotate.py run.
+
+This has never been done before: static_exchange_eval() has been used
+by the eval-tuning pipeline's quiet-position filter since that
+existed, but nothing has ever actually verified it agrees with the
+engine it's trying to approximate. Worth doing once, as reusable
+tooling, since this pipeline (annotate -> quiet-filter -> train) is
+going to be re-run many times as the project continues.
+
+Mechanism: the engine already has a bulk oracle for exactly this,
+`seescores <filename>` (command.c) -- takes a file of "setboard <fen>"
+lines (the same format shard_NN.script already uses) and prints
+"SEESCORES <fen> | <san> | <see-value>" for every legal capture in
+every position in the file, no search involved. Sample N FENs from an
+existing positions.jsonl, write them to a scratch "setboard"-lines
+file, run `seescores` on it, and diff every (fen, move) pair's engine
+SEE value against the Python one.
+
+Usage:
+ python3 check_see_against_engine.py --positions runs/sd10_twic_2M/positions.jsonl \
+ --sample 2000
+"""
+import argparse
+import json
+import os
+import random
+import re
+import subprocess
+import sys
+import tempfile
+from collections import Counter
+from pathlib import Path
+
+_EVAL_TUNE_DIR = Path(__file__).resolve().parent.parent / "src" / "eval_tune"
+sys.path.insert(0, str(_EVAL_TUNE_DIR))
+import chess
+
+# See quiet_filter.py's comment: dna_diff (imported by tune_eval_dna)
+# reads "../eval.c" relative to CWD at import time, not to its own file.
+_cwd = os.getcwd()
+os.chdir(_EVAL_TUNE_DIR)
+try:
+ from tune_eval_dna import static_exchange_eval
+finally:
+ os.chdir(_cwd)
+
+SEESCORES_RE = re.compile(r"^SEESCORES (\S+(?: \S+)*?) \| (\S+) \| (-?\d+)$")
+
+
+def sample_fens(positions_path, n, seed):
+ fens = []
+ with open(positions_path) as f:
+ for line in f:
+ rec = json.loads(line)
+ if rec.get("exact"):
+ continue
+ fens.append(rec["fen"])
+ random.Random(seed).shuffle(fens)
+ return fens[:n]
+
+
+def run_seescores(engine_path, fens, scratch_dir):
+ script_path = Path(scratch_dir) / "see_check.script"
+ out_path = Path(scratch_dir) / "see_check.out"
+ with open(script_path, "w") as f:
+ for fen in fens:
+ f.write(f"setboard {fen}\n")
+
+ cmd = [str(engine_path), "--batch", "--command", f"seescores {script_path}"]
+ with open(out_path, "w") as out:
+ subprocess.run(cmd, stdout=out, stderr=subprocess.STDOUT, check=True)
+ return out_path
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--positions", required=True,
+ help="positions.jsonl to sample FENs from")
+ ap.add_argument("--sample", type=int, default=2000)
+ ap.add_argument("--seed", type=int, default=0)
+ ap.add_argument("--engine", default="../src/typhoon")
+ args = ap.parse_args()
+
+ engine_path = Path(args.engine).resolve()
+ if not engine_path.exists():
+ sys.exit(f"engine not found: {engine_path}")
+
+ print(f"Sampling {args.sample} non-exact positions from {args.positions} ...")
+ fens = sample_fens(args.positions, args.sample, args.seed)
+ print(f" got {len(fens)} FENs")
+
+ with tempfile.TemporaryDirectory() as scratch:
+ print("Running engine seescores ...")
+ out_path = run_seescores(engine_path, fens, scratch)
+
+ n_compared = 0
+ n_exact_match = 0
+ n_sign_match = 0
+ n_sign_mismatch = 0
+ worst = [] # (abs diff, fen, san, engine_val, python_val)
+ diff_histogram = Counter()
+
+ board_cache = {}
+ with open(out_path) as f:
+ for line in f:
+ m = SEESCORES_RE.match(line.rstrip("\n"))
+ if not m:
+ continue
+ fen, san, engine_val = m.groups()
+ engine_val = int(engine_val)
+
+ board = board_cache.get(fen)
+ if board is None:
+ board = chess.Board(fen)
+ board_cache[fen] = board
+ try:
+ move = board.parse_san(san)
+ except ValueError:
+ continue
+ python_val = static_exchange_eval(board, move)
+
+ n_compared += 1
+ diff = engine_val - python_val
+ if diff == 0:
+ n_exact_match += 1
+ bucket = "0" if diff == 0 else (
+ "<=50" if abs(diff) <= 50 else
+ "<=200" if abs(diff) <= 200 else ">200")
+ diff_histogram[bucket] += 1
+
+ engine_sign = (engine_val > 0) - (engine_val < 0)
+ python_sign = (python_val > 0) - (python_val < 0)
+ if engine_sign == python_sign:
+ n_sign_match += 1
+ else:
+ n_sign_mismatch += 1
+
+ worst.append((abs(diff), fen, san, engine_val, python_val))
+
+ worst.sort(reverse=True)
+
+ print(f"\nCompared {n_compared} (fen, capture) pairs")
+ print(f" exact match: {n_exact_match} ({n_exact_match/max(n_compared,1):.1%})")
+ print(f" sign match: {n_sign_match} ({n_sign_match/max(n_compared,1):.1%})")
+ print(f" sign MISmatch:{n_sign_mismatch} ({n_sign_mismatch/max(n_compared,1):.1%})")
+ print(f" |diff| histogram: {dict(diff_histogram)}")
+
+ if worst:
+ print(f"\nWorst {min(10, len(worst))} disagreements "
+ f"(|engine - python|, fen, san, engine_see, python_see):")
+ for d, fen, san, ev, pv in worst[:10]:
+ print(f" {d:5d} {fen} {san} engine={ev} python={pv}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/annotate_pgns/cross_check_features.py b/annotate_pgns/cross_check_features.py
new file mode 100644
index 0000000..f336cda
--- /dev/null
+++ b/annotate_pgns/cross_check_features.py
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+"""Bit-for-bit cross-checker: diffs nnue_feature_spec.py's (Python)
+HalfKAv2_hm feature indices against ../src/nnuefeatures.c's (C,
+`dump nnuefeatures`) indices, for a set of hand-picked FENs chosen to
+exercise every piece type, both king-file halves (a-d / e-h), both
+perspectives, and non-symmetric positions (the starting position
+alone can't catch a perspective-swap bug, since it's symmetric under
+the exact transform between perspectives -- see NNUE.md).
+
+Must pass before any training data is trusted to be feature-indexed
+consistently between the Python trainer and the C inference code.
+
+Usage:
+ python3 cross_check_features.py [--engine ../src/typhoon]
+"""
+import argparse
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+from nnue_feature_spec import active_feature_indices, chess
+
+NNUE_RE = re.compile(r"^NNUE_FEATURES (white|black) (.*)$")
+
+# (label, FEN) -- deliberately includes:
+# - the symmetric starting position (sanity baseline only, see above)
+# - both kings on the queenside half (a-d files) and kingside half (e-h)
+# - a mixed case: White king queenside, Black king kingside (and the
+# reverse), to catch a perspective/orientation mixup
+# - every piece type present at least once, including promoted/extra
+# queens and multiple same-type pieces
+# - a near-empty king-and-pawns endgame (sparse feature set, edge of
+# the active-feature-count range)
+TEST_POSITIONS = [
+ ("startpos",
+ "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"),
+ ("both_kings_queenside",
+ "2kr4/8/8/8/8/8/8/2KR4 w - - 0 1"),
+ ("both_kings_kingside",
+ "4rk2/8/8/8/8/8/8/4RK2 w - - 0 1"),
+ ("white_queenside_black_kingside",
+ "4rk2/8/8/8/8/8/8/2KR4 w - - 0 1"),
+ ("white_kingside_black_queenside",
+ "2kr4/8/8/8/8/8/8/4RK2 w - - 0 1"),
+ ("midgame_mixed_material",
+ "r1bqk2r/pp1nbppp/2p1pn2/3p4/2PP4/2N1PN2/PP2BPPP/R1BQK2R w KQkq - 0 8"),
+ ("sparse_king_pawn_endgame",
+ "8/5k2/8/3K4/8/8/5P2/8 w - - 0 1"),
+ ("extra_queens",
+ "3qk3/3q4/8/8/8/8/3Q4/3QK3 w - - 0 1"),
+ ("black_to_move_midgame",
+ "r2q1rk1/ppp2ppp/2n1bn2/2b1p3/4P3/2NPB3/PPP2PPP/R2Q1RK1 b - - 0 10"),
+]
+
+
+def run_engine_dump(engine, fen):
+ cmd = [
+ str(engine), "--cpus", "1", "--hash", "8m", "--logfile", "-",
+ "--batch", "--command",
+ f"force; setboard {fen}; dump nnuefeatures",
+ ]
+ out = subprocess.run(cmd, capture_output=True, text=True, timeout=30).stdout
+ result = {}
+ for line in out.splitlines():
+ m = NNUE_RE.match(line.strip())
+ if m:
+ side, rest = m.groups()
+ result[side] = sorted(int(x) for x in rest.split()) if rest else []
+ return result
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--engine", default="../src/typhoon")
+ args = ap.parse_args()
+
+ engine = Path(args.engine).resolve()
+ if not engine.exists():
+ sys.exit(f"engine not found: {engine} (build it first -- see CLAUDE.md)")
+
+ n_ok = 0
+ n_fail = 0
+ for label, fen in TEST_POSITIONS:
+ board = chess.Board(fen)
+ py = {
+ "white": active_feature_indices(board, chess.WHITE),
+ "black": active_feature_indices(board, chess.BLACK),
+ }
+ c_out = run_engine_dump(engine, fen)
+
+ ok = True
+ for side in ("white", "black"):
+ if side not in c_out:
+ print(f"[{label}] FAIL: engine produced no '{side}' line at all")
+ ok = False
+ continue
+ if py[side] != c_out[side]:
+ ok = False
+ py_set, c_set = set(py[side]), set(c_out[side])
+ print(f"[{label}] FAIL ({side}): "
+ f"python={len(py[side])} idx, C={len(c_out[side])} idx")
+ only_py = sorted(py_set - c_set)
+ only_c = sorted(c_set - py_set)
+ if only_py:
+ print(f" only in python: {only_py}")
+ if only_c:
+ print(f" only in C: {only_c}")
+
+ if ok:
+ n_ok += 1
+ print(f"[{label}] OK ({len(py['white'])} features/perspective)")
+ else:
+ n_fail += 1
+ print(f" FEN: {fen}")
+
+ print(f"\n{n_ok} passed, {n_fail} failed, out of {len(TEST_POSITIONS)} positions")
+ sys.exit(1 if n_fail else 0)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/annotate_pgns/extract_positions.py b/annotate_pgns/extract_positions.py
new file mode 100644
index 0000000..539419a
--- /dev/null
+++ b/annotate_pgns/extract_positions.py
@@ -0,0 +1,148 @@
+#!/usr/bin/env python3
+"""Extract (FEN, score, depth, PV, exact-flag) tuples directly from a
+completed annotate.py run's raw shard logs -- shard_NN.script (ordered
+FENs) zipped positionally against shard_NN.out (ordered tellothers
+results), bypassing PGN reconstruction entirely.
+
+Why this exists: annotate.py's TELLOTHERS_RE only matched plain-float
+scores, silently dropping +MATE<N>/-MATE<N> lines from the parsed
+results list while leaving them in the position index -- a positional
+zip() between the two then misaligned every later position in that
+shard. This script fixes the parse (captures MATE and <EGTB>/<REC>
+lines as real, index-aligned results, tagged exact=True) and reads
+straight from the logs already on disk, so the 12-hour run does not
+need to be repeated.
+
+Exact positions (mate-distance scores, or any <EGTB>/<REC> PV --
+EGTB/REC lines are game-theoretic/exact regardless of what their score
+field looks like, e.g. the engine prints +0.00 or +-MATE100 for EGTB
+hits, never a real centipawn value) are written out but flagged
+exact=True rather than silently dropped, per NNUE.md's existing
+decision to exclude exact-value positions from the sigmoid-fit WDL
+loss at training-set-assembly time -- not at extraction time, so the
+raw corpus keeps standalone analysis value.
+
+Usage:
+ python3 extract_positions.py --rundir runs/sd10_twic_2M \
+ --out runs/sd10_twic_2M/positions.jsonl
+"""
+import argparse
+import json
+import re
+import sys
+from pathlib import Path
+
+TELLOTHERS_RE = re.compile(
+ r"^tellothers d(\d+), ([+-]?[\w.]+), [\d.]+s,\s*\d+knps, PV=(.*)$"
+)
+FLOAT_RE = re.compile(r"^[+-]?\d+\.\d+$")
+MATE_RE = re.compile(r"^([+-])MATE(\d+)$")
+
+
+def parse_score(token):
+ """Returns (numeric_score_or_None, exact, reason)."""
+ if FLOAT_RE.match(token):
+ return float(token), False, None
+ m = MATE_RE.match(token)
+ if m:
+ sign, n = m.groups()
+ return (1 if sign == "+" else -1) * float(n), True, f"mate{n}"
+ return None, True, f"unrecognized:{token}"
+
+
+def extract_shard(script_path, out_path):
+ fens = []
+ with open(script_path, encoding="utf-8") as f:
+ for line in f:
+ if line.startswith("setboard "):
+ fens.append(line[len("setboard "):].strip())
+
+ 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_tok, pv = m.groups()
+ pv = pv.strip()
+ score, exact, reason = parse_score(score_tok)
+ # <EGTB>/<REC>/<=>/<#> are terminal sentinel moves appended
+ # to the PV (UpdatePV(), search.c) -- the PV may carry real
+ # moves before the sentinel (e.g.
+ # "Kf6 Bc3+ Kxg6 Kf8 Kxh5 <EGTB>"), and in that case the
+ # score field often looks like an ordinary centipawn number,
+ # but the position is still exact/game-theoretic, not
+ # sigmoid-fit training signal -- check the PV's last token,
+ # not the whole PV string. <=> (DRAWMOVE) specifically is a
+ # claimed repetition/50-move draw -- a fact about the game's
+ # move history, not the position itself (the same position
+ # reached a different way isn't a draw at all), so it's
+ # excluded for the same reason <EGTB>/<REC> are: not
+ # position-dependent training signal.
+ last_move = pv.rsplit(" ", 1)[-1] if pv else ""
+ if last_move in ("<EGTB>", "<REC>", "<=>", "<#>"):
+ exact = True
+ reason = {"<=>": "draw_recognizer",
+ "<#>": "mate_recognizer"}.get(
+ last_move, last_move.strip("<>").lower())
+ elif pv == "only":
+ # root.c: exactly one legal move at the root skips the
+ # search entirely (iRootScore/uRootDepth hardcoded to
+ # 0), so depth/score here are placeholders, not a real
+ # evaluation -- not indeterminate, definitely not
+ # trainable.
+ exact = True
+ reason = "unsearched_only_move"
+ results.append((int(depth), score, score_tok, pv, exact, reason))
+
+ if len(fens) != len(results):
+ print(f" MISALIGNED: {script_path.name} has {len(fens)} positions "
+ f"but {out_path.name} produced {len(results)} tellothers lines "
+ f"-- skipping this shard, needs manual inspection",
+ file=sys.stderr)
+ return []
+
+ return list(zip(fens, results))
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--rundir", required=True)
+ ap.add_argument("--out", required=True)
+ ap.add_argument("--shards", type=int, default=16)
+ args = ap.parse_args()
+
+ rundir = Path(args.rundir)
+ total = 0
+ total_exact = 0
+ with open(args.out, "w") as out:
+ for i in range(args.shards):
+ script_path = rundir / f"shard_{i:02d}.script"
+ out_path = rundir / f"shard_{i:02d}.out"
+ if not script_path.exists() or not out_path.exists():
+ continue
+ rows = extract_shard(script_path, out_path)
+ for fen, (depth, score, score_tok, pv, exact, reason) in rows:
+ rec = {
+ "fen": fen,
+ "depth": depth,
+ "score": score,
+ "score_raw": score_tok,
+ "pv": pv,
+ "exact": exact,
+ }
+ if reason:
+ rec["exact_reason"] = reason
+ out.write(json.dumps(rec) + "\n")
+ total += 1
+ if exact:
+ total_exact += 1
+ print(f" shard {i:02d}: {len(rows)} positions "
+ f"({sum(1 for _, r in rows if r[4])} exact)")
+
+ print(f"Wrote {args.out}: {total} positions total, "
+ f"{total_exact} exact (mate/EGTB/REC), "
+ f"{total - total_exact} usable for sigmoid-fit training")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/annotate_pgns/fit_k_and_label.py b/annotate_pgns/fit_k_and_label.py
new file mode 100644
index 0000000..9fd09b0
--- /dev/null
+++ b/annotate_pgns/fit_k_and_label.py
@@ -0,0 +1,146 @@
+#!/usr/bin/env python3
+"""Fit the WDL-sigmoid scale K on the TRAIN split only, then label
+every non-exact position in both train and holdout with:
+ - p_search: sigmoid(score / K) -- the search-score-derived win
+ probability estimate.
+ - r_outcome: that position's own game's actual final result,
+ converted to side-to-move-relative (matching the score's own
+ convention: 1.0 = the side to move at this FEN eventually won
+ their game, 0.0 = eventually lost, 0.5 = drawn).
+
+Does NOT blend p_search/r_outcome into a single training target --
+that mixing weight (lambda) is left as a free parameter to sweep
+later; storing both components separately means that sweep never
+needs to re-derive anything from here.
+
+K is fit by ternary search minimizing MSE(sigmoid(score/K), r_outcome)
+over every non-exact TRAIN position (not holdout -- fitting K on
+holdout data would leak information into what's supposed to be an
+untouched generalization check). The same fitted K is then applied
+(never refit) to label holdout positions too.
+
+Usage:
+ python3 fit_k_and_label.py --train runs/sd10_twic_2M/games.train.jsonl \
+ --holdout runs/sd10_twic_2M/games.holdout.jsonl \
+ --train-out runs/sd10_twic_2M/games.train.labeled.jsonl \
+ --holdout-out runs/sd10_twic_2M/games.holdout.labeled.jsonl
+"""
+import argparse
+import json
+import math
+
+
+def result_to_white_score(result):
+ """1.0/0.5/0.0 from White's perspective, or None if unknown ('*')."""
+ return {"1-0": 1.0, "0-1": 0.0, "1/2-1/2": 0.5}.get(result)
+
+
+def stm_relative_outcome(white_score, fen):
+ """Flip a White-perspective game result to the side-to-move's own
+ perspective at this FEN, matching the score field's own
+ side-to-move-relative convention."""
+ stm_is_white = fen.split()[1] == "w"
+ return white_score if stm_is_white else (1.0 - white_score)
+
+
+def collect_calibration_pairs(games_path):
+ """(score, r_outcome) for every non-exact position across all
+ games in this file."""
+ pairs = []
+ n_unknown_result = 0
+ with open(games_path) as f:
+ for line in f:
+ g = json.loads(line)
+ white_score = result_to_white_score(g["result"])
+ if white_score is None:
+ n_unknown_result += 1
+ continue
+ for pos in g["positions"]:
+ if pos.get("exact"):
+ continue
+ r = stm_relative_outcome(white_score, pos["fen"])
+ pairs.append((pos["score"], r))
+ return pairs, n_unknown_result
+
+
+def mse(pairs, k):
+ err = 0.0
+ for score, r in pairs:
+ p = 1.0 / (1.0 + math.exp(-score / k))
+ err += (r - p) ** 2
+ return err / max(len(pairs), 1)
+
+
+def fit_k(pairs, lo=0.05, hi=20.0, iterations=40):
+ for _ in range(iterations):
+ m1 = lo + (hi - lo) / 3
+ m2 = hi - (hi - lo) / 3
+ if mse(pairs, m1) < mse(pairs, m2):
+ hi = m2
+ else:
+ lo = m1
+ return (lo + hi) / 2
+
+
+def label_file(in_path, out_path, k):
+ n_games = 0
+ n_positions = 0
+ n_exact_skipped = 0
+ n_unknown_result = 0
+ with open(in_path) as fin, open(out_path, "w") as fout:
+ for line in fin:
+ g = json.loads(line)
+ white_score = result_to_white_score(g["result"])
+ if white_score is None:
+ n_unknown_result += 1
+ fout.write(json.dumps(g) + "\n")
+ continue
+ for pos in g["positions"]:
+ n_positions += 1
+ if pos.get("exact"):
+ n_exact_skipped += 1
+ continue
+ r = stm_relative_outcome(white_score, pos["fen"])
+ p_search = 1.0 / (1.0 + math.exp(-pos["score"] / k))
+ pos["p_search"] = p_search
+ pos["r_outcome"] = r
+ fout.write(json.dumps(g) + "\n")
+ n_games += 1
+ return n_games, n_positions, n_exact_skipped, n_unknown_result
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--train", required=True)
+ ap.add_argument("--holdout", required=True)
+ ap.add_argument("--train-out", required=True)
+ ap.add_argument("--holdout-out", required=True)
+ args = ap.parse_args()
+
+ print(f"Collecting calibration pairs from {args.train} (TRAIN only) ...")
+ pairs, n_unknown = collect_calibration_pairs(args.train)
+ print(f" {len(pairs)} (score, outcome) pairs "
+ f"({n_unknown} games skipped: unknown result)")
+
+ print("Fitting K (ternary search, minimizing sigmoid-MSE against "
+ "actual game outcomes) ...")
+ k = fit_k(pairs)
+ final_mse = mse(pairs, k)
+ print(f" K = {k:.2f} (MSE at fit = {final_mse:.5f})")
+
+ print(f"\nLabeling {args.train} -> {args.train_out} ...")
+ n_g, n_p, n_exact, n_unk = label_file(args.train, args.train_out, k)
+ print(f" {n_g} games, {n_p} positions, {n_exact} exact (unlabeled), "
+ f"{n_unk} unknown-result games (unlabeled)")
+
+ print(f"Labeling {args.holdout} -> {args.holdout_out} (same K, not refit) ...")
+ n_g, n_p, n_exact, n_unk = label_file(args.holdout, args.holdout_out, k)
+ print(f" {n_g} games, {n_p} positions, {n_exact} exact (unlabeled), "
+ f"{n_unk} unknown-result games (unlabeled)")
+
+ print(f"\nFitted K = {k:.4f} -- record this; applying a different K "
+ f"later (e.g. after labeling more data) is not the same model.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/annotate_pgns/nnue_feature_spec.py b/annotate_pgns/nnue_feature_spec.py
new file mode 100644
index 0000000..b121c82
--- /dev/null
+++ b/annotate_pgns/nnue_feature_spec.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+"""Python reference implementation of the HalfKAv2_hm feature index
+formula -- see ../src/docs/NNUE.md's "Canonical HalfKAv2_hm
+feature-indexing spec" section, sourced verbatim from Stockfish's
+src/nnue/features/half_ka_v2_hm.{h,cpp} (git tag sf_16).
+
+This module's only job is to be an independent, from-scratch
+implementation of the same math as ../src/nnuefeatures.c
+(`dump nnuefeatures`), using python-chess's own (already
+rank*8+file, 0-63, a1=0) square numbering directly -- no 0x88
+conversion needed on this side, that's purely a typhoon-C-side
+concern.
+
+See cross_check_features.py for the actual Python-vs-C diff driver.
+"""
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src" / "eval_tune"))
+import chess # noqa: E402
+
+SQUARE_NB = 64
+
+PS_W_PAWN, PS_B_PAWN = 0 * SQUARE_NB, 1 * SQUARE_NB
+PS_W_KNIGHT, PS_B_KNIGHT = 2 * SQUARE_NB, 3 * SQUARE_NB
+PS_W_BISHOP, PS_B_BISHOP = 4 * SQUARE_NB, 5 * SQUARE_NB
+PS_W_ROOK, PS_B_ROOK = 6 * SQUARE_NB, 7 * SQUARE_NB
+PS_W_QUEEN, PS_B_QUEEN = 8 * SQUARE_NB, 9 * SQUARE_NB
+PS_KING = 10 * SQUARE_NB
+PS_NB = 11 * SQUARE_NB # 704
+
+DIMENSIONS = (SQUARE_NB * PS_NB) // 2 # 22528
+
+_OWN_PLANE = {
+ chess.PAWN: PS_W_PAWN,
+ chess.KNIGHT: PS_W_KNIGHT,
+ chess.BISHOP: PS_W_BISHOP,
+ chess.ROOK: PS_W_ROOK,
+ chess.QUEEN: PS_W_QUEEN,
+}
+_ENEMY_PLANE = {
+ chess.PAWN: PS_B_PAWN,
+ chess.KNIGHT: PS_B_KNIGHT,
+ chess.BISHOP: PS_B_BISHOP,
+ chess.ROOK: PS_B_ROOK,
+ chess.QUEEN: PS_B_QUEEN,
+}
+
+
+def king_bucket(perspective, sq):
+ """sq, perspective: python-chess 0-63 square / chess.WHITE|BLACK."""
+ file = chess.square_file(sq)
+ rank0 = chess.square_rank(sq) # 0..7
+ file_quartet = file if file < 4 else (7 - file)
+ rank_group = (7 - rank0) if perspective == chess.WHITE else rank0
+ return rank_group * 4 + file_quartet
+
+
+def orient_mask(perspective, ksq):
+ file = chess.square_file(ksq)
+ if perspective == chess.WHITE:
+ return 7 if file < 4 else 0 # SQ_H1 : SQ_A1
+ return 63 if file < 4 else 56 # SQ_H8 : SQ_A8
+
+
+def piece_plane(perspective, piece_color, piece_type):
+ if piece_type == chess.KING:
+ return PS_KING
+ table = _OWN_PLANE if piece_color == perspective else _ENEMY_PLANE
+ return table[piece_type]
+
+
+def active_feature_indices(board, perspective):
+ """board: chess.Board. perspective: chess.WHITE or chess.BLACK.
+ Returns a sorted list of active feature indices (len <= 32)."""
+ ksq = board.king(perspective)
+ mask = orient_mask(perspective, ksq)
+ bucket_offset = king_bucket(perspective, ksq) * PS_NB
+
+ indices = []
+ for sq, piece in board.piece_map().items():
+ idx = (sq ^ mask) + piece_plane(perspective, piece.color, piece.piece_type) + bucket_offset
+ assert 0 <= idx < DIMENSIONS, idx
+ indices.append(idx)
+ indices.sort()
+ return indices
+
+
+if __name__ == "__main__":
+ # Quick manual smoke test against the starting position.
+ board = chess.Board()
+ print("white:", active_feature_indices(board, chess.WHITE))
+ print("black:", active_feature_indices(board, chess.BLACK))
diff --git a/annotate_pgns/nnue_model.py b/annotate_pgns/nnue_model.py
new file mode 100644
index 0000000..acdc8fd
--- /dev/null
+++ b/annotate_pgns/nnue_model.py
@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+"""The Phase A prototype net: a small, known HalfKAv2_hm architecture
+per NNUE.md's architecture decisions -- 256x2-32-32-1-ish, a single
+shared feature-transformer weight matrix applied independently to
+each perspective's active-feature set (that sharing, not two
+separate tables, is what makes the two 256-wide "accumulator" halves
+comparable before concatenation), own-perspective-half-first
+concatenation keyed by side-to-move so the net's single scalar
+output is side-to-move-relative by construction (per the "Output
+convention" architecture decision), and a sigmoid-WDL loss against
+the already-fitted K=1.3123 (per "WDL-sigmoid target conversion
+built and run", fit_k_and_label.py) rather than raw centipawn MSE.
+
+Deliberately NOT doing yet (explicitly deferred, see NNUE.md): clipped
+activations / quantization-aware scaling (this is a float32-only
+Phase A prototype, no export-for-C-inference step exists yet), the
+lazy-eval PSQT-term fast path, and the super-lazy material-only
+prefilter. All irrelevant to proving the training pipeline works.
+"""
+import torch
+import torch.nn as nn
+
+from nnue_feature_spec import DIMENSIONS
+
+L1 = 256 # feature-transformer output width (per perspective)
+L2 = 32
+L3 = 32
+
+DEFAULT_K = 1.3123 # pawns; fit_k_and_label.py's Phase-A train-split fit
+
+
+class NnueNet(nn.Module):
+ def __init__(self, dimensions=DIMENSIONS, l1=L1, l2=L2, l3=L3):
+ super().__init__()
+ # Single shared feature transformer: one [dimensions, l1]
+ # weight matrix, applied once per perspective to that
+ # perspective's own active-feature set. mode="sum" matches
+ # Stockfish's own feature-transformer semantics (accumulate
+ # one weight row per active feature).
+ self.ft = nn.EmbeddingBag(dimensions, l1, mode="sum")
+ self.ft_bias = nn.Parameter(torch.zeros(l1))
+
+ self.fc1 = nn.Linear(2 * l1, l2)
+ self.fc2 = nn.Linear(l2, l3)
+ self.fc3 = nn.Linear(l3, 1)
+
+ def forward(self, white_idx, white_off, black_idx, black_off, stm):
+ """stm: float/long tensor of shape [B], 1.0 if white to move
+ else 0.0. Returns raw score in pawn-ish units, shape [B]."""
+ white_acc = self.ft(white_idx, white_off) + self.ft_bias
+ black_acc = self.ft(black_idx, black_off) + self.ft_bias
+
+ white_acc = torch.relu(white_acc)
+ black_acc = torch.relu(black_acc)
+
+ stm = stm.view(-1, 1).float()
+ # own-perspective-first concatenation, per side to move.
+ own = stm * white_acc + (1.0 - stm) * black_acc
+ enemy = stm * black_acc + (1.0 - stm) * white_acc
+ x = torch.cat([own, enemy], dim=1)
+
+ x = torch.relu(self.fc1(x))
+ x = torch.relu(self.fc2(x))
+ x = self.fc3(x)
+ return x.view(-1)
+
+ def win_probability(self, white_idx, white_off, black_idx, black_off, stm, k=DEFAULT_K):
+ raw = self.forward(white_idx, white_off, black_idx, black_off, stm)
+ return torch.sigmoid(raw / k)
diff --git a/annotate_pgns/nnue_train.py b/annotate_pgns/nnue_train.py
new file mode 100644
index 0000000..6112ab3
--- /dev/null
+++ b/annotate_pgns/nnue_train.py
@@ -0,0 +1,208 @@
+#!/usr/bin/env python3
+"""Phase A training loop: local CPU, PyTorch, over the cached CSR
+tensors build_training_tensors.py produces. Goal is pipeline/
+framework validation (per NNUE.md) -- prove the data loader, model,
+loss, and checkpointing work end-to-end and get fast local iteration
+when something breaks, not to produce the best possible net. The
+real, larger Phase B run moves to a rented GPU against more data.
+
+CSR batching: each position is a variable-length "bag" of feature
+indices (3-32 of them). To build a batch for nn.EmbeddingBag without
+padding, gather the relevant CSR row-slices for a batch of position
+indices, concatenate them, and recompute offsets -- see
+_gather_batch() below.
+
+Usage:
+ python3 nnue_train.py \
+ --train-dir runs/sd10_twic_2M/tensors_train \
+ --holdout-dir runs/sd10_twic_2M/tensors_holdout \
+ --outdir runs/sd10_twic_2M/nnue_ckpt \
+ --epochs 20 --batch-size 8192 --threads 8
+"""
+import argparse
+import json
+import time
+from pathlib import Path
+
+import numpy as np
+import torch
+import torch.nn as nn
+
+from nnue_model import NnueNet, DEFAULT_K
+
+
+class CsrTensors:
+ """mmap-backed CSR feature tensors for one split (train or
+ holdout), as written by build_training_tensors.py."""
+
+ def __init__(self, tensor_dir):
+ tensor_dir = Path(tensor_dir)
+ self.white_idx = np.load(tensor_dir / "white_idx.npy", mmap_mode="r")
+ self.white_off = np.load(tensor_dir / "white_off.npy")
+ self.black_idx = np.load(tensor_dir / "black_idx.npy", mmap_mode="r")
+ self.black_off = np.load(tensor_dir / "black_off.npy")
+ self.stm = np.load(tensor_dir / "stm.npy")
+ self.target = np.load(tensor_dir / "target.npy")
+ self.n = len(self.target)
+ assert len(self.white_off) == self.n + 1
+ assert len(self.black_off) == self.n + 1
+ assert len(self.stm) == self.n
+
+ def __len__(self):
+ return self.n
+
+
+def _gather_batch(idx_arr, off_arr, row_ids):
+ """Build (flat_idx, batch_offsets) for an nn.EmbeddingBag call
+ covering exactly the bags named by row_ids (a 1D array of row
+ indices into off_arr). row_ids need not be sorted or contiguous."""
+ pieces = []
+ batch_off = np.empty(len(row_ids), dtype=np.int64)
+ cursor = 0
+ for i, r in enumerate(row_ids):
+ start, end = off_arr[r], off_arr[r + 1]
+ pieces.append(idx_arr[start:end])
+ batch_off[i] = cursor
+ cursor += end - start
+ flat = np.concatenate(pieces) if pieces else np.empty(0, dtype=np.int32)
+ return flat, batch_off
+
+
+def iterate_batches(data: CsrTensors, batch_size, shuffle, rng):
+ order = rng.permutation(data.n) if shuffle else np.arange(data.n)
+ for start in range(0, data.n, batch_size):
+ rows = order[start:start + batch_size]
+ rows_sorted = np.sort(rows) # sequential mmap reads, not required but friendlier
+ w_idx, w_off = _gather_batch(data.white_idx, data.white_off, rows_sorted)
+ b_idx, b_off = _gather_batch(data.black_idx, data.black_off, rows_sorted)
+ stm = data.stm[rows_sorted]
+ target = data.target[rows_sorted]
+ yield (
+ torch.from_numpy(w_idx.astype(np.int64, copy=False)),
+ torch.from_numpy(w_off),
+ torch.from_numpy(b_idx.astype(np.int64, copy=False)),
+ torch.from_numpy(b_off),
+ torch.from_numpy(stm.astype(np.int64, copy=False)),
+ torch.from_numpy(target.copy()),
+ )
+
+
+def evaluate(model, data, batch_size, k):
+ model.eval()
+ total_loss = 0.0
+ total_n = 0
+ rng = np.random.default_rng(0)
+ with torch.no_grad():
+ for w_idx, w_off, b_idx, b_off, stm, target in iterate_batches(
+ data, batch_size, shuffle=False, rng=rng):
+ pred = model.win_probability(w_idx, w_off, b_idx, b_off, stm, k=k)
+ loss = nn.functional.mse_loss(pred, target, reduction="sum")
+ total_loss += loss.item()
+ total_n += len(target)
+ model.train()
+ return total_loss / max(total_n, 1)
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--train-dir", required=True)
+ ap.add_argument("--holdout-dir", required=True)
+ ap.add_argument("--outdir", required=True)
+ ap.add_argument("--epochs", type=int, default=20)
+ ap.add_argument("--batch-size", type=int, default=8192)
+ ap.add_argument("--lr", type=float, default=1e-3)
+ ap.add_argument("--k", type=float, default=DEFAULT_K)
+ ap.add_argument("--threads", type=int, default=8,
+ help="torch CPU thread count -- kept modest by default "
+ "since this is a shared multi-user box")
+ ap.add_argument("--eval-every-steps", type=int, default=2000)
+ ap.add_argument("--resume", default=None,
+ help="checkpoint .pt to resume from")
+ ap.add_argument("--seed", type=int, default=0)
+ args = ap.parse_args()
+
+ torch.set_num_threads(args.threads)
+ torch.manual_seed(args.seed)
+
+ outdir = Path(args.outdir)
+ outdir.mkdir(parents=True, exist_ok=True)
+ log_path = outdir / "train_log.jsonl"
+
+ print("Loading tensors ...")
+ train_data = CsrTensors(args.train_dir)
+ holdout_data = CsrTensors(args.holdout_dir)
+ print(f" train: {len(train_data)} positions, holdout: {len(holdout_data)} positions")
+
+ model = NnueNet()
+ optimizer = torch.optim.Adam(model.parameters(), lr=args.lr)
+ start_epoch = 0
+ global_step = 0
+
+ if args.resume:
+ ckpt = torch.load(args.resume, map_location="cpu", weights_only=True)
+ model.load_state_dict(ckpt["model"])
+ optimizer.load_state_dict(ckpt["optimizer"])
+ start_epoch = ckpt["epoch"] + 1
+ global_step = ckpt.get("global_step", 0)
+ print(f"Resumed from {args.resume} at epoch {start_epoch}")
+
+ rng = np.random.default_rng(args.seed)
+ t_start = time.time()
+
+ with open(log_path, "a") as log_f:
+ for epoch in range(start_epoch, args.epochs):
+ epoch_loss = 0.0
+ epoch_n = 0
+ t_epoch = time.time()
+
+ for w_idx, w_off, b_idx, b_off, stm, target in iterate_batches(
+ train_data, args.batch_size, shuffle=True, rng=rng):
+ optimizer.zero_grad()
+ pred = model.win_probability(w_idx, w_off, b_idx, b_off, stm, k=args.k)
+ loss = nn.functional.mse_loss(pred, target)
+ loss.backward()
+ optimizer.step()
+
+ bs = len(target)
+ epoch_loss += loss.item() * bs
+ epoch_n += bs
+ global_step += 1
+
+ if global_step % args.eval_every_steps == 0:
+ holdout_mse = evaluate(model, holdout_data, args.batch_size, args.k)
+ elapsed = time.time() - t_start
+ rec = {
+ "step": global_step, "epoch": epoch,
+ "train_mse_running": epoch_loss / max(epoch_n, 1),
+ "holdout_mse": holdout_mse,
+ "elapsed_s": round(elapsed, 1),
+ "positions_seen": epoch_n + epoch * len(train_data),
+ }
+ print(f" step {global_step} epoch {epoch} "
+ f"train_mse={rec['train_mse_running']:.5f} "
+ f"holdout_mse={holdout_mse:.5f} "
+ f"elapsed={elapsed:.0f}s")
+ log_f.write(json.dumps(rec) + "\n")
+ log_f.flush()
+
+ holdout_mse = evaluate(model, holdout_data, args.batch_size, args.k)
+ epoch_elapsed = time.time() - t_epoch
+ print(f"Epoch {epoch} done: train_mse={epoch_loss / max(epoch_n, 1):.5f} "
+ f"holdout_mse={holdout_mse:.5f} ({epoch_elapsed:.0f}s)")
+
+ ckpt_path = outdir / f"ckpt_epoch{epoch:03d}.pt"
+ torch.save({
+ "model": model.state_dict(),
+ "optimizer": optimizer.state_dict(),
+ "epoch": epoch,
+ "global_step": global_step,
+ "holdout_mse": holdout_mse,
+ "args": vars(args),
+ }, ckpt_path)
+ print(f" wrote {ckpt_path}")
+
+ print(f"Training done in {time.time() - t_start:.0f}s")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/annotate_pgns/quiet_common.py b/annotate_pgns/quiet_common.py
new file mode 100644
index 0000000..77544ef
--- /dev/null
+++ b/annotate_pgns/quiet_common.py
@@ -0,0 +1,68 @@
+#!/usr/bin/env python3
+"""Shared quiet-position predicate, per NNUE.md: exclude positions
+where the side to move is in check, or where a given move is an
+SEE-positive (>=50cp) capture.
+
+Used two ways by this pipeline, with a deliberately different choice
+of *which* move gets SEE-checked in each case:
+
+- Pre-search filtering (annotate.py): checked against the move actually
+ played in the source human game, since that's the only move known
+ before the engine has searched anything -- the whole point is to
+ skip the sd-N search entirely for positions that would be discarded
+ anyway.
+- Post-hoc filtering (quiet_filter.py, quiet_filter_games.py): checked
+ against the first move of the already-recorded sd-N search PV (the
+ engine's own considered-best reply), per NNUE.md's original filter
+ design. This remains the definition used for data already annotated
+ before pre-filtering existed, and is NOT the same training
+ distribution as the game-move variant above -- do not mix rows
+ produced under the two definitions into one training run without
+ accounting for that.
+
+Both call the same `position_is_quiet` predicate once a concrete move
+is in hand; only how that move is obtained differs.
+
+Reuses eval_tune/tune_eval_dna.py's existing static_exchange_eval() --
+a classic swap-list SEE (ignores pins/x-rays/discoveries, a
+documented, accepted simplification; see check_see_against_engine.py
+for a cross-check against the engine's real SEE()) -- rather than
+reimplementing it.
+"""
+import os
+import sys
+from pathlib import Path
+
+_EVAL_TUNE_DIR = Path(__file__).resolve().parent.parent / "src" / "eval_tune"
+sys.path.insert(0, str(_EVAL_TUNE_DIR))
+import chess # noqa: E402
+
+# tune_eval_dna imports dna_diff, which reads "../eval.c" relative to
+# the *current working directory* at import time, not relative to its
+# own file -- chdir there just for the import, then restore, so this
+# module can be imported from any directory.
+_cwd = os.getcwd()
+os.chdir(_EVAL_TUNE_DIR)
+try:
+ from tune_eval_dna import static_exchange_eval, has_hanging_material # noqa: E402,F401
+finally:
+ os.chdir(_cwd)
+
+SEE_THRESHOLD = 50 # matches has_hanging_material's own default
+
+
+def is_see_positive_capture(board, move, threshold=SEE_THRESHOLD):
+ """board is the position *before* move. Does not mutate board."""
+ if not board.is_capture(move):
+ return False
+ return static_exchange_eval(board, move) >= threshold
+
+
+def position_is_quiet(board, move, threshold=SEE_THRESHOLD):
+ """board is the position *before* move; move is whichever move the
+ caller has decided to SEE-check (played move or PV's first move --
+ see module docstring). True means: keep this position in the
+ training set."""
+ if board.is_check():
+ return False
+ return not is_see_positive_capture(board, move, threshold)
diff --git a/annotate_pgns/quiet_filter.py b/annotate_pgns/quiet_filter.py
new file mode 100644
index 0000000..177452e
--- /dev/null
+++ b/annotate_pgns/quiet_filter.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+"""Apply the quiet-position filter (NNUE.md: exclude positions where
+the side to move is in check, or where the best/played move is an
+SEE-positive capture) to an already-extracted positions.jsonl, after
+the fact -- this run already paid the full sd-N search cost regardless
+of whether a position turns out to be quiet, so filtering now just
+trims the training set. A *future* annotate.py run should apply this
+filter before writing a position into a shard script at all, so search
+time is never spent on positions that would be discarded anyway.
+
+Reuses eval_tune/tune_eval_dna.py's existing static_exchange_eval() --
+a classic swap-list SEE (ignores pins/x-rays/discoveries, a documented,
+accepted simplification) -- rather than reimplementing it. That
+function has never been cross-checked against the engine's own real
+SEE(); see check_see_against_engine.py for that.
+
+"Best move" here means the first move of the already-recorded sd-N
+PV (positions.jsonl's "pv" field), not the move actually played in the
+source human game -- annotate.py doesn't currently record the latter,
+and the PV's first move is the engine's own considered-best reply,
+which is what NNUE.md's filter is actually trying to exclude (a
+position captured mid-exchange shouldn't carry a settled-looking
+label).
+
+Usage:
+ python3 quiet_filter.py --in runs/sd10_twic_2M/positions.jsonl \
+ --out runs/sd10_twic_2M/positions.quiet.jsonl
+"""
+import argparse
+import json
+import sys
+
+from quiet_common import chess, has_hanging_material, is_see_positive_capture, SEE_THRESHOLD
+
+
+def pv_first_move(board, pv):
+ """None if indeterminate (empty PV, illegal/unparseable SAN --
+ shouldn't happen for a non-exact row but don't crash the whole run
+ over one bad line), else the parsed Move."""
+ if not pv:
+ return None
+ san = pv.split(" ", 1)[0]
+ try:
+ return board.parse_san(san)
+ except ValueError:
+ return None
+
+
+def first_move_is_see_positive_capture(board, pv):
+ """None if indeterminate, else True/False."""
+ move = pv_first_move(board, pv)
+ if move is None:
+ return None
+ return is_see_positive_capture(board, move)
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--in", dest="infile", required=True)
+ ap.add_argument("--out", dest="outfile", required=True)
+ ap.add_argument("--limit", type=int, default=None,
+ help="stop after this many input rows (smoke testing)")
+ args = ap.parse_args()
+
+ n_total = 0
+ n_exact_skipped = 0
+ n_indeterminate = 0
+ n_excluded_check = 0
+ n_excluded_capture = 0
+ n_hanging_material = 0 # diagnostic only, not an exclusion criterion here
+ n_quiet = 0
+
+ with open(args.infile) as fin, open(args.outfile, "w") as fout:
+ for line in fin:
+ if args.limit is not None and n_total >= args.limit:
+ break
+ n_total += 1
+ if n_total % 200000 == 0:
+ print(f" ...{n_total} rows processed "
+ f"({n_quiet} kept so far)", file=sys.stderr)
+
+ rec = json.loads(line)
+ if rec.get("exact"):
+ n_exact_skipped += 1
+ continue
+
+ board = chess.Board(rec["fen"])
+
+ if board.is_check():
+ n_excluded_check += 1
+ continue
+
+ is_see_positive = first_move_is_see_positive_capture(
+ board, rec.get("pv", ""))
+ if is_see_positive is None:
+ n_indeterminate += 1
+ # Can't evaluate the filter criterion -- err on the
+ # side of excluding rather than silently keeping a row
+ # that was never actually checked.
+ continue
+ if is_see_positive:
+ n_excluded_capture += 1
+ continue
+
+ if has_hanging_material(board, threshold=SEE_THRESHOLD):
+ n_hanging_material += 1 # reported, not excluded
+
+ fout.write(json.dumps(rec) + "\n")
+ n_quiet += 1
+
+ print(f"Read {n_total} rows ({args.infile})")
+ print(f" {n_exact_skipped} already exact (mate/EGTB/REC) -- skipped")
+ print(f" {n_indeterminate} indeterminate (empty/unparseable PV) -- excluded")
+ print(f" {n_excluded_check} excluded: side to move in check")
+ print(f" {n_excluded_capture} excluded: PV's first move is an "
+ f"SEE-positive (>={SEE_THRESHOLD}cp) capture")
+ print(f" {n_quiet} quiet positions written to {args.outfile}")
+ print(f" (of those, {n_hanging_material} still have *some* other "
+ f"SEE-positive capture available on the board -- reported for "
+ f"visibility, not excluded by this filter)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/annotate_pgns/quiet_filter_games.py b/annotate_pgns/quiet_filter_games.py
new file mode 100644
index 0000000..f4de31e
--- /dev/null
+++ b/annotate_pgns/quiet_filter_games.py
@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+"""Apply the same post-hoc quiet-position filter as quiet_filter.py,
+but to a game-shaped labeled file (build_games.py / fit_k_and_label.py
+output: games.train.labeled.jsonl, games.holdout.labeled.jsonl) rather
+than the flat positions.jsonl.
+
+Deliberately does NOT join against positions.quiet.jsonl -- that file
+has no row index or game_id, and FENs can repeat across different
+games/plies, so matching by FEN content would silently misattribute
+rows. Instead this re-applies the identical predicate
+(quiet_common.position_is_quiet, checked against each row's own PV's
+first move) directly to each position already embedded in the game
+record, so the two filtered views (flat and game-shaped) are
+guaranteed consistent by construction rather than by a fragile join.
+
+A game that loses all of its positions to filtering is dropped
+entirely (an empty "positions": [] list is not useful downstream).
+
+Usage:
+ python3 quiet_filter_games.py \
+ --in runs/sd10_twic_2M/games.train.labeled.jsonl \
+ --out runs/sd10_twic_2M/games.train.labeled.quiet.jsonl
+"""
+import argparse
+import json
+import sys
+
+from quiet_common import chess, position_is_quiet
+from quiet_filter import pv_first_move
+
+
+def filter_game_positions(game_rec):
+ """Returns a new list of kept position rows (exact rows are always
+ kept -- the quiet filter only applies to ordinary, non-exact
+ search scores, same as quiet_filter.py)."""
+ kept = []
+ n_excluded_check = n_excluded_capture = n_indeterminate = 0
+ for pos in game_rec["positions"]:
+ if pos.get("exact"):
+ kept.append(pos)
+ continue
+ board = chess.Board(pos["fen"])
+ move = pv_first_move(board, pos.get("pv", ""))
+ if move is None:
+ n_indeterminate += 1
+ continue
+ if board.is_check():
+ n_excluded_check += 1
+ continue
+ if not position_is_quiet(board, move):
+ n_excluded_capture += 1
+ continue
+ kept.append(pos)
+ return kept, n_excluded_check, n_excluded_capture, n_indeterminate
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--in", dest="infile", required=True)
+ ap.add_argument("--out", dest="outfile", required=True)
+ args = ap.parse_args()
+
+ n_games_in = n_games_out = n_games_dropped = 0
+ n_pos_in = n_pos_out = 0
+ n_excluded_check = n_excluded_capture = n_indeterminate = 0
+
+ with open(args.infile) as fin, open(args.outfile, "w") as fout:
+ for line in fin:
+ game_rec = json.loads(line)
+ n_games_in += 1
+ n_pos_in += len(game_rec["positions"])
+
+ kept, c, p, i = filter_game_positions(game_rec)
+ n_excluded_check += c
+ n_excluded_capture += p
+ n_indeterminate += i
+
+ if not kept:
+ n_games_dropped += 1
+ continue
+
+ game_rec["positions"] = kept
+ fout.write(json.dumps(game_rec) + "\n")
+ n_games_out += 1
+ n_pos_out += len(kept)
+
+ if n_games_in % 2000 == 0:
+ print(f" ...{n_games_in} games processed "
+ f"({n_pos_out} positions kept so far)", file=sys.stderr)
+
+ print(f"Read {args.infile}: {n_games_in} games, {n_pos_in} positions")
+ print(f" {n_excluded_check} positions excluded: side to move in check")
+ print(f" {n_excluded_capture} positions excluded: PV's first move is "
+ f"an SEE-positive capture")
+ print(f" {n_indeterminate} positions excluded: indeterminate "
+ f"(empty/unparseable PV)")
+ print(f" {n_games_dropped} games dropped entirely (lost all positions)")
+ print(f"Wrote {args.outfile}: {n_games_out} games, {n_pos_out} positions")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/annotate_pgns/split_holdout.py b/annotate_pgns/split_holdout.py
new file mode 100644
index 0000000..277c1bf
--- /dev/null
+++ b/annotate_pgns/split_holdout.py
@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+"""Stratified game-level train/holdout split over a games.jsonl
+(build_games.py output).
+
+Splits by GAME, not by individual position -- every position from one
+game lands on the same side of the split, avoiding the leakage a flat
+position-level shuffle would have (adjacent plies / recurring
+middlegame structures from the same game are highly correlated, not
+independent samples; see eval_tune/tune_eval_dna.py's own holdout
+split, which doesn't do this).
+
+Stratified on two axes so the holdout's composition mirrors the full
+pool's rather than a flat 10% sample risking (by chance) too many
+decisive games, too few draws, or skewing toward short/long games:
+ - result: White win / Black win / Draw
+ - length: quartile bin of the game's annotated ply count (quartiles
+ of the actual corpus, not guessed fixed cutoffs)
+10% (configurable) is sampled independently within each of the
+resulting (result, length-quartile) strata, not 10% of the pool
+overall.
+
+Usage:
+ python3 split_holdout.py --in runs/sd10_twic_2M/games.jsonl \
+ --train-out runs/sd10_twic_2M/games.train.jsonl \
+ --holdout-out runs/sd10_twic_2M/games.holdout.jsonl \
+ --holdout-frac 0.1 --seed 0
+"""
+import argparse
+import json
+import math
+import random
+from collections import defaultdict
+
+
+def result_bucket(result):
+ if result == "1-0":
+ return "white_win"
+ if result == "0-1":
+ return "black_win"
+ if result == "1/2-1/2":
+ return "draw"
+ return "unknown"
+
+
+def quartile_bin(length, cuts):
+ for i, c in enumerate(cuts):
+ if length <= c:
+ return i
+ return len(cuts)
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--in", dest="infile", required=True)
+ ap.add_argument("--train-out", required=True)
+ ap.add_argument("--holdout-out", required=True)
+ ap.add_argument("--holdout-frac", type=float, default=0.1)
+ ap.add_argument("--seed", type=int, default=0)
+ args = ap.parse_args()
+
+ games = []
+ with open(args.infile) as f:
+ for line in f:
+ games.append(json.loads(line))
+ print(f"Loaded {len(games)} games from {args.infile}")
+
+ lengths = sorted(len(g["positions"]) for g in games)
+ def pct(p):
+ idx = min(int(p * len(lengths)), len(lengths) - 1)
+ return lengths[idx]
+ cuts = [pct(0.25), pct(0.50), pct(0.75)]
+ print(f"Length quartile cutpoints (annotated plies): {cuts}")
+
+ strata = defaultdict(list)
+ for g in games:
+ key = (result_bucket(g["result"]),
+ quartile_bin(len(g["positions"]), cuts))
+ strata[key].append(g)
+
+ rng = random.Random(args.seed)
+ train_games = []
+ holdout_games = []
+ print(f"\n{'stratum':<24} {'total':>7} {'holdout':>8} {'train':>8}")
+ for key in sorted(strata.keys()):
+ bucket = strata[key]
+ rng.shuffle(bucket)
+ n_holdout = math.ceil(len(bucket) * args.holdout_frac)
+ holdout_games.extend(bucket[:n_holdout])
+ train_games.extend(bucket[n_holdout:])
+ label = f"{key[0]}/q{key[1]}"
+ print(f"{label:<24} {len(bucket):>7} {n_holdout:>8} "
+ f"{len(bucket) - n_holdout:>8}")
+
+ print(f"\n{'TOTAL':<24} {len(games):>7} {len(holdout_games):>8} "
+ f"{len(train_games):>8}")
+
+ with open(args.train_out, "w") as f:
+ for g in train_games:
+ f.write(json.dumps(g) + "\n")
+ with open(args.holdout_out, "w") as f:
+ for g in holdout_games:
+ f.write(json.dumps(g) + "\n")
+
+ n_train_pos = sum(len(g["positions"]) for g in train_games)
+ n_holdout_pos = sum(len(g["positions"]) for g in holdout_games)
+ print(f"\nWrote {args.train_out}: {len(train_games)} games, "
+ f"{n_train_pos} positions")
+ print(f"Wrote {args.holdout_out}: {len(holdout_games)} games, "
+ f"{n_holdout_pos} positions")
+
+
+if __name__ == "__main__":
+ main()