summaryrefslogtreecommitdiff
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
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]>
-rwxr-xr-xsrc/chess.h30
-rw-r--r--src/command.c5
-rw-r--r--src/data.c49
-rw-r--r--src/eval.c880
-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
10 files changed, 937 insertions, 425 deletions
diff --git a/src/chess.h b/src/chess.h
index df59626..4d7015e 100755
--- a/src/chess.h
+++ b/src/chess.h
@@ -1363,7 +1363,12 @@ PAWN_HASH_ENTRY;
typedef struct _KING_SAFETY_HASH_ENTRY
{
UINT64 u64Key;
- ULONG uFileStormDefects[2];
+ // 2026-09-15: SCORE (direct centipawns), not a counter -- see
+ // eval.c's _ComputeFileStormDefects. KING_DEFECT_DUMP's debug
+ // sibling _ComputeFileStormDefectsSplit is untouched and still
+ // reports old counter units into KING_DEFECT_DUMP_INFO, unrelated
+ // to this cache.
+ SCORE iFileStormDefects[2];
}
KING_SAFETY_HASH_ENTRY;
@@ -1427,6 +1432,15 @@ typedef struct _KING_DEFECT_DUMP_INFO
ULONG uMinorsAtHome;
FLAG fMissingCastleShort;
FLAG fMissingCastleLong;
+ // 2026-09-15: the REAL, current _EvalKing output (post-rewrite linear
+ // ring-scan sum, AFTER the REDUCED_MATERIAL_DOWN_SCALER multiply --
+ // i.e. exactly what gets added into this side's total eval) -- unlike
+ // every other field in this struct, which all come from the
+ // deliberately-frozen OLD counter mechanism (see uSafetyTableValue's
+ // comment). Added for a magnitude sanity check against other engines'
+ // king-safety terms (Crafty/Glaurung), not for regression mining --
+ // it postdates the CSVs the mining pipeline already produced.
+ SCORE iRealSafety;
}
KING_DEFECT_DUMP_INFO;
#endif
@@ -2714,6 +2728,20 @@ extern const int g_BishopRayDeltas[4];
extern const FLAG g_BishopRayPositiveDir[4];
extern BITBOARD g_KnightAttacksBB[128];
extern BITBOARD g_KingAttacksBB[128];
+
+// 2026-09-15: king-ring bitboard split for _EvalKing's linear-SCORE
+// rewrite -- front/back mirror EmptyAttackedSquare's old color-dependent
+// asymmetry and eval_tune/train_king_safety_model.py's BUCKET_SLOTS
+// (WHITE's front = the -17/-16/-15 delta triplet, BLACK's = +15/+16/+17);
+// side is the two same-rank neighbors (-1/+1), color-independent.
+// g_KingRingFrontBB[uColor][c] | g_KingRingBackBB[uColor][c] |
+// g_KingRingSideBB[c] == g_KingAttacksBB[c] always. Precomputed once at
+// startup (InitializeKingAttackTables, data.c) so the hot ring-scan can
+// intersect these with attack bitboards and CountBits() instead of
+// branching per square.
+extern BITBOARD g_KingRingFrontBB[2][128];
+extern BITBOARD g_KingRingBackBB[2][128];
+extern BITBOARD g_KingRingSideBB[128];
extern BITBOARD g_PawnAttackOriginBB[2][128];
extern BITBOARD g_RookOccupancyMask[128];
extern BITBOARD g_BishopOccupancyMask[128];
diff --git a/src/command.c b/src/command.c
index 1696690..0662333 100644
--- a/src/command.c
+++ b/src/command.c
@@ -636,13 +636,14 @@ Return value:
Trace(" filestorm=%u filestormopenfile=%u filestormpawn=%u "
"queenproximity=%u initloc=%u countertotal=%u "
"safetyvalue=%u attackerarmyscaler=%u castled=%u minorsathome=%u "
- "misscastleshort=%u misscastlelong=%u\n",
+ "misscastleshort=%u misscastlelong=%u realsafety=%d\n",
p->uFileStormDefects, p->uFileStormOpenFile, p->uFileStormStormingPawn,
p->uQueenNearKing,
p->uInitialCounterByLocation, p->uCounterTotal,
p->uSafetyTableValue, p->uAttackerArmyScaler,
p->fCastled, p->uMinorsAtHome,
- p->fMissingCastleShort, p->fMissingCastleLong);
+ p->fMissingCastleShort, p->fMissingCastleLong,
+ p->iRealSafety);
}
}
#endif
diff --git a/src/data.c b/src/data.c
index 7c84b1a..71a1531 100644
--- a/src/data.c
+++ b/src/data.c
@@ -750,13 +750,21 @@ Return value:
//
BITBOARD g_KingAttacksBB[128];
+// 2026-09-15: see chess.h's comment on these three. Deltas below match
+// eval.c's old KingSafetyDeltas exactly (front = -17/-16/-15 for WHITE,
+// +15/+16/+17 for BLACK; side = -1/+1, color-independent).
+BITBOARD g_KingRingFrontBB[2][128];
+BITBOARD g_KingRingBackBB[2][128];
+BITBOARD g_KingRingSideBB[128];
+
void
InitializeKingAttackTables(void)
/**
Routine description:
- One-time startup init for g_KingAttacksBB -- see its comment.
+ One-time startup init for g_KingAttacksBB and the front/back/side
+ ring-bitboard split -- see their comments.
Parameters:
@@ -770,8 +778,23 @@ Return value:
{
ULONG uRank, uFile, uDir;
COOR c, cSquare;
+ static const int iFrontDeltas[2][3] =
+ {
+ { +15, +16, +17 }, // BLACK
+ { -17, -16, -15 }, // WHITE
+ };
+ static const int iBackDeltas[2][3] =
+ {
+ { -17, -16, -15 }, // BLACK
+ { +15, +16, +17 }, // WHITE
+ };
+ static const int iSideDeltas[2] = { -1, +1 };
+ ULONG uColor, uFrontBackSlot, uSideSlot;
memset(g_KingAttacksBB, 0, sizeof(g_KingAttacksBB));
+ memset(g_KingRingFrontBB, 0, sizeof(g_KingRingFrontBB));
+ memset(g_KingRingBackBB, 0, sizeof(g_KingRingBackBB));
+ memset(g_KingRingSideBB, 0, sizeof(g_KingRingSideBB));
for (uRank = 0; uRank < 8; uRank++)
{
for (uFile = 0; uFile < 8; uFile++)
@@ -785,6 +808,30 @@ Return value:
g_KingAttacksBB[c] |= COOR_TO_BB(cSquare);
}
}
+ for (uColor = 0; uColor < 2; uColor++)
+ {
+ for (uFrontBackSlot = 0; uFrontBackSlot < 3; uFrontBackSlot++)
+ {
+ cSquare = c + iFrontDeltas[uColor][uFrontBackSlot];
+ if (IS_ON_BOARD(cSquare))
+ {
+ g_KingRingFrontBB[uColor][c] |= COOR_TO_BB(cSquare);
+ }
+ cSquare = c + iBackDeltas[uColor][uFrontBackSlot];
+ if (IS_ON_BOARD(cSquare))
+ {
+ g_KingRingBackBB[uColor][c] |= COOR_TO_BB(cSquare);
+ }
+ }
+ }
+ for (uSideSlot = 0; uSideSlot < 2; uSideSlot++)
+ {
+ cSquare = c + iSideDeltas[uSideSlot];
+ if (IS_ON_BOARD(cSquare))
+ {
+ g_KingRingSideBB[c] |= COOR_TO_BB(cSquare);
+ }
+ }
}
}
}
diff --git a/src/eval.c b/src/eval.c
index 71242c4..ab4d07d 100644
--- a/src/eval.c
+++ b/src/eval.c
@@ -627,6 +627,18 @@ static SCORE QUEEN_KING_TROPISM[8] =
// King eval terms
// ---------------------------------------------------------------------------
//
+// 2026-09-15: this table is ONLY for CountKingSafetyDefects (the cheap
+// lazy-eval-proxy/search-extension-reduction-gate sibling of _EvalKing,
+// called directly from search.c/searchsup.c independent of Eval() ever
+// running) -- deliberately frozen/split off from _EvalKing's own real
+// computation (see KING_EVAL_INITIAL_LOCATION below), which used to
+// read this exact array. That coupling meant any future retune of
+// _EvalKing's king-location term would silently perturb search
+// extension/reduction gate frequency too, an unrelated and much
+// higher-blast-radius side effect than "retune an eval term" should
+// have. DO NOT repoint _EvalKing at this table again -- add to/reshape
+// KING_EVAL_INITIAL_LOCATION instead. See king_safety_data/HANDOFF.md's
+// 2026-09-15 addendum.
static ULONG KING_INITIAL_COUNTER_BY_LOCATION[2][128] =
{
{
@@ -653,6 +665,41 @@ static ULONG KING_INITIAL_COUNTER_BY_LOCATION[2][128] =
}
};
+// 2026-09-15: _EvalKing's own copy -- same starting values as
+// KING_INITIAL_COUNTER_BY_LOCATION above (no retuning done yet, this
+// split is purely about ownership/decoupling), but SCORE-typed (this
+// function's real ring-scan is SCORE-native post-rewrite, no more
+// shared uCounter) and free to be reshaped independently -- e.g. the
+// offboard-ring-exposure idea from king_safety_data/HANDOFF.md's
+// session notes (a1-style corner exposure, distinct from this table's
+// current pure-rank shape) without touching CountKingSafetyDefects'
+// search-gate behavior at all.
+static SCORE KING_EVAL_INITIAL_LOCATION[2][128] =
+{
+ {
+ 1, 0, 0, 0, 1, 0, 0, 1, 0,0,0,0,0,0,0,0,
+ 1, 1, 1, 1, 1, 1, 1, 1, 0,0,0,0,0,0,0,0,
+ 3, 3, 3, 3, 3, 3, 3, 3, 0,0,0,0,0,0,0,0,
+ 4, 4, 4, 4, 4, 4, 4, 4, 0,0,0,0,0,0,0,0,
+ // -------------------------------------------------------------------
+ 4, 4, 4, 4, 4, 4, 4, 4, 0,0,0,0,0,0,0,0,
+ 4, 4, 4, 4, 4, 4, 4, 4, 0,0,0,0,0,0,0,0,
+ 4, 4, 4, 4, 4, 4, 4, 4, 0,0,0,0,0,0,0,0,
+ 4, 4, 4, 4, 4, 4, 4, 4, 0,0,0,0,0,0,0,0
+ },
+ {
+ 4, 4, 4, 4, 4, 4, 4, 4, 0,0,0,0,0,0,0,0,
+ 4, 4, 4, 4, 4, 4, 4, 4, 0,0,0,0,0,0,0,0,
+ 4, 4, 4, 4, 4, 4, 4, 4, 0,0,0,0,0,0,0,0,
+ 4, 4, 4, 4, 4, 4, 4, 4, 0,0,0,0,0,0,0,0,
+ // -------------------------------------------------------------------
+ 4, 4, 4, 4, 4, 4, 4, 4, 0,0,0,0,0,0,0,0,
+ 3, 3, 3, 3, 3, 3, 3, 3, 0,0,0,0,0,0,0,0,
+ 1, 1, 1, 1, 1, 1, 1, 1, 0,0,0,0,0,0,0,0,
+ 1, 0, 0, 0, 1, 0, 0, 1, 0,0,0,0,0,0,0,0
+ }
+};
+
static SCORE KING_TO_CENTER[128] =
{
+0, +0, +0, +0, +0, +0, +0, +0, 0,0,0,0,0,0,0,0,
@@ -758,11 +805,97 @@ static SCORE KING_MISSING_ONE_CASTLE_OPTION = -23;
// danger signal already captured by EmptyUnattackedSquare's front
// weight. Calibrated the same way as KING_QUEEN_PROXIMITY_DANGER above
// (misscastleshort-anchored k =~ -32.4, applied to back_flight's raw
-// coefficient) =~ +1.1/square, rounded to +1. Small and deliberately
-// so -- this is the single most novel, least-collinear finding from
-// the regression, worth landing and validating in isolation before
-// combining with anything else.
-static SCORE KING_BACK_FLIGHT_BONUS = 1;
+// coefficient) =~ +1.1/square, originally rounded to +1 -- but that
+// +1 was never independently self-play tested in isolation (the
+// "worth landing and validating" below didn't happen before other
+// king-safety changes piled on top of it), so it never carried the
+// weight of a validated constant, just a first guess like everything
+// else in this file.
+//
+// 2026-09-15: nudged +1 -> +3. Every regression refit run this session
+// (the original mate-mining regression, and 6,890-row and 92,468-row
+// ElasticNet/Ridge fits on curated mate/control data) puts
+// back_flight's magnitude at roughly half of front_flight's
+// (KING_RING_FRONT_FLIGHT_PENALTY, -6); the +1 baseline's ratio (1/6)
+// was much smaller than that. Small and deliberately so -- this is
+// the single most novel, least-collinear finding from the regression,
+// worth landing and validating in isolation before combining with
+// anything else.
+static SCORE KING_BACK_FLIGHT_BONUS = 3;
+
+// 2026-09-15: _EvalKing's linear-SCORE ring-scan terms, promoted here
+// from local statics inside that function so they're DNA-registered
+// (see g_EvalDNA below) and Texel-tunable -- see that promotion's
+// comment in _EvalKing for why (hand-translating mate-mining
+// regression coefficients into centipawns via a single assumed anchor
+// constant was the likely source of this rewrite's self-play
+// regression, not the underlying feature signal). Starting values are
+// unchanged from the original hand-translated guesses; only their
+// storage location/tunability changed here.
+//
+// 2026-09-15 (later same day): nudged, NOT re-derived from scratch --
+// absolute-magnitude derivation (regression coefficient -> centipawns
+// via a single anchor constant, then Texel coordinate descent on top of
+// curated data) was tried and abandoned; every method produced huge,
+// untrustworthy magnitude swings (see eval_tune/ king-safety session
+// notes). What *did* replicate cleanly across the original mate-mining
+// regression, a small-scale (6,890-row) and full-scale (92,468-row)
+// ElasticNet/Ridge refit was the *relative ranking* between terms, so
+// only reordering/small nudges within the existing scale are applied:
+// - MINOR: data unanimously ranks side ~ front > back in danger;
+// baseline had back rated *most* dangerous (backwards) -- permuted
+// the same three magnitudes rather than resizing them.
+// - ROOK: baseline's front > back > side already matched the data's
+// ranking untouched.
+// - PAWN: data ranks pawn-ring-attacks mid-tier (stronger than
+// back-tier piece attacks), baseline had it tied for smallest --
+// small bump.
+// - UNDEFENDED: baseline already had back as the largest magnitude,
+// matching the one sub-signal (back_undef) that was stable across
+// every fit -- left alone. front/side undefended-square counts were
+// the single most consistently weak/sign-unstable feature across
+// every regression run tried (zeroed by ElasticNet, near-zero and
+// sign-flipping under Ridge) -- softened slightly rather than left
+// implying confidence the data doesn't support.
+// - XRAY: consistently signed, non-trivial magnitude in every fit;
+// baseline had it smallest in the whole file -- small bump.
+// - FRONT_FLIGHT_PENALTY: already correctly signed/positioned once
+// PAWN moved above it -- untouched.
+// - BACK_FLIGHT_BONUS (see above): data puts this at roughly half of
+// FRONT_FLIGHT_PENALTY's magnitude; baseline's ratio was much
+// smaller than that -- small bump.
+// KING_QUEEN_PROXIMITY_DANGER and KING_MISSING_ONE_CASTLE_OPTION
+// deliberately NOT touched: the former is already self-play validated
+// and not cleanly re-derivable at this feature granularity (a 7-bucket
+// nonlinear table vs. one linear coefficient); the latter applies one
+// shared constant to both short- and long-castle loss even though the
+// regression finds those two nowhere near equally dangerous -- fixing
+// that is a structural change (splitting the constant), not a nudge,
+// and wasn't made without discussing it first.
+//
+// 2026-09-15 (later still): PAWN and XRAY nudged again after a THIRD,
+// independent calibration method (logistic regression against material
+// balance -- computed straight from the FEN via python-chess, not
+// anything derived from these same ring features, so it doesn't have
+// iSafety's circularity problem) confirmed the same finding as the
+// first pass: both terms are still ranked mid/low here while every
+// method tried puts them near the top tier, comparable to front-tier
+// piece attacks. Rank order elsewhere (9 of 13 comparable terms)
+// already matched this newest fit without further changes, including
+// UNDEFENDED[back] and both flight terms -- only these two still
+// disagreed, so only these two moved again.
+static SCORE KING_RING_ATTACK_MINOR[3] = { -15, -14, -17 };
+static SCORE KING_RING_ATTACK_ROOK[3] = { -14, -11, -8 };
+static SCORE KING_RING_ATTACK_PAWN = -12;
+static SCORE KING_RING_UNDEFENDED[3] = { -4, -12, -5 };
+static SCORE KING_RING_XRAY_BONUS = -8;
+static SCORE KING_RING_FRONT_FLIGHT_PENALTY = -6;
+static SCORE KING_INITIAL_LOCATION_SCALE = -3;
+static SCORE KING_ATTACK_PATTERN_SCALE = -10;
+static SCORE KING_DANGER_ACCELERATOR[7] =
+{// 0-55 56-111 112-167 168-223 224-279 280-335 336+
+ 128, 175, 225, 200, 145, 128, 128
+};
// Flat "this is bad" flags for _EvalTrappedPieces -- not an attempt to
// price the material outcome (search resolves that), just a nudge away
@@ -830,11 +963,21 @@ static DNA_BASE_SIZE g_EvalDNA[] = {
DNA_ARRAY(QUEEN_MOBILITY_BY_SQUARES),
DNA_ARRAY(QUEEN_KING_TROPISM),
DNA_MATRIX(KING_INITIAL_COUNTER_BY_LOCATION),
+ DNA_MATRIX(KING_EVAL_INITIAL_LOCATION),
DNA_ARRAY(KING_TO_CENTER),
DNA_ARRAY(KING_SAFETY_BY_COUNTER),
DNA_ARRAY(KING_QUEEN_PROXIMITY_DANGER),
DNA_VAR(KING_MISSING_ONE_CASTLE_OPTION),
- DNA_VAR(KING_BACK_FLIGHT_BONUS)
+ DNA_VAR(KING_BACK_FLIGHT_BONUS),
+ DNA_ARRAY(KING_RING_ATTACK_MINOR),
+ DNA_ARRAY(KING_RING_ATTACK_ROOK),
+ DNA_VAR(KING_RING_ATTACK_PAWN),
+ DNA_ARRAY(KING_RING_UNDEFENDED),
+ DNA_VAR(KING_RING_XRAY_BONUS),
+ DNA_VAR(KING_RING_FRONT_FLIGHT_PENALTY),
+ DNA_VAR(KING_INITIAL_LOCATION_SCALE),
+ DNA_VAR(KING_ATTACK_PATTERN_SCALE),
+ DNA_ARRAY(KING_DANGER_ACCELERATOR)
};
ULONG
@@ -2420,7 +2563,7 @@ Return value:
}
-static ULONG
+static SCORE
_ComputeFileStormDefects(IN POSITION *pos,
IN const PAWN_HASH_ENTRY *pHash,
IN ULONG uColor)
@@ -2436,6 +2579,27 @@ Routine description:
prototype) so it can be called once per side from
_GetFileStormDefects's cache-miss path instead of inline.
+ 2026-09-15: converted from an unsigned "counter" (fed through the
+ old shared uCounter/KING_SAFETY_BY_COUNTER lookup) to a direct
+ signed SCORE (centipawns), part of _EvalKing's move away from that
+ shared nonlinear counter entirely -- see king_safety_data/
+ HANDOFF.md's 2026-09-15 addendum. Table SHAPE below is the
+ ORIGINAL, pre-2026-09-14 relative weighting (0,4,3,1,.../+2,+1,+0),
+ not the already-1.75-2x-bumped one -- that's deliberate: the mate-
+ mining regression's filestormopenfile/filestormpawn coefficients
+ were fit against CSV data extracted using this original shape, so
+ scaling *this* shape by the regression-derived conversion factor is
+ the correct one-step translation (scaling the already-bumped shape
+ again would double-apply the 2026-09-14 tuning). Magnitude derived
+ the same way as KING_QUEEN_PROXIMITY_DANGER's self-play-validated
+ -14/count: k = (validated queenproximity weight) / (queenproximity's
+ own cleaned raw regression coefficient) =~ -14 per raw coefficient
+ unit, applied to filestormpawn's raw coefficient (+0.374) and
+ filestormopenfile's (+0.360) against the original table's own
+ integer steps. Unvalidated by self-play as of this writing --
+ expect this exact magnitude to move once tested, same as every
+ other freshly-converted term here.
+
Parameters:
POSITION *pos
@@ -2444,27 +2608,24 @@ Parameters:
Return value:
- ULONG
+ SCORE
**/
{
- // 2026-09-14: scaled up ~1.75-2x relative to the other _EvalKing
- // defect tables (king_safety_data/HANDOFF.md, tier 1 -- these two
- // came back as the dominant, most stable predictors of a real mate
- // by a clear margin over every other defect, but previously shared
- // the same +0..+4 magnitude as everything else). Keep this table in
- // sync with _ComputeFileStormDefectsSplit's copy below.
- static ULONG KingStormingPawnDefects[8] =
+ static SCORE KingStormingPawnDefects[8] =
{
- +0, +7, +5, +2, +0, +0, +0, +0
+ +0, -21, -16, -5, 0, 0, 0, 0
};
- static ULONG KingFileDefects[3] =
+ static SCORE KingFileDefects[3] =
{
- +4, +2, +0
+ -10, -5, 0
};
+ // Old code's edge-file (a/h) kicker was a flat +1 counter-unit;
+ // scaled by the same filestormopenfile-derived factor.
+ static SCORE KING_FILE_EDGE_KICKER = -5;
ULONG ufColor = FLIP(uColor);
COOR c = pos->cNonPawns[uColor][0];
- ULONG uCounter = 0;
+ SCORE iScore = 0;
COOR cFileSq, cSquare;
ULONG u, v;
BITBOARD bb;
@@ -2481,7 +2642,11 @@ Return value:
v = (pHash->uCountPerFile[WHITE][u] > 0) +
(pHash->uCountPerFile[BLACK][u] > 0);
ASSERT((v >= 0) && (v <= 2));
- uCounter += (KingFileDefects[v] + ((v < 2) && ((u == 1) || (u == 8))));
+ iScore += KingFileDefects[v];
+ if ((v < 2) && ((u == 1) || (u == 8)))
+ {
+ iScore += KING_FILE_EDGE_KICKER;
+ }
bb = pos->bbPawns[ufColor] & BBFILE[u - 1];
if (bb)
{
@@ -2494,10 +2659,10 @@ Return value:
cSquare = CoorFromBitBoardRank8ToRank1(&bb);
}
ASSERT(IS_ON_BOARD(cSquare));
- uCounter += KingStormingPawnDefects[DISTANCE(cSquare, c)];
+ iScore += KingStormingPawnDefects[DISTANCE(cSquare, c)];
}
}
- return uCounter;
+ return iScore;
}
@@ -2519,13 +2684,22 @@ Routine description:
hand-picked tables (KingFileDefects / KingStormingPawnDefects) with
no structural change needed to retune them individually once this
tells us their relative importance. See CLAUDE.md's king-safety
- discussion. Logic intentionally identical to _ComputeFileStormDefects,
- just split into two accumulators -- keep both in sync if either
- changes.
+ discussion.
+
+ 2026-09-15: DELIBERATELY DIVERGED from _ComputeFileStormDefects as
+ of the linear-SCORE rewrite (king_safety_data/HANDOFF.md's
+ 2026-09-15 addendum) -- the real path now returns signed centipawns
+ off a differently-shaped table, but this dump sibling must keep
+ reporting the OLD unsigned counter-style values so
+ king_features_v4.csv-shaped mining data stays interpretable/
+ comparable across sessions (extract_king_features.py, this project's
+ whole regression pipeline, assumes these units). Traversal shape
+ (own file + two adjacent, same v/distance logic) still matches --
+ only the two tables' actual values are now intentionally frozen at
+ their pre-rewrite originals, not kept in sync.
**/
{
- // Keep in sync with _ComputeFileStormDefects's copy above.
static ULONG KingStormingPawnDefects[8] =
{
+0, +7, +5, +2, +0, +0, +0, +0
@@ -2574,7 +2748,7 @@ Routine description:
#endif
-static ULONG
+static SCORE
_GetFileStormDefects(IN SEARCHER_THREAD_CONTEXT *ctx,
IN POSITION *pos,
IN const PAWN_HASH_ENTRY *pHash,
@@ -2600,6 +2774,19 @@ Routine description:
a guaranteed hit -- only one real miss per position, not one per
king.
+ 2026-09-15: return type/cache field changed ULONG -> SCORE along
+ with _ComputeFileStormDefects -- see that function's comment. This
+ function's own cache/probe/hit-rate mechanics are unaffected, only
+ the value being cached changed shape. NOTE: CALIBRATE_POSITIONAL's
+ two call sites (_RecordPositionalCalibration and the DumpPositional
+ Calibration-ish block near EstimatePositionalScore) still treat this
+ return value as a small unsigned index component
+ (MINU(10, CountKingSafetyDefects(...) + _GetFileStormDefects(...)))
+ -- now wrong now that this returns signed centipawns, but left
+ alone by direct instruction (that non-default build flag's own
+ recalibration is deferred as separate follow-up work, not part of
+ this rewrite).
+
Parameters:
SEARCHER_THREAD_CONTEXT *ctx
@@ -2609,7 +2796,7 @@ Parameters:
Return value:
- ULONG
+ SCORE
**/
{
@@ -2630,35 +2817,49 @@ Return value:
ctx->sCounters.kingsafetyhash.u64CyclesHit +=
(SystemReadTimeStampCounter() - u64Start);
#endif
- return pEntry->uFileStormDefects[uColor];
+ return pEntry->iFileStormDefects[uColor];
}
pEntry->u64Key = u64Key;
- pEntry->uFileStormDefects[WHITE] = _ComputeFileStormDefects(pos, pHash, WHITE);
- pEntry->uFileStormDefects[BLACK] = _ComputeFileStormDefects(pos, pHash, BLACK);
+ pEntry->iFileStormDefects[WHITE] = _ComputeFileStormDefects(pos, pHash, WHITE);
+ pEntry->iFileStormDefects[BLACK] = _ComputeFileStormDefects(pos, pHash, BLACK);
#ifdef PERF_COUNTERS
ctx->sCounters.kingsafetyhash.u64CyclesMiss +=
(SystemReadTimeStampCounter() - u64Start);
#endif
- return pEntry->uFileStormDefects[uColor];
+ return pEntry->iFileStormDefects[uColor];
}
#ifdef CALIBRATE_POSITIONAL
//
// board_representation/EVAL.md section 9 recalibration (2026-09-05):
-// histogram of (cheap defect index -> real KING_SAFETY_BY_COUNTER value),
-// one row per possible EstimatePositionalScore bucket (the same
-// MINU(10, CountKingSafetyDefects + _GetFileStormDefects) index it looks
-// up), so a p90-per-row read after a calibration run over a large sample
-// (curated suites or a slice of ecm.ep_, at real search depth so the
-// distribution matches what the search tree actually sees, not just root
-// positions) gives a fresh iKingSwingP90 table -- coarse cp bins, not raw
-// samples, to keep this cheap enough to leave compiled into a whole search
-// run rather than needing to buffer millions of individual values.
+// histogram of (cheap defect index -> real king-safety magnitude), one
+// row per possible EstimatePositionalScore bucket, so a p90-per-row
+// read after a calibration run over a large sample (curated suites or
+// a slice of ecm.ep_, at real search depth so the distribution matches
+// what the search tree actually sees, not just root positions) gives a
+// fresh iKingSwingP90 table -- coarse cp bins, not raw samples, to keep
+// this cheap enough to leave compiled into a whole search run rather
+// than needing to buffer millions of individual values.
+//
+// 2026-09-15: bucket index fixed to MINU(10, CountKingSafetyDefects)
+// ONLY, dropping _GetFileStormDefects -- it had drifted out of sync
+// with EstimatePositionalScore's own real lookup index back on
+// 2026-09-06 (_GetFileStormDefects was pulled out of that hot path for
+// being too expensive to call on ~90% of Eval() calls regardless of
+// exit, but this histogram's bucketing was never updated to match,
+// silently refitting against an index EstimatePositionalScore was no
+// longer using). Also: this histogram used to bin the pre-scale
+// KING_SAFETY_BY_COUNTER magnitude, which was always <= 0 by
+// construction; the 2026-09-15 linear-SCORE rewrite's iSafety can be
+// mildly positive (a well-covered king with real back-rank escape
+// squares and no attacks), so this now bins ABS(iSafety) -- by direct
+// instruction, the rare positive case is small enough not to need its
+// own separate treatment.
//
#define CALIBRATE_NUM_BUCKETS 11
#define CALIBRATE_BIN_WIDTH 5
-#define CALIBRATE_NUM_BINS 200 // covers raw KING_SAFETY_BY_COUNTER 0..999
+#define CALIBRATE_NUM_BINS 200 // covers |raw king-safety magnitude| 0..999
static UINT64 g_CalibrateHist[CALIBRATE_NUM_BUCKETS][CALIBRATE_NUM_BINS];
UINT64 g_uCalibrateGateChecked = 0;
UINT64 g_uCalibrateGateTrue = 0;
@@ -2669,13 +2870,13 @@ _RecordPositionalCalibration(IN SEARCHER_THREAD_CONTEXT *ctx,
IN POSITION *pos,
IN const PAWN_HASH_ENTRY *pHash,
IN ULONG uColor,
- IN ULONG uRawCounterValue)
+ IN ULONG uRawMagnitude)
/**
Routine description:
Bucket one real _EvalKing sample (this side's raw, pre-material-scaled
- KING_SAFETY_BY_COUNTER lookup) by the same cheap defect index
+ king-safety magnitude) by the same cheap defect index
EstimatePositionalScore would have used to estimate it, so a later
DumpPositionalCalibration can compute a fresh p90-per-bucket table.
@@ -2683,9 +2884,11 @@ Parameters:
SEARCHER_THREAD_CONTEXT *ctx
POSITION *pos
- PAWN_HASH_ENTRY *pHash
+ PAWN_HASH_ENTRY *pHash : unused as of 2026-09-15 (see the bucket-
+ index fix in this file's #ifdef CALIBRATE_POSITIONAL comment
+ above) -- kept in the signature for call-site stability.
ULONG uColor
- ULONG uRawCounterValue : KING_SAFETY_BY_COUNTER[uCounter], before the
+ ULONG uRawMagnitude : ABS(iSafety), before the
REDUCED_MATERIAL_DOWN_SCALER scaling _EvalKing applies next --
EstimatePositionalScore's table is looked up then scaled the same
way, so the table itself should be fit against the unscaled value.
@@ -2707,10 +2910,9 @@ Return value:
return;
}
- uIndex = MINU(10, CountKingSafetyDefects(pos, uColor) +
- _GetFileStormDefects(ctx, pos, pHash, uColor));
+ uIndex = MINU(10, CountKingSafetyDefects(pos, uColor));
uBin = MINU(CALIBRATE_NUM_BINS - 1,
- uRawCounterValue / CALIBRATE_BIN_WIDTH);
+ uRawMagnitude / CALIBRATE_BIN_WIDTH);
g_CalibrateHist[uIndex][uBin]++;
}
@@ -3190,30 +3392,39 @@ Return value:
// CountKingSafetyDefects(side)).
//
// STALE as of 2026-09-06 (board_representation/EVAL.md section 9):
- // fit while this index also included _GetFileStormDefects(side)
- // (~1.28B samples, see command.c's "calibrate dump" and git history
- // for the fit-time numbers). _GetFileStormDefects was pulled out of
- // this hot path the same day -- it cost ~181 of ~1619 avg eval
- // cycles (measured via EVAL_TIME), more than the "cheap cached
- // lookup" it was assumed to be, running on ~90% of all Eval() calls
- // regardless of whether an exit ever resulted. Recalibration against
- // the now-narrower (CountKingSafetyDefects-only) index deliberately
- // deferred; until then this table is a reasonable but unverified
- // approximation. Worst-case error from dropping the storm term: the
- // index it fed into is clamped to 10 either way, so the max possible
- // per-side swing this can introduce is iKingSwingP90[10] -
- // iKingSwingP90[0] = 300, scaled by REDUCED_MATERIAL_DOWN_SCALER/8
- // (0-1.0) -- only reachable when CountKingSafetyDefects alone is 0
- // but a pawn storm alone would have maxed the old combined index.
- // _EvalKing's real, exact score is unaffected either way -- this
- // only widens/narrows the lazy-eval margin estimate.
+ // Refit 2026-09-15 against _EvalKing's linear-SCORE rewrite (king_
+ // safety_data/HANDOFF.md's 2026-09-15 addendum), replacing the
+ // pre-rewrite table below (which was itself already flagged stale --
+ // fit against a since-changed bucket index, see git history).
+ // _RecordPositionalCalibration's own bucket index was ALSO fixed at
+ // the same time (dropped _GetFileStormDefects, which had silently
+ // drifted out of sync with this function's own CountKingSafetyDefects-
+ // only lookup back on 2026-09-06) -- this refit is against the
+ // corrected, now-matching index, not a second layer of the same
+ // mismatch. CountKingSafetyDefects itself is unchanged (its backing
+ // table, KING_INITIAL_COUNTER_BY_LOCATION, was deliberately split
+ // off from _EvalKing's own copy -- see KING_EVAL_INITIAL_LOCATION --
+ // specifically so this recalibration never needs to touch it again).
+ //
+ // Sample: 5305 positions (5005 real TWIC games >=2400 Elo, quiet
+ // plies, plus 300 ECM positions mixed in for tactical-position
+ // coverage -- TWIC chosen as the primary source since it's
+ // representative of ordinary cross-sectional positions, unlike
+ // ECM's attack-the-king selection bias), sd4 each, 31.8M real
+ // Eval() calls / 17.1M samples through the super-lazy gate. Bucket
+ // 10 had zero samples in this run (the new terms apparently rarely
+ // if ever co-occur with CountKingSafetyDefects>=10) -- extrapolated
+ // from the bucket 8->9 trend (+25/step) rather than left stale;
+ // re-run with a larger/more adversarial sample if this bucket ever
+ // matters in practice.
//
// search.c's ">2" and searchsup.c's ">1" fixed-threshold extension
// gates still read CountKingSafetyDefects' raw, unscaled return
- // value directly and remain equally stale -- not yet recalibrated.
+ // value directly -- untouched by this refit (that function's own
+ // behavior didn't change), so no separate recalibration needed there.
//
static const SCORE iKingSwingP90[11] = {
- 10, 20, 35, 60, 90, 120, 135, 165, 195, 225, 310
+ 30, 65, 90, 120, 155, 190, 220, 255, 265, 290, 315
};
// p90 of |mobility + passers + everything else combined|, measured
// directly (no useful correlation found with piece count).
@@ -4239,100 +4450,66 @@ Return value:
PIECE p;
ULONG uColor, xColor;
COOR cSquare;
- ULONG u, v;
- ULONG uCounter;
- BITV bvAttack;
- BITV bvXray;
- BITV bvDefend;
- BITV bvPattern = 0;
- ULONG uFlightSquares;
- ULONG uBackFlightSquares;
- ULONG uQueenNearKing = 0;
+ ULONG u;
+ BITV bvPattern;
+ ULONG uQueenNearKing;
BITBOARD bb;
SCORE i;
SCORE iKingScore = 0;
- static ULONG KingFlightDefects[9] =
- {
- +4, +2, +1, 0, 0, 0, 0, 0, 0
- };
-
- // Trimmed from 11 entries to the real 8 king-move squares
- // (2026-09-05, by direct instruction): the dropped -2/+2 entries
- // (two squares away on the same rank) were never a king move --
- // confirmed intentional signal, not a bug, but judged not worth
- // keeping once decoupled from the actual bug (KingSafetyDeltas'
- // wrong square set leaking into pos->bbKingAttacks' population,
- // fixed separately) on two grounds: CountKingSafetyDefects' own
- // rewrite doesn't count them either now, and they're "just kinda
- // weird" -- not clearly earning their keep as their own thing.
- static INT KingSafetyDeltas[9] =
- {
- -17, -16, -15,
- -1, +1,
- +15, +16, +17, 0
- };
-
- // 2026-09-14: color-dependent front/back ring-slot classification,
- // mirroring EmptyAttackedSquare/EmptyUnattackedSquare's own existing
- // asymmetry (WHITE's front is slots 0/1/2, BLACK's is 5/6/7) and
- // matching eval_tune/train_king_safety_model.py's BUCKET_SLOTS --
- // used to isolate the back-bucket flight-square bonus below. Slots
- // 3/4 (the same-rank side squares) are neither front nor back, same
- // as the regression's own treatment.
- static const FLAG KING_RING_IS_BACK[2][8] =
- {
- // BLACK: back = slots 0,1,2 (front = 5,6,7)
- { TRUE, TRUE, TRUE, FALSE, FALSE, FALSE, FALSE, FALSE },
- // WHITE: back = slots 5,6,7 (front = 0,1,2)
- { FALSE, FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, TRUE },
- };
-
- static ULONG EmptyAttackedSquare[2][8] =
- {
- {
- 1, 1, 1,
- 1, 1,
- 2, 2, 2
- },
- {
- 2, 2, 2,
- 1, 1,
- 1, 1, 1
- },
- };
-
- static ULONG EmptyUnattackedSquare[2][8] =
- {
- {
- 0, 0, 0,
- 0, 0,
- 1, 1, 1
- },
- {
- 1, 1, 1,
- 0, 0,
- 0, 0, 0
- },
- };
-
- static ULONG OccupiedAttackedSquare[2] =
- {
- 1,
- 2
- };
-
#ifdef KING_DEFECT_DUMP
KING_DEFECT_DUMP_INFO *pDump;
#endif
+ // 2026-09-15: linear-SCORE rewrite -- see king_safety_data/
+ // HANDOFF.md's 2026-09-15 addendum for the full rationale. Replaces
+ // the old shared uCounter -> KING_SAFETY_BY_COUNTER nonlinear-lookup
+ // mechanism (EmptyAttackedSquare/EmptyUnattackedSquare/
+ // OccupiedAttackedSquare/KingFlightDefects/KingSafetyDeltas/
+ // KING_RING_IS_BACK all retired) with per-defect SCORE weights that
+ // accumulate into one running total and get scaled by
+ // REDUCED_MATERIAL_DOWN_SCALER exactly ONCE at the end -- by direct
+ // instruction, since this is the hottest inner loop in eval.c and
+ // scaler*(A+B+C+...) is one multiply instead of scaler*A + scaler*B
+ // + ... Ring-square attack/defense/flight terms below are computed
+ // via bitboard intersection + CountBits() against the whole front/
+ // back/side bucket at once (g_KingRingFrontBB/g_KingRingBackBB/
+ // g_KingRingSideBB, data.c), not a per-square branchy loop -- a
+ // fixed handful of AND+popcount ops regardless of how many ring
+ // squares are actually occupied/attacked.
+ //
+ // Magnitudes are the mate-mining logistic regression's cleaned,
+ // collinearity-reduced coefficients (front_def/back_def/*_atk_queen
+ // dropped -- collinear with the atk_* terms and queenproximity
+ // respectively), unstandardized (divided by each raw feature's own
+ // std-dev) and converted to centipawns via k =~ -14, the same
+ // conversion constant KING_QUEEN_PROXIMITY_DANGER's self-play-
+ // validated -14/count already established (anchor: validated weight
+ // / that term's own cleaned raw coefficient). None of the NEW
+ // per-piece-type/undefended/xray/flight terms below have been
+ // through self-play yet as of this writing -- expect every one of
+ // these starting magnitudes to move once tested, same discipline as
+ // the file-storm and queenproximity retunes before them.
+ //
+ // 2026-09-15 (later): promoted from local statics to file scope
+ // and DNA-registered (see g_EvalDNA below) so they can be Texel-
+ // tuned directly against real game outcomes instead of hand-
+ // translated from regression coefficients -- see king_safety_data/
+ // HANDOFF.md's addendum on why the log-odds-to-centipawn
+ // translation step was the likely source of this rewrite's
+ // self-play regression, not the underlying feature signal itself.
+ const ULONG uKingDangerAcceleratorBucketWidth = 56;
+
ASSERT(IS_ON_BOARD(c));
p = pos->rgSquare[c].pPiece;
ASSERT(p && IS_KING(p));
uColor = GET_COLOR(p);
xColor = FLIP(uColor);
ASSERT(IS_VALID_COLOR(uColor));
- pos->bbKingAttacks[uColor] |= g_KingAttacksBB[c];
+
+ // Update attack bitvs for squares around the king.
+ BITBOARD bbRing = g_KingAttacksBB[c];
+ pos->bbKingAttacks[uColor] |= bbRing;
#ifdef KING_DEFECT_DUMP
pDump = &ctx->sKingDefectDump[uColor];
memset(pDump, 0, sizeof(KING_DEFECT_DUMP_INFO));
@@ -4347,271 +4524,250 @@ Return value:
goto skip_safety;
}
- // Prepare to do king safety
- uCounter = (u >= KEEP_KING_AT_HOME_THRESHOLD) *
- KING_INITIAL_COUNTER_BY_LOCATION[uColor][c];
-#ifdef KING_DEFECT_DUMP
- pDump->uInitialCounterByLocation = uCounter;
-#endif
-#ifdef EVAL_DUMP
- Trace("%s Initial KS Counter: %u\n", COLOR_NAME(uColor), uCounter);
-#endif
+ // Depending on whether we are assessing a black or white king,
+ // the "front" and "back" directions swap. Front is "towards
+ // the center of the board at game start time".
+ BITBOARD bbFront = g_KingRingFrontBB[uColor][c];
+ BITBOARD bbBack = g_KingRingBackBB[uColor][c];
+ BITBOARD bbSide = g_KingRingSideBB[c];
+ ASSERT(bbRing == (bbFront | bbBack | bbSide));
- BITBOARD bbEnemyOcc = _BuildFriendlySideBB(pos, xColor);
- uFlightSquares = 0;
- uBackFlightSquares = 0;
- u = 0;
- ASSERT(KingSafetyDeltas[u] != 0);
- do
- {
- cSquare = c + KingSafetyDeltas[u];
- if (IS_ON_BOARD(cSquare))
- {
- //
- // board_representation/EVAL.md section 9: bvAttacks/
- // ATTACK_BITV retired entirely -- every bit here now
- // comes straight from a bbXAttacks accumulator read,
- // no more c|8 shadow-index struct storage or
- // per-square writes. No enemy-king contribution in
- // bvAttack, by direct instruction (see this function's
- // header comment on the black-then-white evaluation-
- // order asymmetry this sidesteps). bvDefend's own-king
- // bit is real, load-bearing signal (see the bvDefend
- // &= ~8 below, which strips it back out when the
- // square is x-rayed or multiply attacked -- "a lone
- // king isn't adequate defense against that") -- not
- // just self-consistency noise, so it keeps its
- // own-color check.
- //
- BITBOARD sq = COOR_TO_BB(cSquare);
+ BITBOARD bbEnemyAtkAny = pos->bbPawnAttacks[xColor] |
+ pos->bbMinorAttacks[xColor] |
+ pos->bbRookAttacks[xColor] |
+ pos->bbQueenAttacks[xColor];
+ BITBOARD bbEnemyXrayAny = pos->bbMinorXrayAttacks[xColor] |
+ pos->bbRookXrayAttacks[xColor] |
+ pos->bbQueenXrayAttacks[xColor];
+ BITBOARD bbOwnDefendNoKing = pos->bbPawnAttacks[uColor] |
+ pos->bbMinorAttacks[uColor] |
+ pos->bbRookAttacks[uColor] |
+ pos->bbQueenAttacks[uColor];
+ BITBOARD bbEmpty = ~pos->bbOccupied;
+ BITBOARD bbUndefended = bbEnemyAtkAny & ~bbOwnDefendNoKing;
+ SCORE iSafety = 0;
- bvAttack = ((pos->bbPawnAttacks[xColor] & sq) ? PAWN_BIT : 0) |
- ((pos->bbMinorAttacks[xColor] & sq) ? MINOR_BIT : 0) |
- ((pos->bbRookAttacks[xColor] & sq) ? ROOK_BIT : 0) |
- ((pos->bbQueenAttacks[xColor] & sq) ? QUEEN_BIT : 0);
- bvXray = ((pos->bbMinorXrayAttacks[xColor] & sq) ? MINOR_BIT : 0) |
- ((pos->bbRookXrayAttacks[xColor] & sq) ? ROOK_BIT : 0) |
- ((pos->bbQueenXrayAttacks[xColor] & sq) ? QUEEN_BIT : 0);
- bvDefend = ((pos->bbPawnAttacks[uColor] & sq) ? PAWN_BIT : 0) |
- ((pos->bbMinorAttacks[uColor] & sq) ? MINOR_BIT : 0) |
- ((pos->bbRookAttacks[uColor] & sq) ? ROOK_BIT : 0) |
- ((pos->bbQueenAttacks[uColor] & sq) ? QUEEN_BIT : 0) |
- ((pos->bbKingAttacks[uColor] & sq) ? KING_BIT : 0);
+ // Pay attention to the types of pieces currently attacking.
+ bvPattern =
+ (((pos->bbPawnAttacks[xColor] & bbRing) != 0) ? PAWN_BIT : 0) |
+ ((((pos->bbMinorAttacks[xColor] | pos->bbMinorXrayAttacks[xColor]) &
+ bbRing) != 0) ? MINOR_BIT : 0) |
+ ((((pos->bbRookAttacks[xColor] | pos->bbRookXrayAttacks[xColor]) &
+ bbRing) != 0) ? ROOK_BIT : 0) |
+ ((((pos->bbQueenAttacks[xColor] | pos->bbQueenXrayAttacks[xColor]) &
+ bbRing) != 0) ? QUEEN_BIT : 0);
+ ASSERT(bvPattern < 32);
+ iSafety += KING_ATTACK_PATTERN_SCALE *
+ (SCORE)KING_COUNTER_BY_ATTACK_PATTERN[bvPattern];
- //
- // Count squares near the king the enemy queen
- // specifically attacks or x-rays (2026-08-30, replaces
- // the old per-queen "pointing near enemy K" bolt-on
- // this loop's own bvAttack/bvXray data already made
- // redundant). Reuses data already read above -- no
- // extra attack-table work -- and, unlike the old term,
- // catches x-ray/latent queen threats too, not just
- // direct ray-cast hits.
- //
- // Queen no longer writes small.uQueen/xray.uQueen at
- // all (2026-09-05 conversion) -- bvAttack/bvXray above
- // already fold its contribution back in via QUEEN_BIT
- // (same byte position .small.uQueen used), so just
- // read those instead of the now-permanently-zero raw
- // struct fields.
- //
- uQueenNearKing += (((bvAttack | bvXray) & QUEEN_BIT) != 0);
+ // Count how many ring squares are attacked by the enemy queen.
+ uQueenNearKing = CountBits(
+ bbRing & (pos->bbQueenAttacks[xColor] | pos->bbQueenXrayAttacks[xColor])
+ );
+ ASSERT(uQueenNearKing <= 8);
+ iSafety += KING_QUEEN_PROXIMITY_DANGER[MINU(uQueenNearKing, 6)];
+
+ // Minor attacks at front, side, back.
+ iSafety += KING_RING_ATTACK_MINOR[0] *
+ (SCORE)CountBits(bbFront & pos->bbMinorAttacks[xColor]);
+ iSafety += KING_RING_ATTACK_MINOR[1] *
+ (SCORE)CountBits(bbBack & pos->bbMinorAttacks[xColor]);
+ iSafety += KING_RING_ATTACK_MINOR[2] *
+ (SCORE)CountBits(bbSide & pos->bbMinorAttacks[xColor]);
+
+ // Rook attacks at front, side, back.
+ iSafety += KING_RING_ATTACK_ROOK[0] *
+ (SCORE)CountBits(bbFront & pos->bbRookAttacks[xColor]);
+ iSafety += KING_RING_ATTACK_ROOK[1] *
+ (SCORE)CountBits(bbBack & pos->bbRookAttacks[xColor]);
+ iSafety += KING_RING_ATTACK_ROOK[2] *
+ (SCORE)CountBits(bbSide & pos->bbRookAttacks[xColor]);
+
+ // Pawn attacks.
+ iSafety += KING_RING_ATTACK_PAWN *
+ (SCORE)CountBits(bbRing & pos->bbPawnAttacks[xColor]);
+
+ // Undefended squares in front, side, back.
+ iSafety += KING_RING_UNDEFENDED[0] *
+ (SCORE)CountBits(bbFront & bbUndefended);
+ iSafety += KING_RING_UNDEFENDED[1] *
+ (SCORE)CountBits(bbBack & bbUndefended);
+ iSafety += KING_RING_UNDEFENDED[2] *
+ (SCORE)CountBits(bbSide & bbUndefended);
+
+ // Only xray attack, no direct attack.
+ iSafety += KING_RING_XRAY_BONUS *
+ (SCORE)CountBits(bbRing & bbEnemyXrayAny & ~bbEnemyAtkAny);
+
+ // Flight squares are empty, unattacked squares around the king.
+ // In front of the king, though, these are gaps and a negative
+ // whereas behind the king they are legitimately small bonuses.
+ iSafety += KING_RING_FRONT_FLIGHT_PENALTY *
+ (SCORE)CountBits(bbFront & bbEmpty & ~bbEnemyAtkAny);
+
+ // Small bonus for empty squares behind the king to retreat into.
+ iSafety += KING_BACK_FLIGHT_BONUS *
+ (SCORE)CountBits(bbBack & bbEmpty & ~bbEnemyAtkAny);
#ifdef KING_DEFECT_DUMP
+ {
+ // Debug-only, per-square breakdown for the mining pipeline
+ // (extract_king_features.py) -- kept fully independent of
+ // the bitboard computation above, same traversal shape as
+ // the pre-rewrite loop so king_features_v4.csv's column
+ // semantics stay unchanged. Does not feed iSafety.
+ static INT KingSafetyDeltas[9] =
+ {
+ -17, -16, -15,
+ -1, +1,
+ +15, +16, +17, 0
+ };
+ ULONG uSlot = 0;
+ BITV bvSlotAttack, bvSlotXray, bvSlotDefend;
+
+ do
+ {
+ cSquare = c + KingSafetyDeltas[uSlot];
+ if (IS_ON_BOARD(cSquare))
{
- BITV bvEither = bvAttack | bvXray;
- ASSERT(u < KING_RING_NUM_SLOTS);
- pDump->fOnBoard[u] = TRUE;
- pDump->fAttackedByPieceType[u][KING_RING_PAWN] =
+ BITBOARD sq = COOR_TO_BB(cSquare);
+ BITV bvEither;
+
+ bvSlotAttack = ((pos->bbPawnAttacks[xColor] & sq) ? PAWN_BIT : 0) |
+ ((pos->bbMinorAttacks[xColor] & sq) ? MINOR_BIT : 0) |
+ ((pos->bbRookAttacks[xColor] & sq) ? ROOK_BIT : 0) |
+ ((pos->bbQueenAttacks[xColor] & sq) ? QUEEN_BIT : 0);
+ bvSlotXray = ((pos->bbMinorXrayAttacks[xColor] & sq) ? MINOR_BIT : 0) |
+ ((pos->bbRookXrayAttacks[xColor] & sq) ? ROOK_BIT : 0) |
+ ((pos->bbQueenXrayAttacks[xColor] & sq) ? QUEEN_BIT : 0);
+ bvSlotDefend = ((pos->bbPawnAttacks[uColor] & sq) ? PAWN_BIT : 0) |
+ ((pos->bbMinorAttacks[uColor] & sq) ? MINOR_BIT : 0) |
+ ((pos->bbRookAttacks[uColor] & sq) ? ROOK_BIT : 0) |
+ ((pos->bbQueenAttacks[uColor] & sq) ? QUEEN_BIT : 0) |
+ ((pos->bbKingAttacks[uColor] & sq) ? KING_BIT : 0);
+
+ bvEither = bvSlotAttack | bvSlotXray;
+ ASSERT(uSlot < KING_RING_NUM_SLOTS);
+ pDump->fOnBoard[uSlot] = TRUE;
+ pDump->fAttackedByPieceType[uSlot][KING_RING_PAWN] =
((bvEither & PAWN_BIT) != 0);
- pDump->fAttackedByPieceType[u][KING_RING_MINOR] =
+ pDump->fAttackedByPieceType[uSlot][KING_RING_MINOR] =
((bvEither & MINOR_BIT) != 0);
- pDump->fAttackedByPieceType[u][KING_RING_ROOK] =
+ pDump->fAttackedByPieceType[uSlot][KING_RING_ROOK] =
((bvEither & ROOK_BIT) != 0);
- pDump->fAttackedByPieceType[u][KING_RING_QUEEN] =
+ pDump->fAttackedByPieceType[uSlot][KING_RING_QUEEN] =
((bvEither & QUEEN_BIT) != 0);
- if (bvAttack != 0)
+ if (bvSlotAttack != 0)
{
- // NB: bvDefend here still includes the own-king bit
- // that the real code strips below via bvDefend &=
- // ~KING_BIT once xrayed/multiply-attacked -- match
- // that exact "is this square adequately defended"
- // semantics rather than the raw pre-strip value.
- BITV bvDefendForDump = bvDefend;
- if (bvXray || (bvAttack & (bvAttack - 1)))
+ BITV bvDefendForDump = bvSlotDefend;
+ if (bvSlotXray || (bvSlotAttack & (bvSlotAttack - 1)))
{
bvDefendForDump &= ~KING_BIT;
}
if (bvDefendForDump == 0)
{
- pDump->fAttackedUndefended[u] = TRUE;
+ pDump->fAttackedUndefended[uSlot] = TRUE;
}
else
{
- pDump->fAttackedDefended[u] = TRUE;
+ pDump->fAttackedDefended[uSlot] = TRUE;
}
}
else
{
- if (bvXray != 0)
+ if (bvSlotXray != 0)
{
- pDump->fXrayedUnattacked[u] = TRUE;
+ pDump->fXrayedUnattacked[uSlot] = TRUE;
}
if (!(pos->bbOccupied & sq))
{
- pDump->fEmptyFlight[u] = TRUE;
+ pDump->fEmptyFlight[uSlot] = TRUE;
}
}
}
-#endif
-
- //
- // Tried a branchless rewrite of this attacked/unattacked
- // split (2026-09-09): compute both sides' contributions
- // and blend via 0/1 multiply instead of branching. Measured
- // *worse* under EVAL_TIME (king bucket 17.9% vs 16.0-16.1%
- // baseline, full-eval cost ~3330 vs ~3270 cyc/call,
- // consistent across 3 runs each on a fixed sd9 position) --
- // the fOccupied ? : select that stayed in that version
- // still compiled to a real CMP/branch (confirmed in
- // objdump), so the rewrite paid for extra multiplies/masks
- // on top of a branch that was never actually eliminated.
- // Reverted; not a win here, don't retry without a way to
- // force the compare into a cmov/select first.
- //
- if (bvAttack != 0)
- {
- bvPattern |= (bvAttack | bvXray);
- if (bvXray || (bvAttack & (bvAttack - 1)))
- {
- bvDefend &= ~KING_BIT;
- }
- if (!(pos->bbOccupied & sq))
- {
- ASSERT(u < 8);
- uCounter += EmptyAttackedSquare[uColor][u];
- }
- else
- {
- // OccupiedAttackedSquare is indexed purely by
- // color (is the occupant ufColor's own piece,
- // i.e. an enemy from uColor's perspective) --
- // no need for the mailbox piece type at all.
- uCounter +=
- OccupiedAttackedSquare[(bbEnemyOcc & sq) != 0];
- }
- uCounter += (bvDefend == 0);
- }
- else
- {
- FLAG fEmpty = ((pos->bbOccupied & sq) == 0);
- uCounter += (bvXray != 0);
- ASSERT(u < 8);
- uCounter += EmptyUnattackedSquare[uColor][u];
- uFlightSquares += fEmpty;
- uBackFlightSquares += (fEmpty && KING_RING_IS_BACK[uColor][u]);
- }
+ uSlot++;
}
- u++;
+ while (KingSafetyDeltas[uSlot] != 0);
}
- while(KingSafetyDeltas[u] != 0);
-#ifdef EVAL_DUMP
- Trace("%s KS Counter post-squares: %u\n", COLOR_NAME(uColor), uCounter);
#endif
//
// King's own file plus the two adjacent ones. EXPERIMENTAL,
// prototype-only (2026-09-05, board_representation/EVAL.md
// section 9's king-safety-hash discussion): now routed through
- // _GetFileStormDefects's cache instead of computed inline --
- // _ComputeFileStormDefects holds the exact same logic this used
- // to be (collapsed 2026-08-30 from three copy-pasted blocks that
- // differed only in c-1/c/c+1; confirmed behaviorally neutral by
- // isolated sd10 suite testing before that landing).
+ // _GetFileStormDefects's cache instead of computed inline.
+ // Already a direct SCORE as of the 2026-09-15 rewrite -- see
+ // _ComputeFileStormDefects's comment.
//
- uCounter += _GetFileStormDefects(ctx, pos, pHash, uColor);
+ iSafety += _GetFileStormDefects(ctx, pos, pHash, uColor);
#ifdef KING_DEFECT_DUMP
- pDump->uFileStormDefects = _GetFileStormDefects(ctx, pos, pHash, uColor);
_ComputeFileStormDefectsSplit(pos, pHash, uColor,
- &pDump->uFileStormOpenFile,
- &pDump->uFileStormStormingPawn);
-#endif
-#ifdef EVAL_DUMP
- Trace("%s KS Counter post-open file/stormers: %u\n", COLOR_NAME(uColor),
- uCounter);
+ &pDump->uFileStormOpenFile,
+ &pDump->uFileStormStormingPawn);
+ pDump->uFileStormDefects = pDump->uFileStormOpenFile +
+ pDump->uFileStormStormingPawn;
#endif
- ASSERT(bvPattern >= 0);
- ASSERT(bvPattern < 32);
- v = KING_COUNTER_BY_ATTACK_PATTERN[bvPattern];
- uCounter += v;
-#ifdef EVAL_DUMP
- Trace("%s KS Counter post-attack pattern: %u\n", COLOR_NAME(uColor),
- uCounter);
+ if (u >= KEEP_KING_AT_HOME_THRESHOLD)
+ {
+ iSafety += KING_INITIAL_LOCATION_SCALE *
+ KING_EVAL_INITIAL_LOCATION[uColor][c];
+ }
+#ifdef KING_DEFECT_DUMP
+ pDump->uInitialCounterByLocation =
+ (u >= KEEP_KING_AT_HOME_THRESHOLD) ?
+ (ULONG)KING_EVAL_INITIAL_LOCATION[uColor][c] : 0;
#endif
- ASSERT(uFlightSquares <= 8);
- uCounter += KingFlightDefects[uFlightSquares] * (v > 1);
-#ifdef EVAL_DUMP
- Trace("%s KS Counter post-flight sq: %u\n", COLOR_NAME(uColor), uCounter);
-#endif
+ // Danger-magnitude acceleration -- see KING_DANGER_ACCELERATOR's
+ // comment above. Applied here (as part of the real per-king
+ // computation) rather than folded into the final material-scale
+ // multiply below, since it's a function of the aggregate danger
+ // itself, not of the attacker's material.
+ if (iSafety < 0)
+ {
+ ULONG uMagBucket = MINU(6,
+ (ULONG)(-iSafety) / uKingDangerAcceleratorBucketWidth);
+ iSafety = (iSafety * KING_DANGER_ACCELERATOR[uMagBucket]) / 128;
+ }
- // Note: can't use pos->iReducedMaterialDownScaler here because we
- // are scaling it based on the _other_ side's material.
- uCounter = MINU(uCounter, 41);
- i = KING_SAFETY_BY_COUNTER[uCounter];
-#ifdef KING_DEFECT_DUMP
- pDump->uQueenNearKing = uQueenNearKing;
- pDump->uCounterTotal = uCounter;
- pDump->uSafetyTableValue = (ULONG)(-i);
- pDump->fCastled = pos->fCastled[uColor];
- pDump->uMinorsAtHome = pos->uMinorsAtHome[uColor];
- pDump->uAttackerArmyScaler = pos->uArmyScaler[xColor];
-#endif
#ifdef CALIBRATE_POSITIONAL
- // KING_SAFETY_BY_COUNTER is <= 0 throughout (a penalty); the
- // calibration histogram bins magnitude, matching iKingSwingP90's
- // own all-positive-mOBagnitude shape.
- ASSERT(i <= 0);
- _RecordPositionalCalibration(ctx, pos, pHash, uColor, (ULONG)(-i));
+ // Restored 2026-09-15 (was dropped when the old uCounter/
+ // KING_SAFETY_BY_COUNTER-shaped call site disappeared in the
+ // linear-SCORE rewrite) -- see _RecordPositionalCalibration's
+ // comment for why this now bins ABS(iSafety) instead of the old
+ // always-nonnegative -i.
+ _RecordPositionalCalibration(ctx, pos, pHash, uColor,
+ (ULONG)((iSafety < 0) ? -iSafety : iSafety));
#endif
- i *= REDUCED_MATERIAL_DOWN_SCALER[pos->uArmyScaler[xColor]];
- i /= 8;
- EVAL_TERM(uColor,
- KING,
- c,
- iKingScore,
- i,
- "king safety");
- // 2026-09-15: this term used to apply at full, unscaled magnitude
- // regardless of how much material the attacker had left -- unlike
- // the main counter-based king-safety score just above, which is
- // multiplied by REDUCED_MATERIAL_DOWN_SCALER/8 (a step function at
- // DO_KING_SAFETY_THRESHOLD, full strength only once the attacker
- // has roughly queen+2R+2m). Self-play at both -32/count and -21/
- // count came back net negative (2780 games score 0.490 llr -1.91;
- // 520 games score 0.480 llr -0.63) even after trimming magnitude
- // 33% -- the scaling gap, not just the raw weight, looks like the
- // real defect: a position barely past DO_KING_SAFETY_THRESHOLD was
- // getting the same full penalty as a position with a full army.
- // Matching the main term's scaling here instead of trimming further.
- ASSERT(uQueenNearKing <= 10);
+ // Single multiply for the entire accumulated total -- the
+ // whole point of this rewrite (see the function-header comment
+ // above). Note: can't use pos->iReducedMaterialDownScaler here
+ // because we are scaling based on the _other_ side's material.
+ iSafety = (iSafety *
+ (SCORE)REDUCED_MATERIAL_DOWN_SCALER[pos->uArmyScaler[xColor]]) / 8;
EVAL_TERM(uColor,
KING,
c,
iKingScore,
- (SCORE)((KING_QUEEN_PROXIMITY_DANGER[MINU(uQueenNearKing, 6)] *
- (SCORE)REDUCED_MATERIAL_DOWN_SCALER[pos->uArmyScaler[xColor]]) / 8),
- "queen proximity danger");
+ iSafety,
+ "king safety");
- ASSERT(uBackFlightSquares <= 3);
- EVAL_TERM(uColor,
- KING,
- c,
- iKingScore,
- (SCORE)(KING_BACK_FLIGHT_BONUS * uBackFlightSquares),
- "back rank flight bonus");
+#ifdef KING_DEFECT_DUMP
+ pDump->iRealSafety = iSafety;
+ pDump->uQueenNearKing = uQueenNearKing;
+ // uCounterTotal/uSafetyTableValue no longer have a coherent
+ // meaning post-rewrite (no more shared uCounter/KING_SAFETY_
+ // BY_COUNTER lookup) -- left zeroed by the memset above rather
+ // than populated with something misleading. Both were REFERENCE-
+ // only columns in the training pipeline (train_king_safety_
+ // model.py explicitly excludes them from the feature set), so
+ // this doesn't affect the regression itself.
+ pDump->fCastled = pos->fCastled[uColor];
+ pDump->uMinorsAtHome = pos->uMinorsAtHome[uColor];
+ pDump->uAttackerArmyScaler = pos->uArmyScaler[xColor];
+#endif
// Bonus for castling / penalty for loss of castle. Also, if side has
// not yet castled, be concerned with undeveloped minor pieces.
@@ -4667,9 +4823,7 @@ Return value:
u = pos->uNonPawnCount[WHITE][0] + pos->uNonPawnCount[BLACK][0];
if (u < 8)
{
- //
- // Encourage kings to come to the center
- //
+ // Encourage kings to come to the center when safe.
i = KING_TO_CENTER[c];
EVAL_TERM(uColor,
KING,
@@ -4678,9 +4832,7 @@ Return value:
i,
"centralize king");
- //
// Kings in front of passers in the late endgame are strong...
- //
cSquare = FILE(c);
bb = pHash->bbPasserLocations[uColor] &
BBADJACENT_FILES_INCLUSIVE[cSquare];
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: