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/searchsup.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/searchsup.c')
| -rw-r--r-- | src/searchsup.c | 233 |
1 files changed, 159 insertions, 74 deletions
diff --git a/src/searchsup.c b/src/searchsup.c index 397d57e..0bddc31 100644 --- a/src/searchsup.c +++ b/src/searchsup.c @@ -552,6 +552,56 @@ _ShipLateBucket(ULONG uRawLateBucket) #define JOINT_TABLE_SENTINEL (0xFFFF) +// Decodes generate.c's _ScoreAllMoves packed iValue sort key back +// into one of the FH_COLLECT_TIER tiers (chess.h) -- moved here from +// fhcollect.c (2026-10-24) and made unconditional: fhmodel.c's +// production FH-regression inference needs this classification on +// every build, not just FH_REGRESSION_COLLECTION ones, and this +// function is pure/stateless (just decodes bits already in mv/iValue) +// so there's no reason to keep it gated or duplicated. Does NOT +// distinguish FHC_TIER_NULL -- the null-move probe isn't a real, +// generated move at all (no iValue, no mv to compare), so callers +// identify it directly rather than through this function. +FH_COLLECT_TIER +FhClassifyMoveTier(MOVE mv, MOVE mvHash, FLAG fIsCaptureOrPromotion, + SCORE iValue) +{ + if (IS_SAME_MOVE(mv, mvHash)) + { + return(FHC_TIER_HASH); + } + // NOT a `TRUE ==` comparison on purpose -- the caller passes + // IS_CAPTURE_OR_PROMOTION(mv) straight through, and that macro is + // `(mv.uMove & 0x0FF00000)`, a bitmask test that returns an + // arbitrary nonzero value, not literally 1/TRUE (confirmed by + // grepping every other real call site in generate.c/searchsup.c/ + // search.c -- all plain truthiness, never `TRUE ==`). + if (0 != fIsCaptureOrPromotion) + { + // _ScoreAllMoves adds SORT_THESE_FIRST only to non-negative-SEE + // captures/promotions; a losing capture's iValue never reaches + // it. + return((iValue >= SORT_THESE_FIRST) ? + FHC_TIER_GOOD_CAPTURE : FHC_TIER_LOSING_CAPTURE); + } + // Quiet move. Killer bits take priority over the countermove bonus + // (matches _ScoreAllMoves' own documented priority list). + // COUNTERMOVE_TIER_BONUS shares its low bit with GOOD_MOVE + // (0x03000000 = 0x02000000 | 0x01000000), so it needs an exact + // match, not a bare AND-is-nonzero test, or a plain killer (whose + // bits are all numerically >= GOOD_MOVE too) could be misread as + // a countermove. + if (0 != (iValue & (FIRST_KILLER | SECOND_KILLER | THIRD_KILLER | FOURTH_KILLER))) + { + return(FHC_TIER_KILLER); + } + if (COUNTERMOVE_TIER_BONUS == (iValue & COUNTERMOVE_TIER_BONUS)) + { + return(FHC_TIER_COUNTERMOVE); + } + return(FHC_TIER_LEFTOVER_QUIET); +} + // Looks up this move's predicted FH% (thousandths of a percent) from // the baked joint table, given the four RAW bucket indices (as // HangCpBucket/GapCpBucket/MoveNumBucket/DepthBucket produce them). @@ -689,6 +739,7 @@ _RefineNodeTypeGuess(SEARCHER_THREAD_CONTEXT *ctx, SCORE iAlpha, SCORE iBeta, ULONG uDepth, + SCORE iImprovement, SCORE iEnemyImprovement, ULONG uRawImproveB) { @@ -696,6 +747,19 @@ _RefineNodeTypeGuess(SEARCHER_THREAD_CONTEXT *ctx, ULONG uAncestorGuess; ULONG uLocalFH = MOVE_FH_ESTIMATE_NO_DATA; ULONG uDepthInPlies; +#ifdef FH_USE_REGRESSION_MODEL + // Parent-derived guess, read BEFORE this function's own + // assignment below overwrites pi->eNodeType -- same leakage + // concern as search.c's eNodeTypeGuessParent capture (feeding the + // model's own refined output back in as one of its own inputs + // would be circular). ctx->sPosition is already reachable here, + // so material needs no new parameter either -- see eval.c's + // uArmyScaler for the same VALUE_KING-backout convention. + NODE_TYPE eNodeTypeGuessParent = pi->eNodeType; + ULONG uTotalNonPawnMaterial = + (ctx->sPosition.uNonPawnMaterial[WHITE] - VALUE_KING) + + (ctx->sPosition.uNonPawnMaterial[BLACK] - VALUE_KING); +#endif if (NODE_PV == pi->eNodeType) { @@ -705,20 +769,43 @@ _RefineNodeTypeGuess(SEARCHER_THREAD_CONTEXT *ctx, uDepthInPlies = uDepth / ONE_PLY; if (iEval > iBeta) { +#ifdef FH_USE_REGRESSION_MODEL + uLocalFH = FhModelBetaPredictedFHThousandths( + iEval, iBeta, uDepth, iImprovement, iEnemyImprovement, + uTotalNonPawnMaterial, eNodeTypeGuessParent); +#else uLocalFH = _BetaTablePredictedFHThousandths( DepthBucket(uDepthInPlies), GapBetaBucket((SCORE)(iEval - iBeta)), uRawImproveB, ImproveBucket(iEnemyImprovement)); +#endif } else if (iEval <= iAlpha) { + // The alpha side of this refinement is a WHOLE-NODE question + // (same as beta's) -- it's asked before any move is tried, so + // it must use the model's whole-node convention too (tier + // forced to NO_MOVE, not a real per-move tier guess). The + // actual per-move alpha prediction (SearchOneMove, below) is + // a separate, later call with real move-level values. +#ifdef FH_USE_REGRESSION_MODEL + { + FHM_NODE_BASE sBase = FhModelAlphaNodeBaseQ16( + iEval, iAlpha, uDepth, iImprovement, iEnemyImprovement, + uTotalNonPawnMaterial, eNodeTypeGuessParent); + uLocalFH = FhModelNodeBaseIsMateGated(sBase) ? + MOVE_FH_ESTIMATE_NO_DATA : + FhModelAlphaMoveThousandths(sBase, 0, 0, 0, 0, FHC_TIER_NO_MOVE); + } +#else uLocalFH = _JointTablePredictedFHThousandths( DepthBucket(uDepthInPlies), 0, // hang bucket: assume nothing hung GapCpBucket((SCORE)(iAlpha - iEval)), 0, // late bucket: real whole-node sample uRawImproveB); +#endif } pi->eNodeType = g_NodeTypeGuessTable @@ -1919,6 +2006,9 @@ SearchOneMove(IN SEARCHER_THREAD_CONTEXT *ctx, IN ULONG uRawImproveB, IN BITBOARD bbMinorAttacksSnapshot[2], IN BITBOARD bbRookAttacksSnapshot[2], +#ifdef FH_USE_REGRESSION_MODEL + IN FHM_NODE_BASE sFhAlphaNodeBase, +#endif #ifdef MOVE_ORDERING_EVIDENCE_COLLECTION IN FLAG fThisMoveIsCountermoveMatch, IN ULONG uCMEvidenceBucket, @@ -1937,6 +2027,9 @@ SearchOneMove(IN SEARCHER_THREAD_CONTEXT *ctx, , OUT ULONG *puThisMoveNumBucket , OUT ULONG *puThisMoveDepthBucket #endif +#ifdef FH_REGRESSION_COLLECTION + , OUT SCORE *piMoveValueForFhCollect +#endif ) /** @@ -2027,6 +2120,24 @@ Return value: ASSERT((IS_CHECKING_MOVE(mv) && InCheck(&ctx->sPosition, ctx->sPosition.uToMove)) || (!IS_CHECKING_MOVE(mv) && !InCheck(&ctx->sPosition, ctx->sPosition.uToMove))); +#ifdef FH_REGRESSION_COLLECTION + // MUST happen here, inside this function's own Make/Unmake window + // -- EstimateHangingMaterialCp asserts the move is already applied + // on the board (DEBUG-only, but a real, silent-at-release-O3 + // correctness bug otherwise: a caller computing this AFTER + // SearchOneMove has already returned -- and un-made the move -- + // would be reading stale pre-move board state). Computed + // unconditionally for every move (not just the leftover-eligible + // population PERF_COUNTERS' own hang bucket below is restricted + // to) since every tier needs a move-value feature. The function + // itself branches internally for captures/promotions (returns + // -ComputeMoveScore(...), a SEE-plus-promotion material estimate) + // vs. quiet moves (real hang/attack-defense analysis) -- one call + // covers both, see its own routine comment. + *piMoveValueForFhCollect = EstimateHangingMaterialCp( + ctx, mv, (x - 1), bbMinorAttacksSnapshot, bbRookAttacksSnapshot); +#endif + // Compute per-move extension (as opposed to per-position // extensions which are represented by iPositionalExtend). iExtend = iPositionalExtend; @@ -2086,28 +2197,42 @@ Return value: // of about 0.4% but this is misleading -- the // population is actually bimodal, split between moves // near 0% and moves legitimately much higher. - ULONG uPredictedFH = _JointTablePredictedFHThousandths(uRawDepthB, - uRawDumbB, - uRawGapB, - uRawLateB, - uRawImproveB); + ULONG uPredictedFH; +#ifdef FH_USE_REGRESSION_MODEL + // sFhAlphaNodeBase was computed ONCE for this whole node + // (search.c, same point FhCollectNodeEntry is called) -- + // every move in this loop just adds its own move-level terms + // to that same precomputed base, rather than recomputing the + // node-level terms (gap, depth, improve, material, node type) + // on every move. FhModelNodeBaseIsMateGated mirrors the old + // table's JOINT_TABLE_SENTINEL/MOVE_FH_ESTIMATE_NO_DATA "no + // data" convention -- callers below already handle that + // value gracefully, no new branching needed there. + if (TRUE == FhModelNodeBaseIsMateGated(sFhAlphaNodeBase)) + { + uPredictedFH = MOVE_FH_ESTIMATE_NO_DATA; + } + else + { + FH_COLLECT_TIER eTier = FhModelTierForMove( + x, mv, ctx->sMoveStack.mvf[x - 1].iValue); + uPredictedFH = FhModelAlphaMoveThousandths( + sFhAlphaNodeBase, iHangCp, *puLegalMoves, + uFHPctThousandths, uFHAttempts, eTier); + } +#else + uPredictedFH = _JointTablePredictedFHThousandths(uRawDepthB, + uRawDumbB, + uRawGapB, + uRawLateB, + uRawImproveB); +#endif // JOINT_TABLE_SENTINEL and MOVE_FH_ESTIMATE_NO_DATA are the // same numeric value on purpose -- see both macros' comments. uThisMoveFHEstimate = uPredictedFH; // Late move pruning; skip searching under this move? -#ifdef FH_TABLE_COLLECTION - // Collection builds must not let LMP skip a move before it - // ever reaches the INC() calls below -- ShouldLatePrune is - // itself partly driven by uPredictedFH, i.e. the CURRENTLY - // LOADED data.c table, so leaving it live here would mean the - // old (soon-to-be-replaced) table decides which moves are - // "safe enough" to go unobserved by the new collection, - // silently censoring the population the new table is trying - // to measure. Force every eligible leftover move to actually - // be tried instead. - fThisMoveLMPPruned = FALSE; -#else +#ifndef FH_REGRESSION_COLLECTION fThisMoveLMPPruned = ShouldLatePrune( ctx, uDepth, @@ -2115,6 +2240,17 @@ Return value: uFHAttempts, uFHPctThousandths, uPredictedFH); +#else + // A collection build must not let LMP skip a move before + // FhCollectMoveTried (search.c's move loop) ever gets a chance + // to log it -- ShouldLatePrune is itself partly driven by + // uPredictedFH, i.e. the CURRENTLY LOADED (soon-to-be-replaced) + // data.c table, so leaving it live here would mean the old + // table decides which moves are "safe enough" to go + // unobserved by the new collection, silently censoring the + // population it's trying to measure. Force every eligible + // leftover move to actually be tried instead. + fThisMoveLMPPruned = FALSE; #endif // If LMP didn't fire, we are going to search below this @@ -2175,63 +2311,12 @@ Return value: [(NODE_CUT == pi->eNodeType) ? 1 : 0]); #endif -#ifdef FH_TABLE_COLLECTION - if (TRUE == fMoveIsPruneReduceEligible) - { - SCORE iHangCp; - - INC(ctx->sCounters.tree.u64EligibleLeftoverTries); - iHangCp = EstimateHangingMaterialCp( - ctx, mv, x - 1, - bbMinorAttacksSnapshot, - bbRookAttacksSnapshot); - uThisMoveHangBucket = HangCpBucket(iHangCp); - INC(ctx->sCounters.tree.u64HangEstimateTries[uThisMoveHangBucket]); - - // "Down axis": how far below alpha is this - // node's own static eval? Same eligibility - // gate as the dumb axis -- iAlpha/iEval are - // both plain node-local SCOREs (not shared, - // mutable pos-> fields like bbRookAttacks - // was), so no snapshot/staleness concern here. - uThisMoveGapBucket = GapCpBucket( - (SCORE)(iAlpha - iEval)); - INC(ctx->sCounters.tree.u64GapEstimateTries[uThisMoveGapBucket]); - - // "Late axis": this move's own 1-based ordinal - // position among moves actually tried here. - uThisMoveNumBucket = MoveNumBucket(*puLegalMoves); - INC(ctx->sCounters.tree.u64MoveNumEstimateTries[uThisMoveNumBucket]); - - // "Depth axis": remaining search depth at - // this node, before this move's own - // extension/reduction. - uThisMoveDepthBucket = DepthBucket(uDepth / ONE_PLY); - INC(ctx->sCounters.tree.u64DepthEstimateTries[uThisMoveDepthBucket]); - - // Joint 4D cell, same four bucket indices, in - // addition to (not instead of) the marginals - // above -- keeps the marginals live this run as - // a cheap cross-check that the joint reduces to - // what we already understand. - INC(ctx->sCounters.tree.u64JointTries - [uThisMoveDepthBucket][uThisMoveHangBucket] - [uThisMoveGapBucket][uThisMoveNumBucket]); - -#ifdef FH_TABLE_COLLECTION - // Full 5D alpha joint (restored for the NNUE-eval - // re-collection run, see chess.h) -- same four bucket - // indices just computed, plus the caller-supplied - // improve bucket (loop-invariant per node, computed once - // by Search() before the move loop). Independent - // "improve" marginal too, matching the convention above. - INC(ctx->sFhCollection.u64ImproveEstimateTries[uRawImproveB]); - INC(ctx->sFhCollection.u64Alpha5Tries - [uThisMoveDepthBucket][uThisMoveHangBucket] - [uThisMoveGapBucket][uThisMoveNumBucket][uRawImproveB]); -#endif - } -#endif + // FH_REGRESSION_COLLECTION needs no separate per-move "tries" + // log here, unlike the old FH_TABLE_COLLECTION design -- the + // new per-move record (tries AND outcome together) is + // emitted once, back in search.c's move loop, by + // FhCollectMoveTried once this move's real score is known; + // there's nothing for this pre-search eligibility site to log. } #endif // PERF_COUNTERS #ifdef MOVE_ORDERING_EVIDENCE_COLLECTION |
