diff options
| author | Scott Gasch <[email protected]> | 2026-09-15 13:31:36 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-09-15 13:31:36 -0700 |
| commit | 390e675849ff42c8b7e1806f75552de988741fff (patch) | |
| tree | 17b8792aeaf74373bbaef3bac6b71bb5c0d74c69 /src | |
| parent | ca73728f7dbf01c4b39c7496729f81b1fbffa00b (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')
| -rwxr-xr-x | src/chess.h | 30 | ||||
| -rw-r--r-- | src/command.c | 5 | ||||
| -rw-r--r-- | src/data.c | 49 | ||||
| -rw-r--r-- | src/eval.c | 880 | ||||
| -rwxr-xr-x | src/eval_tune/dna_diff.py | 94 | ||||
| -rw-r--r-- | src/eval_tune/extract_king_features.py | 2 | ||||
| -rwxr-xr-x | src/eval_tune/match_play.py | 2 | ||||
| -rw-r--r-- | src/eval_tune/mine_king_mates.py | 72 | ||||
| -rwxr-xr-x | src/eval_tune/test_vs_head.sh | 46 | ||||
| -rwxr-xr-x | src/eval_tune/tune_eval_dna.py | 182 |
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 @@ -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); + } + } } } } @@ -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: |
