diff options
| author | Scott Gasch <[email protected]> | 2026-10-25 09:55:30 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-10-25 09:55:30 -0700 |
| commit | 874ef0478078ef4eb436af7d2b68a353f61f4d70 (patch) | |
| tree | e9954c289393c64d88402beff04637b52732ca73 /src/search.c | |
| parent | 5f901d737b17c5f99b6e456d6c0c7d5e3c093dc4 (diff) | |
Learned FH% logistic-regression model (alpha full, beta whole-node-only)
Replaces the data.c histogram tables (g_uJointFHThousandths/
g_uBetaFHThousandths and their _NNUE counterparts) with two quantized
logistic regressions as the default FH%-prediction mechanism under
FH_USE_REGRESSION_MODEL=1, trained against real collected search data
(fhcollect.c) per docs/REGRESSION.md.
Alpha side is a full per-move regression, validated against the old
table (0.119pp weighted mean deviation across 10,684 matched cells).
Beta side is whole-node-only (matching the old table's own scope --
no functionality lost), refit on the NO_MOVE population after an
earlier mixed-tier fit saturated and over-pruned; its skip threshold
(FH_BETA_SKIP_THRESHOLD=99000) was confirmed by direct self-play
sweeps, not just ECM solve-count intuition (see docs/REGRESSION.md's
"Category A vs B" discussion).
Gated by an overnight SPRT match (regression vs HEAD tables, same
NNUE eval, sd8): at 4120 games, llr=+2.53 (never dipped negative,
peaked +2.8), score=0.517, candidate reaching comparable depth at
higher nps/ebf than head. Did not cross the H1=+2.94 SPRT bound, but
the trend across the full run is unambiguous and the marginal value of
waiting out the remaining games was judged not worth it.
The data.c tables are left in place as the inactive #else branch of
FH_USE_REGRESSION_MODEL for now; a follow-up commit will flip the
build default and remove them once this commit's head_reference
baseline and ECM numbers are recorded.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/search.c')
| -rw-r--r-- | src/search.c | 336 |
1 files changed, 210 insertions, 126 deletions
diff --git a/src/search.c b/src/search.c index a347e12..dc0fdc0 100644 --- a/src/search.c +++ b/src/search.c @@ -373,6 +373,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, { POSITION *pos = &ctx->sPosition; PLY_INFO *pi = &ctx->sPlyInfo[ctx->uPly]; + SCORE iBestScore; #ifdef DEBUG ASSERT(IS_VALID_SCORE(iAlpha)); ASSERT(IS_VALID_SCORE(iBeta)); @@ -393,25 +394,6 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // instead of extending for free. root.c compensates by trimming the // same QUARTER_PLY off the per-iteration depth budget so this doesn't // just add a blanket 1/4 ply to every search. - SCORE iBestScore; -#ifdef FH_TABLE_COLLECTION - // Declared here, BEFORE the QSearch-jump `goto end;` just below -- - // these must be initialized on every path reaching end:, including - // the early QSearch exit, or the commit code there reads - // indeterminate (garbage) values. Declaring them further down - // (alongside the other per-node locals) put their initializers - // after this goto, which skips them entirely on the QSearch-exit - // path -- caught live via a release-build (-O3) segfault that - // didn't reproduce in DEBUG (-O0, where the skipped stack slots - // happened to already read as zero): fBetaCollectEligible/ - // fAlpha0CollectEligible read back as nonzero garbage, feeding - // equally-garbage bucket indices into the array indexing at end:. - FLAG fBetaCollectEligible = FALSE; - ULONG uBetaCollectDepthB = 0, uBetaCollectGapB = 0, - uBetaCollectOwnImpB = 0, uBetaCollectEnemyImpB = 0; - FLAG fAlpha0CollectEligible = FALSE; - ULONG uAlpha0CollectDepthB = 0, uAlpha0CollectGapB = 0; -#endif if (uDepth < THREE_QUARTERS_PLY) { pf->fCouldStandPat[BLACK] = pf->fCouldStandPat[WHITE] = FALSE; @@ -424,7 +406,33 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, goto end; } pi->fInQsearch = FALSE; + iBestScore = -INFINITY_SCORE; + + // Declared here, BEFORE the QSearch-jump `goto end;` just below, and + // zero-initialized by the `= {0}` -- every path reaching end: (the + // early QSearch exit included) must see a well-defined + // sFhPending.fEligible, or FhCollectNodeExit would read + // indeterminate garbage there. This bit the old FH_TABLE_COLLECTION + // design exactly this way once already (a release-build segfault + // that didn't reproduce in DEBUG, where the skipped stack slots + // happened to already read as zero) -- same fix, applied up front + // via the initializer so there's no runtime code to accidentally + // skip a second time. See chess.h's FH_COLLECT_PENDING comment for + // why this is an ordinary local, not something stored in ctx. + FH_COLLECT_PENDING sFhPending = {0}; + + // Declared unconditionally (not #ifdef FH_REGRESSION_COLLECTION) + // so the FhCollectMoveTried call sites below -- themselves + // unconditional, by design, see chess.h's no-op-stub comment -- + // compile in every build. Only SearchOneMove's WRITE to this (via + // its own #ifdef-gated out-param) is collection-build-only; in any + // other build it stays at this initializer, harmlessly unread + // (FhCollectMoveTried is a no-op there). Written by SearchOneMove + // inside its own Make/Unmake window -- see that function's own + // comment on why this can't be computed by this caller after the + // fact. + SCORE iFhCollectMoveValue = 0; MOVE mvLast = (pi-1)->mv; ASSERT((mvLast.uMove != 0) || (pf->fAvoidNullmove == TRUE)); SCORE iInitialAlpha = iAlpha; @@ -637,6 +645,14 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, ctx->sPlyInfo[ctx->uPly - 3].iEval); } + // Capture CommonSearchInit's ancestor-only NODE_TYPE guess BEFORE + // _RefineNodeTypeGuess overwrites pi->eNodeType below -- the + // collection model's node-type-guess feature must be this raw, + // parent-derived guess, never the refined one (feeding a model's + // own, even indirectly produced, output back in as one of its own + // inputs would be leakage -- see docs/REGRESSION.md). + NODE_TYPE eNodeTypeGuessParent = pi->eNodeType; + // Now that iEval (and the improvement signals derived from it) are // known, refine CommonSearchInit's ancestor-only NODE_TYPE guess -- // see _RefineNodeTypeGuess's own comment. Must happen before @@ -648,8 +664,49 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // the static-nullmove and nullmove-skip blocks below need that // exact same number and must reuse uNodeLocalFH instead of // re-consulting the data.c tables a second time per node. - ULONG uNodeLocalFH = _RefineNodeTypeGuess(ctx, iEval, iAlpha, iBeta, uDepth, - iEnemyImprovement, uRawImproveB); + ULONG uNodeLocalFH = _RefineNodeTypeGuess(ctx, + iEval, + iAlpha, + iBeta, + uDepth, + iImprovement, + iEnemyImprovement, + uRawImproveB); + + // One call, regardless of which (or neither) of the iEval>iBeta / + // iEval<=iAlpha branches below ends up applying -- FhCollectNodeEntry + // decides eligibility and which table internally from iEval/iAlpha/ + // iBeta, and records it in sFhPending for FhCollectMoveTried/ + // FhCollectNodeExit to read later. iEnemyImprovement is only + // meaningful for the beta table (its own 4th axis); the alpha table + // has no second improve axis and simply ignores it. A no-op (does + // nothing, costs nothing) in a non-collection build. + FhCollectNodeEntry(ctx, + &sFhPending, + iEval, + iAlpha, + iBeta, + uDepth, + iImprovement, + iEnemyImprovement, + eNodeTypeGuessParent, + (pos->u64NonPawnSig ^ pos->u64PawnSig), + ((pos->uNonPawnMaterial[WHITE] - VALUE_KING) + + (pos->uNonPawnMaterial[BLACK] - VALUE_KING))); + +#ifdef FH_USE_REGRESSION_MODEL + // HOISTED node-level precompute: + FHM_NODE_BASE sFhAlphaNodeBase = (iEval <= iAlpha) ? + FhModelAlphaNodeBaseQ16(iEval, + iAlpha, + uDepth, + iImprovement, + iEnemyImprovement, + (ULONG)((pos->uNonPawnMaterial[WHITE] - VALUE_KING) + + (pos->uNonPawnMaterial[BLACK] - VALUE_KING)), + eNodeTypeGuessParent) : + (FHM_NODE_BASE)FHM_MATE_GATED; +#endif // If the Eval call above was a full eval (i.e. not lazy or super // lazy), there are usable attack bitboards. We'll consult them @@ -760,38 +817,49 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, if (iEval > iBeta) { INC(ctx->sCounters.tree.u64QuickNullMoves); -#ifdef FH_TABLE_COLLECTION - ULONG uStaticNMDepthB = DepthBucket(uDepth / ONE_PLY); - ULONG uRawEnemyImproveB = ImproveBucket(iEnemyImprovement); - // Collection builds record a genuine whole-node try here - // (ground truth committed at end:) and must NOT take the - // shortcut below -- it would skip the real move loop - // based on the CURRENTLY LOADED (soon-to-be-replaced) - // data.c table, censoring exactly the population this - // collection is trying to measure. Same reasoning as the - // LMP disable in searchsup.c. - fBetaCollectEligible = TRUE; - uBetaCollectDepthB = uStaticNMDepthB; - uBetaCollectGapB = GapBetaBucket((SCORE)(iEval - iBeta)); - uBetaCollectOwnImpB = uRawImproveB; - uBetaCollectEnemyImpB = uRawEnemyImproveB; - INC(ctx->sFhCollection.u64BetaDepthEstimateTries[uBetaCollectDepthB]); - INC(ctx->sFhCollection.u64BetaGapEstimateTries[uBetaCollectGapB]); - INC(ctx->sFhCollection.u64BetaOwnImpEstimateTries[uBetaCollectOwnImpB]); - INC(ctx->sFhCollection.u64BetaEnemyImpEstimateTries[uBetaCollectEnemyImpB]); - INC(ctx->sFhCollection.u64BetaTries - [uBetaCollectDepthB][uBetaCollectGapB] - [uBetaCollectOwnImpB][uBetaCollectEnemyImpB]); +#ifndef FH_REGRESSION_COLLECTION + // A collection build must NOT take this shortcut -- it + // would skip the real move loop based on the CURRENTLY + // LOADED (soon-to-be-replaced) data.c table, censoring + // exactly the population FhCollectNodeEntry/NodeExit + // (above/below) are trying to measure. Same reasoning as + // the LMP disable in searchsup.c. uNodeLocalFH was already + // computed above by _RefineNodeTypeGuess via this exact + // same _BetaTablePredictedFHThousandths lookup (same + // depth/gap/improve bucket inputs, gated by the same + // iEval > iBeta branch) -- reuse it instead of consulting + // the table again. +#ifdef FH_USE_REGRESSION_MODEL + static ULONG uBetaSkipThreshold = 0; + if (0 == uBetaSkipThreshold) + { + CHAR *pszThresh = getenv("FH_BETA_SKIP_THRESHOLD"); + // 2026-10-24: provisional default, NOT yet confirmed by + // match-play -- see docs/REGRESSION.md's "Static-nullmove + // shortcut firing-rate" section. 96000 (matching the old + // table's nominal threshold) empirically fires the + // shortcut ~2x as often as the table did on real search + // trees (calibration is honest at both; this is a + // firing-frequency gap, not an accuracy one) and costs a + // large chunk of ECM solve rate. 99000 recovers ECM + // parity. Per DEEPER_PRUNING.md's Category A/B framework, + // ECM is the WRONG instrument to pick this value with -- + // this is a Category B (deliberately-unsound, + // cluster-tested) pruning decision, and only + // match_play.py's LOWER95 gate can say whether the more + // aggressive 96000 is actually a net real-game win + // despite losing ECM solves. That sweep has not been run + // yet. 99000 is chosen here only as the closest-to-known- + // safe (table-matched) stand-in until it is. + uBetaSkipThreshold = (pszThresh && atol(pszThresh) > 0) ? + (ULONG)atol(pszThresh) : 99000; + } #else - // uNodeLocalFH was already computed above by - // _RefineNodeTypeGuess via this exact same - // _BetaTablePredictedFHThousandths lookup (same depth/gap/ - // improve bucket inputs, gated by the same iEval > iBeta - // branch) -- reuse it instead of consulting the table - // again. + #define uBetaSkipThreshold 96000 +#endif ULONG uBetaPredictedFHThousandths = uNodeLocalFH; if ((uBetaPredictedFHThousandths != 0xFFFF) && - (uBetaPredictedFHThousandths > 96000)) + (uBetaPredictedFHThousandths > uBetaSkipThreshold)) { INC(ctx->sCounters.tree.u64QuickNullMoveSuccess); @@ -800,35 +868,13 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, iBestScore = iBeta; goto end; } +#ifndef FH_USE_REGRESSION_MODEL + #undef uBetaSkipThreshold +#endif #endif } else if (iEval <= iAlpha) { -#ifdef FH_TABLE_COLLECTION - ULONG uNullSkipDepthB = DepthBucket(uDepth / ONE_PLY); - ULONG uRawGapAlphaB = GapCpBucket((SCORE)(iAlpha - iEval)); - // Whole-node sample for the alpha table's reserved - // late==0 bucket (see chess.h) -- the alpha-side analog - // of the beta-side whole-node try just above, covering - // the iEval <= iAlpha regime the beta table doesn't. - // hang is pinned to 0 (no specific move; "nothing hung" - // is the only sensible placeholder) and late is pinned to - // 0 (the reserved "whole node" sentinel, never produced - // by a real per-move sample) -- deliberately NOT fed into - // the shared per-move hang/late marginals below (those - // values are placeholders here, not real measurements), - // but depth/gap/improve ARE real, equally-meaningful - // quantities for a whole-node sample, so they DO feed the - // shared marginals. - fAlpha0CollectEligible = TRUE; - uAlpha0CollectDepthB = uNullSkipDepthB; - uAlpha0CollectGapB = uRawGapAlphaB; - INC(ctx->sCounters.tree.u64DepthEstimateTries[uAlpha0CollectDepthB]); - INC(ctx->sCounters.tree.u64GapEstimateTries[uAlpha0CollectGapB]); - INC(ctx->sFhCollection.u64ImproveEstimateTries[uRawImproveB]); - INC(ctx->sFhCollection.u64Alpha5Tries - [uAlpha0CollectDepthB][0][uAlpha0CollectGapB][0][uRawImproveB]); -#endif #if (NULLMOVE_SKIP_FH_CEILING_THOUSANDTHS > 0) // uNodeLocalFH was already computed above by // _RefineNodeTypeGuess via this exact same @@ -859,14 +905,28 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, uNullRBucket = NULL_R_BUCKETS - 1; } INC(ctx->sCounters.tree.u64NullMoveTriesByR[uNullRBucket]); - if (TryNullmovePruning(ctx, - &fThreat, - iAlpha, - iBeta, - uNextDepth, - &iPositionalExtend, - &iScore, - uNullRBucket)) + FLAG fNullmoveCutoff = TryNullmovePruning(ctx, + &fThreat, + iAlpha, + iBeta, + uNextDepth, + &iPositionalExtend, + &iScore, + uNullRBucket); + // The null-move probe's own recursive Search() call runs + // unconditionally inside TryNullmovePruning before it + // returns either way, so iScore is always this probe's + // real result here -- log it as one more "move" tried at + // this node (tier = null), same label convention as every + // other move/row. Skipped on an aborted/incomplete search, + // same reasoning as FhCollectNodeExit. A no-op in a + // non-collection build. + if (!WE_SHOULD_STOP_SEARCHING) + { + FhCollectMoveTried(ctx, &sFhPending, FHC_TIER_NULL, + iScore, iBeta, 1, 0, 0, 0, FALSE, 0); + } + if (fNullmoveCutoff) { if (iScore > iBeta) { @@ -1175,6 +1235,9 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, uRawImproveB, bbMinorAttacksSnapshot, bbRookAttacksSnapshot, +#ifdef FH_USE_REGRESSION_MODEL + sFhAlphaNodeBase, +#endif #ifdef MOVE_ORDERING_EVIDENCE_COLLECTION fThisMoveIsCountermoveMatch, uCMEvidenceBucket, @@ -1193,6 +1256,9 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, , &uThisMoveNumBucket , &uThisMoveDepthBucket #endif +#ifdef FH_REGRESSION_COLLECTION + , &iFhCollectMoveValue +#endif ); switch (eMoveKernelResult) { @@ -1207,6 +1273,57 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, break; } + // One call per move actually searched here (hash move and + // every generated move alike -- MOVE_KERNEL_PRUNED/ILLEGAL + // already `continue`d above without reaching this point, and + // a collection build disables LMP entirely, so in practice + // every eligible move reaches here). iScore is this exact + // move's own full-depth result, so its label (>= iBeta) is + // known immediately -- unlike the deferred whole-node commit, + // no stash/commit split needed. A no-op in a non-collection + // build. + if (IS_SAME_MOVE(mv, mvHash)) + { + // Never scored by _ScoreAllMoves (generate.c skips it + // entirely, see that function's own comment) -- no packed + // iValue to read a tier from, but iFhCollectMoveValue + // (written by SearchOneMove, inside its own Make/Unmake + // window -- see that function's comment) is valid for + // every move regardless, hash move included. + FhCollectMoveTried(ctx, &sFhPending, FHC_TIER_HASH, iScore, + iBeta, 1, iFhCollectMoveValue, + GetMoveFailHighWilsonUpperBoundThousandths(mv), + GetMoveFailHighAttempts(mv), + FALSE, 0); + } + else + { + FLAG fMoveIsCapOrPromo = (0 != IS_CAPTURE_OR_PROMOTION(mv)); + + // iFhCollectMoveValue was written by SearchOneMove, inside + // its own Make/Unmake window -- EstimateHangingMaterialCp + // asserts the move is already applied on the board, so it + // can only safely be called from there, never by this + // caller after SearchOneMove has already returned (and + // un-made the move) -- a real bug this doc's session notes + // cover, caught by the DEBUG+TEST self-test suite. The + // function itself branches internally for captures/ + // promotions (-ComputeMoveScore, a SEE-plus-promotion + // material estimate) vs. quiet moves (real hang/attack- + // defense analysis, the old table's `dumb` axis -- 96x + // discrimination on the alpha side, per docs/REGRESSION.md) + // -- one value covers both tiers correctly. + FhCollectMoveTried(ctx, &sFhPending, + FhClassifyMoveTier(mv, mvHash, + fMoveIsCapOrPromo, + ctx->sMoveStack.mvf[x-1].iValue), + iScore, iBeta, uLegalMoves, + iFhCollectMoveValue, + GetMoveFailHighWilsonUpperBoundThousandths(mv), + GetMoveFailHighAttempts(mv), + TRUE, MOVE_COUNT(ctx, ctx->uPly)); + } + // Check results of search below this move. ASSERT(iBestScore <= iAlpha); ASSERT(iAlpha < iBeta); @@ -1246,12 +1363,6 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, INC(ctx->sCounters.tree.u64JointFH [uThisMoveDepthBucket][uThisMoveHangBucket] [uThisMoveGapBucket][uThisMoveNumBucket]); -#ifdef FH_TABLE_COLLECTION - INC(ctx->sFhCollection.u64ImproveEstimateFH[uRawImproveB]); - INC(ctx->sFhCollection.u64Alpha5FH - [uThisMoveDepthBucket][uThisMoveHangBucket] - [uThisMoveGapBucket][uThisMoveNumBucket][uRawImproveB]); -#endif } } #endif // PERF_COUNTERS @@ -1385,43 +1496,16 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, ASSERT(IS_VALID_SCORE(iAlpha)); ASSERT(IS_VALID_SCORE(iBestScore) || WE_SHOULD_STOP_SEARCHING); ASSERT(PositionsAreEquivalent(pos, &pi->sPosition)); -#ifdef FH_TABLE_COLLECTION - // Commit the whole-node ground truth stashed above, now that - // iBestScore's real fate against iBeta is known -- same two-phase - // pattern as TEST_NULL's commit just below. Skipped on an - // aborted/incomplete search (WE_SHOULD_STOP_SEARCHING): iBestScore - // may not reflect a fully-searched node in that case, and counting - // it as ground truth would bias the table toward whatever got - // searched before the clock/node-limit fired. - if ((!WE_SHOULD_STOP_SEARCHING) && IS_VALID_SCORE(iBestScore)) - { - FLAG fRealFH = (iBestScore >= iBeta); - if (fBetaCollectEligible) - { - if (fRealFH) - { - INC(ctx->sFhCollection.u64BetaDepthEstimateFH[uBetaCollectDepthB]); - INC(ctx->sFhCollection.u64BetaGapEstimateFH[uBetaCollectGapB]); - INC(ctx->sFhCollection.u64BetaOwnImpEstimateFH[uBetaCollectOwnImpB]); - INC(ctx->sFhCollection.u64BetaEnemyImpEstimateFH[uBetaCollectEnemyImpB]); - INC(ctx->sFhCollection.u64BetaFH - [uBetaCollectDepthB][uBetaCollectGapB] - [uBetaCollectOwnImpB][uBetaCollectEnemyImpB]); - } - } - if (fAlpha0CollectEligible) - { - if (fRealFH) - { - INC(ctx->sCounters.tree.u64DepthEstimateFH[uAlpha0CollectDepthB]); - INC(ctx->sCounters.tree.u64GapEstimateFH[uAlpha0CollectGapB]); - INC(ctx->sFhCollection.u64ImproveEstimateFH[uRawImproveB]); - INC(ctx->sFhCollection.u64Alpha5FH - [uAlpha0CollectDepthB][0][uAlpha0CollectGapB][0][uRawImproveB]); - } - } - } -#endif // FH_TABLE_COLLECTION + // Commit the whole-node ground truth stashed by FhCollectNodeEntry + // above, now that iBestScore's real fate against iBeta is known -- + // same two-phase pattern as TEST_NULL's commit just below. + // FhCollectNodeExit itself skips committing on an aborted/ + // incomplete search (iBestScore may not reflect a fully-searched + // node in that case, and counting it as ground truth would bias + // the model toward whatever got searched before the clock/node- + // limit fired) or if sFhPending.fEligible is FALSE. A no-op in a + // non-collection build. + FhCollectNodeExit(ctx, &sFhPending, iBestScore, iBeta, WE_SHOULD_STOP_SEARCHING); #ifdef TEST_NULL // Commit the calibration record stashed at the diagnostic probe // site above, now that iBestScore's fate against iBeta (this |
