summaryrefslogtreecommitdiff
path: root/src/eval_tune
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-09-15 13:31:36 -0700
committerScott Gasch <[email protected]>2026-09-15 13:31:36 -0700
commit390e675849ff42c8b7e1806f75552de988741fff (patch)
tree17b8792aeaf74373bbaef3bac6b71bb5c0d74c69 /src/eval_tune
parentca73728f7dbf01c4b39c7496729f81b1fbffa00b (diff)
Rewrite _EvalKing as a linear ring-scan and promote its terms to DNA-tunable constants; retune via mate-mining regression, ElasticNet/Ridge, and logistic-vs-material refits.
Splits _EvalKing's own king-location table from CountKingSafetyDefects' search-gate copy so future eval retunes can't silently perturb extension/reduction gate frequency. Bumps KING_BACK_FLIGHT_BONUS (+1 -> +3) and reorders/nudges the ring-scan sub-terms (minor/pawn/xray/undefended) to match relative rankings that replicated across three independent calibration methods, while leaving KING_QUEEN_PROXIMITY_DANGER and KING_MISSING_ONE_CASTLE_OPTION untouched pending further discussion. Self-play (640 games) is roughly even (score 0.509, llr=+0.17, far from either SPRT bound) but consistently faster (nps candidate=1362140 vs head=1333682) at equal average depth -- landing as a wash/speed-neutral checkpoint, not a confirmed tactical improvement, for further tuning to build on. Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/eval_tune')
-rwxr-xr-xsrc/eval_tune/dna_diff.py94
-rw-r--r--src/eval_tune/extract_king_features.py2
-rwxr-xr-xsrc/eval_tune/match_play.py2
-rw-r--r--src/eval_tune/mine_king_mates.py72
-rwxr-xr-xsrc/eval_tune/test_vs_head.sh46
-rwxr-xr-xsrc/eval_tune/tune_eval_dna.py182
6 files changed, 341 insertions, 57 deletions
diff --git a/src/eval_tune/dna_diff.py b/src/eval_tune/dna_diff.py
index ecdd6bb..1b44280 100755
--- a/src/eval_tune/dna_diff.py
+++ b/src/eval_tune/dna_diff.py
@@ -1,45 +1,89 @@
#!/usr/bin/env python3
"""Human-readable diff between a baseline DNA dump and a tuned .dna
-file: names each of g_EvalDNA's 56 arrays (order taken directly from
-eval.c's g_EvalDNA[] table) and, for the 128-cell board-shaped
-location tables, renders an actual 8x8 grid diff instead of a wall of
-raw numbers."""
+file: names each of g_EvalDNA's arrays and, for the 128-cell board-
+shaped location tables, renders an actual 8x8 grid diff instead of a
+wall of raw numbers.
+
+2026-09-15: DNA_NAMES used to be a hand-maintained list "exact order
+from eval.c:604-659" -- found to have drifted badly (wrong from index
+0: eval.c's g_EvalDNA[] no longer even contains TRADE_PIECES/
+DONT_TRADE_PAWNS, the list's first two entries, and roughly a dozen
+other names had also renamed/moved/vanished over past sessions without
+this list being updated). Every consumer of this list (this script,
+dna_trend.py, tune_eval_dna.py's --freeze) had been silently mislabeling
+or misdirecting against the wrong array for a while -- plausibly the
+real explanation for at least some past "Texel tuning produced
+nonsensical results" reports, since a diff/freeze targeting the wrong
+array by name looks exactly like that from the output alone.
+
+Fixed by deriving this list from eval.c directly at import time instead
+of hand-maintaining a parallel copy that can silently drift again. Only
+falls back to the hardcoded snapshot below (kept only as a last resort
+for running this script somewhere eval.c isn't reachable) with a loud
+warning -- never silently.
+"""
+import re
import sys
+from pathlib import Path
-# Exact order from eval.c:604-659 (g_EvalDNA[] initializer).
-DNA_NAMES = [
- "TRADE_PIECES", "DONT_TRADE_PAWNS", "REDUCED_MATERIAL_DOWN_SCALER",
+# Last-resort fallback only -- see module docstring. Regenerate by
+# running _derive_dna_names() with eval.c present and pasting the result
+# here if this ever needs to be updated by hand again.
+_FALLBACK_DNA_NAMES = [
+ "REDUCED_MATERIAL_DOWN_SCALER", "CASTLE_AND_DEVELOPMENT_SCALER",
"REDUCED_MATERIAL_UP_SCALER", "PASSER_MATERIAL_UP_SCALER",
"PAWN_CENTRALITY_BONUS", "BACKWARD_SHIELDED_BY_LOCATION",
"BACKWARD_EXPOSED_BY_LOCATION", "DOUBLED_PAWN_PENALTY_BY_COUNT",
- "ISOLATED_PAWN_PENALTY_BY_COUNT", "ISOLATED_PAWN_BY_PAWNFILE",
- "ISOLATED_EXPOSED_PAWN", "ISOLATED_DOUBLED_PAWN", "PASSER_BY_RANK",
- "CANDIDATE_PASSER_BY_RANK", "CONNECTED_PASSERS_BY_RANK",
- "SUPPORTED_PASSER_BY_RANK", "OUTSIDE_PASSER_BY_DISTANCE",
+ "ISOLATED_PAWN_BY_PAWNFILE", "ISOLATED_EXPOSED_PAWN",
+ "ISOLATED_DOUBLED_PAWN", "PASSER_BY_RANK", "CANDIDATE_PASSER_BY_RANK",
+ "CONNECTED_PASSERS_BY_RANK", "SUPPORTED_PASSER_BY_RANK",
+ "KING_SUPPORTING_OWN_PASSER_BY_RANK", "OUTSIDE_PASSER_BY_DISTANCE",
"PASSER_BONUS_AS_MATERIAL_COMES_OFF", "RACER_WINS_RACE",
"UNDEVELOPED_MINORS_IN_OPENING", "BISHOP_OVER_KNIGHT_IN_ENDGAME",
- "BISHOP_PAIR", "STATIONARY_PAWN_ON_BISHOP_COLOR",
+ "BISHOP_PAIR_BONUS", "STATIONARY_PAWN_ON_BISHOP_COLOR",
"TRANSIENT_PAWN_ON_BISHOP_COLOR", "BISHOP_MOBILITY_BY_SQUARES",
- "BISHOP_MAX_MOBILITY_IN_A_ROW_BONUS",
- "BISHOP_UNASSAILABLE_BY_DIST_FROM_EKING", "BISHOP_IN_CLOSED_POSITION",
- "KNIGHT_CENTRALITY_BONUS", "KNIGHT_KING_TROPISM_BONUS",
- "KNIGHT_UNASSAILABLE_BY_DIST_FROM_EKING",
+ "BISHOP_UNASSAILABLE_BY_DIST_FROM_EKING", "KNIGHT_CENTRALITY_BONUS",
+ "KNIGHT_KING_TROPISM_BONUS", "KNIGHT_UNASSAILABLE_BY_DIST_FROM_EKING",
"KNIGHT_ON_INTERESTING_SQUARE_BY_RANK", "KNIGHT_MOBILITY_BY_COUNT",
"KNIGHT_WITH_N_PAWNS_SUPPORTING", "KNIGHT_IN_CLOSED_POSITION",
- "ROOK_ON_FULL_OPEN_BY_DIST_FROM_EKING",
- "ROOK_ON_HALF_OPEN_WITH_ENEMY_BY_DIST_FROM_EKING",
- "ROOK_ON_HALF_OPEN_WITH_FRIEND_BY_DIST_FROM_EKING",
- "ROOK_BEHIND_PASSER_BY_PASSER_RANK", "ROOK_LEADS_PASSER_BY_PASSER_RANK",
- "KING_TRAPPING_ROOK", "ROOK_TRAPPING_EKING",
+ "ROOK_ON_FULL_OPEN", "ROOK_ON_HALF_OPEN_WITH_ENEMY",
+ "ROOK_ON_HALF_OPEN_WITH_FRIEND", "ROOK_WITH_ENEMY_PASSER",
+ "ROOK_WITH_FRIEND_PASSER", "ROOK_BEHIND_PASSER_BY_PASSER_RANK",
+ "ROOK_LEADS_PASSER_BY_PASSER_RANK", "KING_TRAPPING_ROOK",
"ROOK_VALUE_AS_PAWNS_COME_OFF", "ROOK_CONNECTED_VERT",
"ROOK_CONNECTED_HORIZ", "ROOK_MOBILITY_BY_SQUARES",
- "ROOK_MAX_MOBILITY_IN_A_ROW_BONUS", "QUEEN_MOBILITY_BY_SQUARES",
- "QUEEN_OUT_EARLY", "QUEEN_KING_TROPISM",
- "QUEEN_ATTACKS_SQ_NEXT_TO_KING", "KING_INITIAL_COUNTER_BY_LOCATION",
+ "QUEEN_MOBILITY_BY_SQUARES", "QUEEN_KING_TROPISM",
+ "KING_INITIAL_COUNTER_BY_LOCATION", "KING_EVAL_INITIAL_LOCATION",
"KING_TO_CENTER", "KING_SAFETY_BY_COUNTER",
- "KING_MISSING_ONE_CASTLE_OPTION",
+ "KING_QUEEN_PROXIMITY_DANGER", "KING_MISSING_ONE_CASTLE_OPTION",
+ "KING_BACK_FLIGHT_BONUS", "KING_RING_ATTACK_MINOR",
+ "KING_RING_ATTACK_ROOK", "KING_RING_ATTACK_PAWN",
+ "KING_RING_UNDEFENDED", "KING_RING_XRAY_BONUS",
+ "KING_RING_FRONT_FLIGHT_PENALTY", "KING_INITIAL_LOCATION_SCALE",
+ "KING_ATTACK_PATTERN_SCALE", "KING_DANGER_ACCELERATOR",
]
+
+def _derive_dna_names(eval_c_path="../eval.c"):
+ """Parse g_EvalDNA[]'s initializer directly out of eval.c, in
+ order -- the only way this list can't silently drift out of sync
+ with the real array again."""
+ text = Path(eval_c_path).read_text()
+ m = re.search(r'static DNA_BASE_SIZE g_EvalDNA\[\] = \{(.*?)\n\};',
+ text, re.DOTALL)
+ if not m:
+ return None
+ return re.findall(r'DNA_(?:ARRAY|MATRIX|VAR)\(([A-Za-z0-9_]+)\)', m.group(1))
+
+
+DNA_NAMES = _derive_dna_names()
+if DNA_NAMES is None:
+ print("WARNING: could not find/parse g_EvalDNA[] in ../eval.c -- "
+ "falling back to a hardcoded DNA_NAMES snapshot that WILL "
+ "silently drift out of date. Run this from eval_tune/ with "
+ "the real eval.c reachable if at all possible.", file=sys.stderr)
+ DNA_NAMES = _FALLBACK_DNA_NAMES
+
# Arrays laid out as a 128-cell "0x88-style" board: 8 files + 8 padding
# zeros per rank, 8 ranks (see eval.c's literal formatting -- each row
# of the C initializer is one rank, padded to 16 slots). Rendered
diff --git a/src/eval_tune/extract_king_features.py b/src/eval_tune/extract_king_features.py
index 509f823..08d39fa 100644
--- a/src/eval_tune/extract_king_features.py
+++ b/src/eval_tune/extract_king_features.py
@@ -167,7 +167,7 @@ def main():
print(f"All shards done in {time.time() - t0:.1f}s", file=sys.stderr)
- base_cols = ["label", "mated_color_or_sampled_color", "n_back",
+ base_cols = ["fen", "label", "mated_color_or_sampled_color", "n_back",
"game_index", "control_source"]
fieldnames = base_cols + FEATURE_KEYS
diff --git a/src/eval_tune/match_play.py b/src/eval_tune/match_play.py
index c4cad6c..e2e089a 100755
--- a/src/eval_tune/match_play.py
+++ b/src/eval_tune/match_play.py
@@ -582,7 +582,7 @@ def main():
"default to 8 even when --st was passed, which "
"silently ignored --st entirely -- fixed so "
"the two are actually mutually exclusive now.")
- tc.add_argument("--st", type=int, default=None,
+ tc.add_argument("--st", type=float, default=None,
help="seconds per move per side instead of fixed depth")
ap.add_argument("--max-plies", type=int, default=200)
ap.add_argument("--scratch", default="/usr/local/tmp/typhoon_match")
diff --git a/src/eval_tune/mine_king_mates.py b/src/eval_tune/mine_king_mates.py
index 7b5866b..678c5ea 100644
--- a/src/eval_tune/mine_king_mates.py
+++ b/src/eval_tune/mine_king_mates.py
@@ -17,6 +17,31 @@ 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
@@ -61,12 +86,24 @@ def total_non_pawn_piece_count(board):
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):
+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())
@@ -80,6 +117,15 @@ def mine_game(game, n_backs, min_ply_from_start=20):
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:
@@ -95,6 +141,11 @@ def mine_game(game, n_backs, min_ply_from_start=20):
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
@@ -171,6 +222,11 @@ def mine_controls(game, n_needed, rng, min_ply=20, max_ply_from_end=15):
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:
@@ -186,7 +242,11 @@ 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])
+ 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,
@@ -309,6 +369,14 @@ def main():
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__":
diff --git a/src/eval_tune/test_vs_head.sh b/src/eval_tune/test_vs_head.sh
index 871b801..29c050f 100755
--- a/src/eval_tune/test_vs_head.sh
+++ b/src/eval_tune/test_vs_head.sh
@@ -1,11 +1,45 @@
#!/usr/bin/env bash
+#
+# Gated self-play match against a baseline binary. Historically this
+# always compared the live working-tree build against head_reference/
+# -- generalized (2026-09-14) to take both binary paths as args so two
+# arbitrary builds (e.g. a file-storm-only worktree build vs. the
+# current working tree, to isolate one change at a time when several
+# have landed together -- see king_safety_data/HANDOFF.md's session
+# notes on not measuring two eval changes in one match) can be compared
+# without hand-rolling a match_play.py invocation each time.
+#
+# Usage: test_vs_head.sh [head_binary] [candidate_binary] [label]
+#
+# head_binary Baseline engine. Defaults to head_reference/typhoon
+# (the usual "is this better than last checkpoint"
+# question).
+# candidate_binary Candidate engine. Defaults to a fresh copy of the
+# live working-tree build (../typhoon) into
+# /tmp/typhoon/typhoon_match_play_candidate -- pass
+# an explicit path (already built) to skip that
+# copy/freeze step entirely, e.g. when comparing
+# two prebuilt worktree binaries.
+# label Distinguishes scratch dir/log/pgn-out so multiple
+# matches can run concurrently without colliding.
+# Defaults to "match".
+#
+# Examples:
+# test_vs_head.sh # old default behavior
+# test_vs_head.sh /tmp/typhoon/filestorm_only /tmp/typhoon/candidate queenprox_backflight
set -e
-CANDIDATE=/tmp/typhoon/typhoon_match_play_candidate
-HEAD=../../head_reference/typhoon
+HEAD="${1:-../../head_reference/typhoon}"
+LABEL="${3:-match}"
+
+if [ -n "$2" ]; then
+ CANDIDATE="$2"
+else
+ CANDIDATE=/tmp/typhoon/typhoon_match_play_candidate
+ cp -f ../typhoon ${CANDIDATE}
+fi
-cp -f ../typhoon ${CANDIDATE}
python3 ./match_play.py \
${HEAD} \
${CANDIDATE} \
@@ -14,6 +48,6 @@ python3 ./match_play.py \
--workers 12 \
--st 1 \
--sprt --elo0 0 --elo1 5 \
- --scratch /usr/local/tmp/typhoon_match_overnight \
- --log overnight_match.log \
- --pgn-out ../self_play_games.pgn
+ --scratch /usr/local/tmp/typhoon_match_${LABEL} \
+ --log ${LABEL}.log \
+ --pgn-out ../${LABEL}_games.pgn
diff --git a/src/eval_tune/tune_eval_dna.py b/src/eval_tune/tune_eval_dna.py
index b3960f8..96796dc 100755
--- a/src/eval_tune/tune_eval_dna.py
+++ b/src/eval_tune/tune_eval_dna.py
@@ -67,8 +67,32 @@ class Engine:
# flag (see its "--logfile matters here" comment). A caller
# that actually wants a logfile can still pass extra_args
# explicitly to override this default.
+ #
+ # --hash 1m (2026-09-15): main.c defaults to 256m with no
+ # --hash flag at all, and command.c's SetBoardCommand calls
+ # PreGameReset -> ClearHashTable on EVERY setboard, which is a
+ # full memset(g_pHashTable, 0, g_uHashTableSizeBytes) -- hash.c
+ # confirms this is unconditional, not incremental. Measured
+ # live: ~25ms per setboard+eval round trip with the 256m
+ # default, matching a 256MB memset almost exactly, and it's
+ # WORSE than linear across N parallel workers since memory
+ # bandwidth (unlike most CPU work) is a genuinely shared,
+ # finite resource -- 18 workers all memset-ing 256MB
+ # concurrently starved each other on the memory bus (measured
+ # 27 positions/s aggregate against an expected ~700/s if this
+ # were compute-bound and scaled cleanly). This class only ever
+ # scores one-off positions (no repeated-search benefit from a
+ # large TT anyway), so hashing is disabled outright rather than
+ # just shrunk -- 2026-09-15: "none" measured ~30% faster than
+ # "1m" (951/s vs 730/s single-process) with 0/200 score
+ # mismatches for both eval and qsearch against "1m" -- _ParseHashOption
+ # ("none"/"no" -> 0 entries) is a real, intentionally-supported
+ # engine option (hash.c's ClearHashTable guards on g_pHashTable
+ # != NULL, so a disabled table skips the memset for free), not
+ # an untested codepath, so no reason to prefer the smaller-but-
+ # nonzero table over this now that it's been measured safe.
if extra_args is None:
- extra_args = ["--logfile", "-"]
+ extra_args = ["--logfile", "-", "--hash", "none"]
self.proc = subprocess.Popen(
[path] + extra_args,
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
@@ -83,6 +107,17 @@ class Engine:
self._send("protover 2")
self._drain_features()
self._send("force") # we set positions, engine doesn't play
+ # 2026-09-15: g_Options.fShouldPost defaults TRUE (main.c), so
+ # every setboard prints a full ASCII board diagram that this
+ # class's readers then have to skip past line-by-line -- real,
+ # measured cost at corpus scale (this is the exact bug
+ # CLAUDE.md already documents extract_king_features.py hitting
+ # and fixing; it just never got applied here). At ~9x the
+ # per-position cost of a full sd4 search in one live comparison,
+ # this was almost certainly the dominant cost of every
+ # eval_fen[s_batched]/qsearch_fen[s_batched] call this class has
+ # ever made, not anything about eval/qsearch being expensive.
+ self._send("nopost")
atexit.register(self._hard_cleanup)
def __enter__(self):
@@ -467,6 +502,8 @@ def filter_by_qsearch_agreement(engine, positions, max_diff_cp=30,
load_training_positions' module comment: qsearch there would need the
book disabled, a real time budget, and still return a useless 0 swing
for tactics that resolve into recognized draws/endgames)."""
+ import time as _time
+ t0 = _time.time()
kept = []
n_dropped = 0
for i in range(0, len(positions), batch_size):
@@ -482,12 +519,86 @@ def filter_by_qsearch_agreement(engine, positions, max_diff_cp=30,
kept.append((fen, result))
else:
n_dropped += 1
+ done = i + len(chunk)
+ elapsed = _time.time() - t0
+ rate = done / elapsed if elapsed > 0 else 0
+ eta = (len(positions) - done) / rate if rate > 0 else float("inf")
+ print(f" ...{done}/{len(positions)} checked "
+ f"({len(kept)} kept, {n_dropped} dropped) "
+ f"[{rate:.0f}/s, eta {eta/60:.1f}m]")
print(f" qsearch-agreement filter: kept {len(kept)}/{len(positions)} "
f"(dropped {n_dropped} where |eval-qsearch| > {max_diff_cp}cp "
f"or either was a mate score)")
return kept
+def load_quiet_position_pool(engine, engine_path, pgn_path, max_positions=200_000,
+ sample_every=8, min_elo=2400, game_stride=1,
+ qsearch_max_diff=None, cache_dir="position_cache",
+ force_rescan=False):
+ """Combines load_training_positions (SEE-quiet PGN scan, already its
+ own cache) with filter_by_qsearch_agreement (the stricter, engine-
+ driven gate) behind ONE cache keyed on everything that could change
+ which positions end up in the final pool -- so a repeat run with the
+ same settings against the same engine build can skip straight past
+ both the PGN scan AND the qsearch pass entirely, not just the scan.
+
+ The qsearch-agreement result depends on the actual engine binary and
+ whatever DNA is currently loaded into it (a different eval.c build,
+ or different baseline constants, can legitimately classify a
+ different subset of positions as "quiet"), unlike the pure-Python
+ SEE filter -- so this cache key includes the binary's mtime+size and
+ a hash of its currently-loaded baseline DNA on top of
+ load_training_positions' own key material, and is intentionally a
+ SEPARATE cache file rather than folded into that one, so a binary
+ rebuild invalidates only the qsearch-dependent layer, not the
+ (unaffected) raw PGN-scan result underneath it.
+
+ force_rescan forces both layers to recompute, matching
+ load_training_positions' own flag semantics.
+ """
+ raw_positions = load_training_positions(
+ pgn_path, max_positions=max_positions, sample_every=sample_every,
+ min_elo=min_elo, game_stride=game_stride, cache_dir=cache_dir,
+ force_rescan=force_rescan,
+ )
+ if qsearch_max_diff is None:
+ return raw_positions
+
+ engine_path = Path(engine_path)
+ est = engine_path.stat()
+ baseline_flat, _ = engine.dump_dna()
+ dna_hash = hashlib.sha256(str(baseline_flat).encode()).hexdigest()[:16]
+ key_material = (
+ f"{pgn_path}|{max_positions}|{sample_every}|{min_elo}|{game_stride}|"
+ f"qsearch|{qsearch_max_diff}|{engine_path}|{est.st_mtime}|"
+ f"{est.st_size}|{dna_hash}"
+ )
+ key = hashlib.sha256(key_material.encode()).hexdigest()[:16]
+ cache_path = Path(cache_dir) / f"quiet_pool_{key}.tsv"
+
+ if cache_path.exists() and not force_rescan:
+ positions = []
+ with open(cache_path) as f:
+ for line in f:
+ fen, result = line.rstrip("\n").split("\t")
+ positions.append((fen, float(result)))
+ print(f" loaded {len(positions)} qsearch-filtered positions from "
+ f"cache {cache_path}")
+ return positions
+
+ print(f"Filtering by eval/qsearch agreement (max_diff={qsearch_max_diff}cp)...")
+ filtered = filter_by_qsearch_agreement(
+ engine, raw_positions, max_diff_cp=qsearch_max_diff
+ )
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
+ with open(cache_path, "w") as f:
+ for fen, result in filtered:
+ f.write(f"{fen}\t{result}\n")
+ print(f" cached {len(filtered)} qsearch-filtered positions to {cache_path}")
+ return filtered
+
+
def load_training_positions(pgn_path, max_positions=200_000, sample_every=8,
min_elo=2400, game_stride=1,
cache_dir="position_cache", force_rescan=False):
@@ -592,6 +703,9 @@ def _load_training_positions_uncached(pgn_path, max_positions=200_000,
ply += 1
if is_quiet_training_ply(board, ply, was_capture, sample_every):
positions.append((board.fen(), r))
+ if len(positions) % 20000 == 0:
+ print(f" ...{len(positions)} positions extracted "
+ f"({game_index} games scanned so far)")
return positions
@@ -890,24 +1004,33 @@ def write_dna_file(path, flat, row_lengths):
def tune(engine_path, pgn_path, dna_scratch_dir, out_path,
batch_size=None, holdout_frac=0.1, full_eval_every=1, max_passes=6,
n_workers=2, max_positions=200_000, min_elo=2400, frozen_arrays=None,
- game_stride=1, force_rescan=False, qsearch_max_diff=None):
+ game_stride=1, force_rescan=False, qsearch_max_diff=None,
+ position_file=None):
"""batch_size=None (the default) sizes each pass's training batch as
1/10th of the training pool (post-holdout-split) instead of a fixed
count -- a fixed 2000 was fine at the 25k-position scale this was
first tuned against, but silently stayed thin (~4 positions per
tunable parameter) once runs started loading 600k positions,
since nothing scaled the batch with the pool. Pass an explicit int
- to override."""
+ to override.
+
+ position_file (2026-09-15): a pre-built (fen, result) TSV -- e.g.
+ from build_curated_texel_pool.py's matched mate/control pool --
+ bypasses load_quiet_position_pool (PGN scan + qsearch filter)
+ entirely and loads this file directly instead. pgn_path is ignored
+ when set, but still required positionally for call-site stability.
+ """
import time as _time
run_start = _time.time()
engine = EnginePool(engine_path, dna_scratch_dir, n_workers=n_workers)
try:
- _tune_body(engine, pgn_path, out_path, batch_size, holdout_frac,
- full_eval_every, max_passes, max_positions, min_elo,
- run_start, frozen_arrays=frozen_arrays,
+ _tune_body(engine, engine_path, pgn_path, out_path, batch_size,
+ holdout_frac, full_eval_every, max_passes, max_positions,
+ min_elo, run_start, frozen_arrays=frozen_arrays,
game_stride=game_stride, force_rescan=force_rescan,
- qsearch_max_diff=qsearch_max_diff)
+ qsearch_max_diff=qsearch_max_diff,
+ position_file=position_file)
finally:
# Graceful quit() is what actually releases each engine's
# SysV semaphore (input.c) -- a bare kill() does not, since it
@@ -917,10 +1040,10 @@ def tune(engine_path, pgn_path, dna_scratch_dir, out_path,
engine.quit()
-def _tune_body(engine, pgn_path, out_path, batch_size, holdout_frac,
+def _tune_body(engine, engine_path, pgn_path, out_path, batch_size, holdout_frac,
full_eval_every, max_passes, max_positions, min_elo, run_start,
frozen_arrays=None, game_stride=1, force_rescan=False,
- qsearch_max_diff=None):
+ qsearch_max_diff=None, position_file=None):
import time as _time
baseline_flat, row_lengths = engine.dump_dna()
@@ -936,21 +1059,24 @@ def _tune_body(engine, pgn_path, out_path, batch_size, holdout_frac,
print(f"Freezing {n_frozen}/{len(param_vec)} parameters in "
f"{len(frozen_arrays)} array(s): {sorted(frozen_arrays)}")
- print(f"Loading up to {max_positions} quiet positions (min_elo={min_elo}, "
- f"game_stride={game_stride})...")
t0 = _time.time()
- all_positions = load_training_positions(
- pgn_path, max_positions=max_positions, min_elo=min_elo,
- game_stride=game_stride, force_rescan=force_rescan
- )
-
- if qsearch_max_diff is not None:
- print(f"Filtering by eval/qsearch agreement (max_diff={qsearch_max_diff}cp)...")
- t_qs = _time.time()
- all_positions = filter_by_qsearch_agreement(
- engine, all_positions, max_diff_cp=qsearch_max_diff
+ if position_file is not None:
+ print(f"Loading pre-built position pool from {position_file} "
+ f"(bypassing PGN scan + qsearch filter -- already done "
+ f"when this file was built)...")
+ all_positions = []
+ with open(position_file) as f:
+ for line in f:
+ fen, result = line.rstrip("\n").split("\t")
+ all_positions.append((fen, float(result)))
+ else:
+ print(f"Loading up to {max_positions} quiet positions (min_elo={min_elo}, "
+ f"game_stride={game_stride})...")
+ all_positions = load_quiet_position_pool(
+ engine, engine_path, pgn_path, max_positions=max_positions,
+ min_elo=min_elo, game_stride=game_stride,
+ qsearch_max_diff=qsearch_max_diff, force_rescan=force_rescan
)
- print(f" filtered in {_time.time()-t_qs:.1f}s")
random.shuffle(all_positions)
print(f" got {len(all_positions)} positions in {_time.time()-t0:.1f}s")
@@ -1168,6 +1294,16 @@ if __name__ == "__main__":
if no_pgn_elo_filter:
args.remove("--no-pgn-elo-filter")
+ # --position-file: a pre-built (fen, result) TSV (e.g. from
+ # build_curated_texel_pool.py) -- bypasses the PGN scan + qsearch
+ # filter entirely. pgn_path is still required positionally but
+ # ignored when this is set.
+ position_file = None
+ if "--position-file" in args:
+ idx = args.index("--position-file")
+ position_file = args[idx + 1]
+ del args[idx:idx + 2]
+
frozen_arrays = set()
for flag in ("--freeze", "--freeze="):
for a in list(args):
@@ -1182,6 +1318,8 @@ if __name__ == "__main__":
break
kwargs = {}
+ if position_file is not None:
+ kwargs["position_file"] = position_file
if frozen_arrays:
kwargs["frozen_arrays"] = frozen_arrays
if game_stride != 1: