summaryrefslogtreecommitdiff
path: root/annotate_pgns/cross_check_features.py
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/cross_check_features.py
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/cross_check_features.py')
-rw-r--r--annotate_pgns/cross_check_features.py122
1 files changed, 122 insertions, 0 deletions
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()