summaryrefslogtreecommitdiff
path: root/annotate_pgns/cross_check_features.py
blob: f336cdafae71a40d7d6e3786e0f00720585b8917 (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
113
114
115
116
117
118
119
120
121
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()