summaryrefslogtreecommitdiff
path: root/src/eval_tune/mine_king_mates.py
blob: 678c5ea456166273178245ba03b5278f4561a1e3 (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
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
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
#!/usr/bin/env python3
"""Mine middlegame checkmate positions + matched controls for king-safety
defect regression. See CLAUDE.md / conversation context: _EvalKing's
counted "defects" are summed into one scalar and run through one lookup
table with no per-defect weighting; this pulls training data to fit
relative weights instead of hand-picking them.

Output: a CSV of (fen, label, mated_color, n_back, game_id) rows. FEN
extraction only -- no engine call here. The actual per-defect feature
values get pulled from these FENs by the engine-side dump command
(separate step).
"""
import argparse
import chess
import chess.pgn
import csv
import random
import sys

# has_hanging_material (Python SEE, same swap-list algorithm already
# validated against 800k TWIC positions) -- reused rather than
# reimplemented, so "quiet" means the identical thing here as it does
# for the generic TWIC pool. 2026-09-15: this project's original
# mine_king_mates.py design deliberately did NOT filter for quietness
# (a position N plies before mate is *supposed* to capture real,
# unresolved danger, including in-check/mid-tactic cases) -- fine for
# the mate-mining regression, which never fed FENs to a static-eval-vs-
# outcome fit, but wrong for Texel tuning, which needs every training
# position's static eval to be meaningful on its own. Counters below
# report the actual quiet-survival rate directly -- expected to be much
# lower than TWIC's ~93-94% given near-mate positions are inherently
# more likely to be mid-tactic by construction.
sys.path.insert(0, ".")
from tune_eval_dna import has_hanging_material

g_uPositiveCandidates = 0
g_uPositiveQuiet = 0
g_uControlCandidates = 0
g_uControlQuiet = 0


def is_quiet_position(board):
    return (not board.is_check()) and (not has_hanging_material(board))

# Matches eval.c's DO_KING_SAFETY_THRESHOLD minus the VALUE_KING baseline
# _EvalKing adds internally: real threshold is "enemy has at least a
# rook+bishop of non-pawn, non-king material" before king safety is even
# scored at all. No point mining data _EvalKing would ignore.
MATING_MATERIAL_THRESHOLD_CP = 800

PIECE_VALUES_CP = {
    chess.PAWN: 0,  # excluded: "non-pawn material" by definition
    chess.KNIGHT: 300,
    chess.BISHOP: 300,
    chess.ROOK: 500,
    chess.QUEEN: 975,
    chess.KING: 0,  # excluded: baseline, not a mating-material contributor
}


def non_pawn_material_cp(board, color):
    total = 0
    for piece_type in (chess.KNIGHT, chess.BISHOP, chess.ROOK, chess.QUEEN):
        total += len(board.pieces(piece_type, color)) * PIECE_VALUES_CP[piece_type]
    return total


# Mirrors _EvalKing's own middlegame/endgame boundary exactly (eval.c:
# "u = pos->uNonPawnCount[WHITE][0] + pos->uNonPawnCount[BLACK][0]; if (u <
# 8)") -- total non-pawn pieces, including both kings, across both sides.
# Below this, _EvalKing itself stops caring about ring-square/file-storm
# defects and switches to endgame-only terms (centralize king, escort
# passer). Checking the attacker's material alone (non_pawn_material_cp)
# is NOT sufficient on its own: a lone queen mating a bare king trivially
# clears that floor but is a basic mating technique, not a middlegame
# king-safety collapse -- caught by manually sampling FENs from the mined
# set (several K+Q-vs-lone-K endings slipped through material-only
# filtering).
def total_non_pawn_piece_count(board):
    count = 2  # both kings
    for color in (chess.WHITE, chess.BLACK):
        for piece_type in (chess.KNIGHT, chess.BISHOP, chess.ROOK, chess.QUEEN):
            count += len(board.pieces(piece_type, color))
    return count


MIDDLEGAME_MIN_TOTAL_NON_PAWN_PIECES = 8

# 2026-09-15, by direct instruction: a promotion within the last N plies
# of the game means this "mate" is really an endgame pawn-race/technique
# conversion (queen a pawn, then mate the resulting weak/bare king) --
# not a middlegame king-safety collapse. We want positions where one
# side mounts a real attack on a king still attempting fortification/
# defense, not a K+P-and-a-few-pieces endgame decided by promotion.
# Matches the n_backs cap (14) -- if we'd sample as far back as 14 plies
# before mate, a promotion inside that same trailing window taints every
# n_back for this game, not just the ones after the promotion.
LATE_PROMOTION_EXCLUSION_WINDOW = 14


def is_middlegame(board):
    return total_non_pawn_piece_count(board) >= MIDDLEGAME_MIN_TOTAL_NON_PAWN_PIECES


def mine_game(game, n_backs, min_ply_from_start=20,
              promotion_exclusion_window=LATE_PROMOTION_EXCLUSION_WINDOW):
    """Yields (fen, mated_color, n_back) for a game ending in checkmate."""
    board = game.board()
    moves = list(game.mainline_moves())
    if not moves:
        return
    # Replay to the end to confirm checkmate and find the mated side.
    for mv in moves:
        board.push(mv)
    if not board.is_checkmate():
        return
    mated_color = board.turn  # side to move at mate = side that got mated
    total_plies = len(moves)

    # Cheap check -- Move objects already carry .promotion, no replay
    # needed. Skips the WHOLE game's positives, not just n_backs past
    # the promotion, since a late-game promotion taints the game's
    # suitability as a middlegame king-safety example regardless of
    # which n_back we're sampling.
    if any(mv.promotion is not None
           for mv in moves[max(0, total_plies - promotion_exclusion_window):]):
        return

    for n_back in n_backs:
        idx = total_plies - n_back
        if idx < min_ply_from_start:
            continue
        replay = game.board()
        for mv in moves[:idx]:
            replay.push(mv)
        # Only meaningful if the mated side's king safety was even "in
        # scope" for _EvalKing at this point -- attacker must have
        # mating material.
        attacker = not mated_color
        if non_pawn_material_cp(replay, attacker) < MATING_MATERIAL_THRESHOLD_CP:
            continue
        if not is_middlegame(replay):
            continue
        global g_uPositiveCandidates, g_uPositiveQuiet
        g_uPositiveCandidates += 1
        if not is_quiet_position(replay):
            continue
        g_uPositiveQuiet += 1
        yield replay.fen(), mated_color, n_back


def is_draw(game):
    return game.headers.get("Result", "") == "1/2-1/2"


def mine_controls(game, n_needed, rng, min_ply=20, max_ply_from_end=15):
    """Yields (fen, color) control samples from a non-mate game (or a mate
    game's earlier-game portion), gated on the same material threshold,
    kept well clear of the game's ending so a real collapse (if any) isn't
    accidentally captured as a "stable" control.

    Draw games should be called with a larger max_ply_from_end than
    decisive/resignation games (see is_draw callers in main()) -- a draw
    only vouches for a king position if the position stayed quiet for a
    real stretch *after* the sample point too, not just "the game
    eventually drew" (e.g. a middlegame attack that later got defused into
    a simplified drawn endgame doesn't vouch for the earlier position).

    min_ply must be large enough that development is realistically done
    (move ~10, ply 20) -- NOT just "not literally move 1". A large chunk
    of the pool's draws are short draws-by-agreement close to
    filter_pgn.py's own 40-ply floor; combined with a big max_ply_from_end
    and a too-small min_ply, the only plies satisfying BOTH constraints on
    a short game land in the early opening, where minorsathome/development
    features are legitimately still nonzero for completely ordinary
    reasons -- not a real king-safety defect. Confirmed as the actual bug
    behind an earlier version's controls showing minorsathome>=2 in ~85%
    of samples: the engine's own computation was verified correct on a
    real developed middlegame FEN, so the contamination was 100% a
    sampling-window problem, not an eval bug.

    A game too short to satisfy both min_ply and max_ply_from_end is
    skipped entirely rather than forced into an early sample -- fewer,
    honest control samples beat many contaminated ones."""
    moves = list(game.mainline_moves())
    total_plies = len(moves)
    end_guard = max_ply_from_end
    if total_plies < min_ply + end_guard:
        return
    candidates = list(range(min_ply, max(min_ply, total_plies - end_guard)))
    if not candidates:
        return
    # BUG #1 (found while investigating why controls skewed early-game):
    # shuffling `candidates` then iterating `sorted(candidates)` completely
    # undid the shuffle, so every game always visited its window in
    # ascending ply order and grabbed the *earliest* plies satisfying
    # per_game_budget -- systematically starving late-middlegame coverage
    # in any game with a wide candidate window, regardless of min_ply.
    #
    # BUG #2 (found immediately after "fixing" #1 with a random subsample
    # + sort): stopping the ascending-order loop as soon as `produced >=
    # n_needed` still biases toward whichever subsample members happen to
    # be smallest, since ascending iteration always finds the low end of
    # the subsample first. Measured: even after subsampling, sampled plies
    # still clustered at the 20th-40th percentile of game length instead
    # of spreading across the whole game.
    #
    # Fix for both: replay ascending through an oversampled random subset
    # ONCE (required -- python-chess boards are only walkable forward) to
    # collect every candidate result that passes the material/middlegame
    # filters, then pick which ones to actually keep via `rng.sample` on
    # that result LIST -- decoupling "must replay in order" from "which
    # results count" is what actually gives uniform ply coverage.
    num_to_try = min(len(candidates), max(n_needed * 4, 20))
    sample_indices = sorted(rng.sample(candidates, num_to_try))
    replay = game.board()
    ply = 0
    valid_results = []
    for idx in sample_indices:
        while ply < idx:
            replay.push(moves[ply])
            ply += 1
        if not is_middlegame(replay):
            continue
        global g_uControlCandidates, g_uControlQuiet
        g_uControlCandidates += 1
        if not is_quiet_position(replay):
            continue
        g_uControlQuiet += 1
        for color in (chess.WHITE, chess.BLACK):
            attacker = not color
            if non_pawn_material_cp(replay, attacker) >= MATING_MATERIAL_THRESHOLD_CP:
                valid_results.append((replay.fen(), color))

    if len(valid_results) > n_needed:
        valid_results = rng.sample(valid_results, n_needed)
    for fen, color in valid_results:
        yield fen, color


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("pgn_file")
    ap.add_argument("out_csv")
    ap.add_argument("--n-backs", type=int, nargs="+",
                     default=[4, 6, 8, 10, 12, 14],
                     help="Plies before mate to sample -- capped at 14 by "
                          "direct instruction (anything farther back is too "
                          "loosely connected to the actual mating attack).")
    ap.add_argument("--max-games", type=int, default=None,
                     help="Stop after scanning this many games (0/None = all)")
    ap.add_argument("--control-ratio", type=float, default=1.0,
                     help="Controls per positive, roughly")
    ap.add_argument("--draw-only-controls", action="store_true",
                     help="Don't fall back to decisive/resignation games "
                          "for controls even if draws don't fill the quota")
    ap.add_argument("--draw-end-guard", type=int, default=24,
                     help="Plies of quiet play required *after* a draw "
                          "control sample, stricter than decisive games "
                          "since a draw only vouches for a position that "
                          "stayed sound past the sample point too")
    ap.add_argument("--decisive-end-guard", type=int, default=15)
    ap.add_argument("--min-ply", type=int, default=20,
                     help="Earliest ply eligible for any sample (positive "
                          "or control) -- must be well past normal opening "
                          "development (ply 20 = move 10), not just >0. "
                          "See mine_controls' docstring for why this "
                          "matters a lot for short draws.")
    ap.add_argument("--seed", type=int, default=42)
    ap.add_argument("--byte-start", type=int, default=0,
                     help="Seek here before reading (shard support -- "
                          "must be a real game-start offset, e.g. from "
                          "shard_mine.py, not an arbitrary byte)")
    ap.add_argument("--byte-end", type=int, default=None,
                     help="Stop once past this offset (shard support)")
    args = ap.parse_args()

    rng = random.Random(args.seed)
    positives = []
    draw_pool_games = []
    decisive_pool_games = []

    with open(args.pgn_file, encoding="utf-8", errors="replace") as f:
        if args.byte_start:
            f.seek(args.byte_start)
        n_scanned = 0
        while True:
            if args.max_games and n_scanned >= args.max_games:
                break
            if args.byte_end is not None and f.tell() >= args.byte_end:
                break
            game = chess.pgn.read_game(f)
            if game is None:
                break
            n_scanned += 1
            if n_scanned % 20000 == 0:
                print(f"...scanned {n_scanned} games, "
                      f"{len(positives)} positives so far", file=sys.stderr)
            try:
                found_any = False
                for fen, mated_color, n_back in mine_game(
                        game, args.n_backs, min_ply_from_start=args.min_ply):
                    positives.append((fen, 1, chess.COLOR_NAMES[mated_color],
                                       n_back, n_scanned))
                    found_any = True
                if not found_any:
                    if is_draw(game):
                        draw_pool_games.append(game)
                    else:
                        decisive_pool_games.append(game)
            except Exception as e:
                print(f"skip game {n_scanned}: {e}", file=sys.stderr)
                continue

    print(f"Scanned {n_scanned} games -> {len(positives)} positive samples "
          f"from checkmate games. Control pools: {len(draw_pool_games)} "
          f"draws, {len(decisive_pool_games)} decisive/other.",
          file=sys.stderr)

    n_controls_needed = int(len(positives) * args.control_ratio)
    controls = []
    per_game_budget = 2

    # Draws first -- a position two comparable players couldn't crack is
    # stronger "this was sound" evidence than a resignation, where the
    # king position may simply never have been tested by that opponent.
    rng.shuffle(draw_pool_games)
    for game in draw_pool_games:
        if len(controls) >= n_controls_needed:
            break
        try:
            for fen, color in mine_controls(game, per_game_budget, rng,
                                             min_ply=args.min_ply,
                                             max_ply_from_end=args.draw_end_guard):
                controls.append((fen, 0, chess.COLOR_NAMES[color], -1, None, "draw"))
        except Exception:
            continue

    n_from_draws = len(controls)
    print(f"Collected {n_from_draws} control samples from draws.",
          file=sys.stderr)

    if not args.draw_only_controls and len(controls) < n_controls_needed:
        rng.shuffle(decisive_pool_games)
        for game in decisive_pool_games:
            if len(controls) >= n_controls_needed:
                break
            try:
                for fen, color in mine_controls(game, per_game_budget, rng,
                                                 min_ply=args.min_ply,
                                                 max_ply_from_end=args.decisive_end_guard):
                    controls.append((fen, 0, chess.COLOR_NAMES[color], -1, None, "decisive"))
            except Exception:
                continue
        print(f"Collected {len(controls) - n_from_draws} additional control "
              f"samples from decisive/resignation games (quota not met by "
              f"draws alone).", file=sys.stderr)

    print(f"Collected {len(controls)} control samples total.", file=sys.stderr)

    with open(args.out_csv, "w", newline="") as f:
        w = csv.writer(f)
        w.writerow(["fen", "label", "mated_color_or_sampled_color", "n_back",
                     "game_index", "control_source"])
        for row in positives:
            w.writerow(row + (None,))
        for row in controls:
            w.writerow(row)

    print(f"Wrote {len(positives) + len(controls)} rows to {args.out_csv}",
          file=sys.stderr)
    pos_rate = (g_uPositiveQuiet / g_uPositiveCandidates
                if g_uPositiveCandidates else 0.0)
    ctl_rate = (g_uControlQuiet / g_uControlCandidates
                if g_uControlCandidates else 0.0)
    print(f"Quiet-filter yield: positives {g_uPositiveQuiet}/"
          f"{g_uPositiveCandidates} ({pos_rate:.1%}), controls "
          f"{g_uControlQuiet}/{g_uControlCandidates} ({ctl_rate:.1%})",
          file=sys.stderr)


if __name__ == "__main__":
    main()