diff options
| author | Scott Gasch <[email protected]> | 2026-10-23 13:19:17 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-10-23 13:19:17 -0700 |
| commit | 69525eb2f42ea9cca8255f6cc3b85e6b1312fd05 (patch) | |
| tree | d78420b6c45d584a3f09b4dcbaf4f1edb36e8d5d /src/searchsup.c | |
| parent | 6b883e83935f5e8110788565cfcb77cd6fbf8950 (diff) | |
NNUE search integration: FH% tables, NODE_TYPE refinement, TREEDUMP rewrite
- data.c/chess.h: widen the joint FH% table's late axis 16->17, dedicating
the new bucket 0 to a whole-node ("will something here fail high at
all") sample rather than sharing it with the first-leftover-move curve.
Add an NNUE-eval counterpart table (g_uJointFHThousandths_NNUE) and an
NNUE counterpart of the beta-side table, selected at runtime based on
whether NNUE eval is active.
- search.c/searchsup.c: add _RefineNodeTypeGuess, which uses the FH%
tables (via g_NodeTypeGuessTable) to refine a node's NODE_TYPE guess
instead of the old heuristic; thread uParentMoveFHEstimate through
PLY_INFO so a node can see its parent's prediction for the move that
led to it.
- generate.c: initialize sKillers[2]/[3].uBonus unconditionally (an
uninitialized-stack read caught by valgrind under LTO; harmless today
but a real bug).
- dumptree.c/chess.h/GNUmakefile: replace the long-dead DUMP_TREE XML
tree dump (written for a GUI this project no longer has) with TREEDUMP,
a flat, path-keyed move/result trace meant to diff against an
identically-instrumented Stockfish build -- see docs/TREEDUMP_FORMAT.md.
- docs/DEEPER_PRUNING.md: notes on closing the search-efficiency gap vs
Stockfish.
- crafty_egtb/stockfish_xboard/typhoon_nnue: wrapper scripts so
match_play.py's Engine class can drive Crafty, Stockfish (via
PolyGlot), and an NNUE-enabled typhoon the same way it drives HCE
typhoon.
- tests/lop: rename lop.txt -> lop.ep_; update compare_lop.cpp.
- annotate_pgns: add aggregate_marginsafety.py, dedup_pgn.py,
fit_lazy_probe.py; update annotate.py/run_nnue_pipeline.py for the NNUE
training pipeline.
- .gitignore: ignore compile_commands.json (clangd build DB) and
eval.nnue (a symlink into the untracked head_reference/ comparison
baseline), neither of which belongs in the repo.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/searchsup.c')
| -rw-r--r-- | src/searchsup.c | 291 |
1 files changed, 227 insertions, 64 deletions
diff --git a/src/searchsup.c b/src/searchsup.c index eed9846..d43d387 100644 --- a/src/searchsup.c +++ b/src/searchsup.c @@ -329,8 +329,10 @@ Return value: // A king can't "hang material" in the sense this function measures // -- it's never capturable, so PIECE_VALUE(KING) (== INFINITY_SCORE) - // has no business feeding into a cp-loss estimate at all. - if (IS_KING(mv.pMoved)) + // has no business feeding into a cp-loss estimate at all. Likewise + // we don't care about "hung pawns", they are not worth enough and + // we can't tell if it's actually hung or not easily. + if (IS_KING(mv.pMoved) || IS_PAWN(mv.pMoved)) { return 0; } @@ -338,8 +340,7 @@ Return value: // Any non-pawn moved onto a square an enemy pawn attacks -- // always unconditionally bad, a pawn is the cheapest possible // attacker there is. - if ((!IS_PAWN(mv.pMoved)) && - (0 != (g_PawnAttackOriginBB[uOpponent][mv.cTo] & pos->bbPawns[uOpponent]))) + if (0 != (g_PawnAttackOriginBB[uOpponent][mv.cTo] & pos->bbPawns[uOpponent])) { return (SCORE)(PIECE_VALUE(mv.pMoved) - VALUE_PAWN); } @@ -350,15 +351,12 @@ Return value: // "attacking" our destination square means nothing by itself -- // only flag this when OUR side has zero defenders of the square. // Pawns excluded, same reasoning as the pawn-attack check above. - bbTo = COOR_TO_BB(mv.cTo); - if ((!IS_PAWN(mv.pMoved)) && - (0 != (g_KingAttacksBB[mv.cTo] & COOR_TO_BB(KingPosition(pos, uOpponent))))) + if (0 != (g_KingAttacksBB[mv.cTo] & COOR_TO_BB(KingPosition(pos, uOpponent)))) { ULONG uMover = FLIP(uOpponent); BITBOARD bbOurDefenders = _WhoAttacksSquareBB(pos, mv.cTo, uMover, pos->bbOccupied) | (g_PawnAttackOriginBB[uMover][mv.cTo] & pos->bbPawns[uMover]); - if (0 == bbOurDefenders) { return (SCORE)PIECE_VALUE(mv.pMoved); @@ -367,47 +365,32 @@ Return value: // to the minor/rook checks below. } - // Anything worth more than a minor, moved onto a square an enemy - // minor attacks. Snapshotted locally by the caller -- NOT read - // from pos->bbMinorAttacks directly, which gets clobbered by every - // recursive Eval() call made while searching an earlier sibling - // move and is never restored by UnmakeMove. - if ((PIECE_VALUE(mv.pMoved) > VALUE_KNIGHT) && - (0 != (bbMinorAttacksSnapshot[uOpponent] & bbTo))) - { - return (SCORE)(PIECE_VALUE(mv.pMoved) - VALUE_KNIGHT); - } - - // Anything worth more than a rook, moved onto a square an enemy - // rook attacks. Same snapshot reasoning as the minor check above. - if ((PIECE_VALUE(mv.pMoved) > VALUE_ROOK) && - (0 != (bbRookAttacksSnapshot[uOpponent] & bbTo))) + // If we're using NNUE eval, cTrapped, bbMinorAttacks, etc... will + // be unset. + if (FALSE == g_fUseNnueEval) { - return (SCORE)(PIECE_VALUE(mv.pMoved) - VALUE_ROOK); - } - - // 2026-10-10: mv itself looks perfectly clean, but this node already - // had one of the mover's OWN pieces flagged trapped by - // _EvalTrappedPieces at node entry (ctx->cTrapped[uPly], uPly being - // the PARENT ply here -- MakeMove already advanced ctx->uPly to the - // child, so the parent's own slot is uPly-1; see RecordTrappedPiece's - // ASSERT that the recorded piece's color always equals the mover's, - // never the opponent's). Half credit, not full: mv doesn't move that - // piece, so it isn't the thing hanging it, but it's also not doing - // anything about it either, and a position with a trapped piece - // sitting there is a real, if lesser, source of risk than this - // move's own clean bill of health suggests. Only reached for a quiet - // move (every capture/promotion already returned above) and only - // overrides the "nothing hung" verdict -- never stacks with an - // already-nonzero estimate from this same move. - COOR cOwnTrapped = ctx->cTrapped[ctx->uPly - 1]; - if (IS_ON_BOARD(cOwnTrapped) && (mv.cFrom != cOwnTrapped)) - { - PIECE pTrapped = ctx->pTrapped[ctx->uPly - 1]; + // But if we're using the HCE, consult the attack bitboards + // and trapped piece records too. + bbTo = COOR_TO_BB(mv.cTo); + if ((PIECE_VALUE(mv.pMoved) > VALUE_KNIGHT) && + (0 != (bbMinorAttacksSnapshot[uOpponent] & bbTo))) + { + return (SCORE)(PIECE_VALUE(mv.pMoved) - VALUE_KNIGHT); + } + if ((PIECE_VALUE(mv.pMoved) > VALUE_ROOK) && + (0 != (bbRookAttacksSnapshot[uOpponent] & bbTo))) + { + return (SCORE)(PIECE_VALUE(mv.pMoved) - VALUE_ROOK); + } + COOR cOwnTrapped = ctx->cTrapped[ctx->uPly - 1]; + if (IS_ON_BOARD(cOwnTrapped) && (mv.cFrom != cOwnTrapped)) + { + PIECE pTrapped = ctx->pTrapped[ctx->uPly - 1]; #ifdef DEBUG - ASSERT(GET_COLOR(pTrapped) == FLIP(uOpponent)); + ASSERT(GET_COLOR(pTrapped) == FLIP(uOpponent)); #endif - return (SCORE)(PIECE_VALUE(pTrapped) / 2); + return (SCORE)(PIECE_VALUE(pTrapped) / 2); + } } return 0; } @@ -546,18 +529,24 @@ _ShipDepthBucket(ULONG uRawDepthBucket) static ULONG _ShipLateBucket(ULONG uRawLateBucket) { - // Raw indices 0 and 1 are never produced: uMoveNum is 1-based (so 0 - // is out), and the collection/live eligibility gate itself - // (fPruneReduceEligible, search.c, requires uLegalMoves > 1) means - // the raw value handed to MoveNumBucket is never less than 2 - // either. Shift the real range (2..) down by two so the shipped - // array wastes no slot -- confirmed against the 2026-09-30 - // collection run, which showed ship_late bucket 0 permanently empty - // under the old (-1) shift. - if ((uRawLateBucket >= 2) && (uRawLateBucket <= 13)) return uRawLateBucket - 2; - if (uRawLateBucket <= 21) return 12; - if (uRawLateBucket <= 31) return 13; - return 14; + // 2026-10-21: rewritten to match dual_table_run{,_nnue}/dashboard.py's + // real, data-derived ship_late_17() exactly -- this function had + // drifted out of sync with the actual shipped shape baked into + // data.c (it still had the old 2026-09-30, pre-derive_late_buckets.py + // 15-bucket boundaries; the live table has been 16/17-bucket-shaped + // for a while). MUST stay in exact lockstep with ship_late_17() -- + // see that function's own comment for the full raw-bucket story + // (why raw==0 gets its own dedicated slot now, why raw==1 is + // permanently unreachable and harmlessly lumped with raw==2). + if (0 == uRawLateBucket) return 0; + if (uRawLateBucket <= 1) return 1; + if (uRawLateBucket <= 11) return uRawLateBucket - 1; + if (uRawLateBucket <= 13) return 11; + if (uRawLateBucket <= 15) return 12; + if (uRawLateBucket <= 18) return 13; + if (uRawLateBucket <= 21) return 14; + if (uRawLateBucket <= 31) return 15; + return 16; } #define JOINT_TABLE_SENTINEL (0xFFFF) @@ -583,6 +572,21 @@ _JointTablePredictedFHThousandths(IN ULONG uRawDepthBucket, { return JOINT_TABLE_SENTINEL; } + // 2026-10-21: two tables compiled in (HCE-collected and NNUE- + // collected, see chess.h's g_uJointFHThousandths_NNUE comment) -- + // runtime-selected, not a build-time #ifdef, since NNUE eval + // itself is already a runtime switch in this binary. Both flags + // checked (not just g_fUseNnueEval) for the same reason eval.c's + // own NNUE dispatch does: g_fUseNnueEval alone doesn't guarantee + // real weights are behind it. + if (g_fUseNnueEval && g_fNnueWeightsLoaded) + { + return g_uJointFHThousandths_NNUE[_ShipDepthBucket(uRawDepthBucket)] + [uRawDumbBucket] + [uRawGapBucket] + [_ShipLateBucket(uRawLateBucket)] + [uRawImproveBucket]; + } return g_uJointFHThousandths[_ShipDepthBucket(uRawDepthBucket)] [uRawDumbBucket] [uRawGapBucket] @@ -612,10 +616,24 @@ _BetaTablePredictedFHThousandths(IN ULONG uRawDepthBucket, IN ULONG uOwnImproveBucket, IN ULONG uEnemyImproveBucket) { - USHORT uRawGap = g_uBetaFHThousandths[_ShipDepthBucket(uRawDepthBucket)] - [uGapBetaBucket] - [uOwnImproveBucket] - [uEnemyImproveBucket]; + USHORT uRawGap; + + // Same runtime HCE/NNUE table selection as + // _JointTablePredictedFHThousandths above -- see its comment. + if (g_fUseNnueEval && g_fNnueWeightsLoaded) + { + uRawGap = g_uBetaFHThousandths_NNUE[_ShipDepthBucket(uRawDepthBucket)] + [uGapBetaBucket] + [uOwnImproveBucket] + [uEnemyImproveBucket]; + } + else + { + uRawGap = g_uBetaFHThousandths[_ShipDepthBucket(uRawDepthBucket)] + [uGapBetaBucket] + [uOwnImproveBucket] + [uEnemyImproveBucket]; + } if (JOINT_TABLE_SENTINEL == uRawGap) { return JOINT_TABLE_SENTINEL; @@ -623,6 +641,93 @@ _BetaTablePredictedFHThousandths(IN ULONG uRawDepthBucket, return (ULONG)(100000 - uRawGap); } +// Buckets a predicted-FH%-thousandths value (from either table above, +// or one of the two PLY_INFO.uParentMoveFHEstimate sentinels) into one +// of NODE_TYPE_FH_BANDS coarse bands for g_NodeTypeGuessTable's (data.c) +// lookup. MOVE_FH_ESTIMATE_NO_DATA and JOINT_TABLE_SENTINEL are the +// same numeric value (0xFFFF) on purpose -- see both macros' comments. +ULONG +FHThousandthsToBand(IN ULONG uFHThousandths) +{ + if (MOVE_FH_ESTIMATE_NONLEFTOVER == uFHThousandths) return 1; + if (MOVE_FH_ESTIMATE_NO_DATA == uFHThousandths) return 0; + if (uFHThousandths < 50000) return 2; + if (uFHThousandths < 80000) return 3; + if (uFHThousandths < 96000) return 4; + if (uFHThousandths < 99900) return 5; + return 6; +} + +// Refines CommonSearchInit's ancestor-only NODE_TYPE guess (above, +// this file) using two more signals that aren't available yet at +// CommonSearchInit time (before iEval is computed): this node's own +// predicted FH% (from whichever of the alpha/beta tables applies -- +// the same two call sites Search()'s nullmove block already consults +// them from) and the PARENT's predicted FH% for the move that led +// here (PLY_INFO.uParentMoveFHEstimate, chess.h -- written by this +// file's SearchOneMove before it recursed into this node). See +// data.c's g_NodeTypeGuessTable for how the three signals combine and +// the measurement behind it (90.7% vs today's 72.3%, on the +// scope-limited population described there). Deliberately a no-op on +// PV nodes (not a CUT/ALL question) and not applied in QSearch (whose +// own eval/stand-pat timing differs enough to warrant its own look, +// if ever extended there). +// +// Returns the local (this-node, non-PV-only) predicted FH thousandths +// it just computed -- MOVE_FH_ESTIMATE_NO_DATA if neither the beta nor +// alpha branch applied (including the PV early-return). Search()'s own +// static-nullmove/LMR-skip blocks need the exact same table lookup, +// keyed off the exact same (iEval>iBeta) / (iEval<=iAlpha) branch and +// the exact same bucket inputs -- returning it here lets the caller +// reuse it instead of redoing the same data.c table consult a second +// time per node. This is the hottest function in the engine by +// profiler; don't reintroduce the duplicate lookup. +ULONG +_RefineNodeTypeGuess(SEARCHER_THREAD_CONTEXT *ctx, + SCORE iEval, + SCORE iAlpha, + SCORE iBeta, + ULONG uDepth, + SCORE iEnemyImprovement, + ULONG uRawImproveB) +{ + PLY_INFO *pi = &ctx->sPlyInfo[ctx->uPly]; + ULONG uAncestorGuess; + ULONG uLocalFH = MOVE_FH_ESTIMATE_NO_DATA; + ULONG uDepthInPlies; + + if (NODE_PV == pi->eNodeType) + { + return(uLocalFH); + } + uAncestorGuess = (NODE_CUT == pi->eNodeType) ? 0 : 1; + uDepthInPlies = uDepth / ONE_PLY; + if (iEval > iBeta) + { + uLocalFH = _BetaTablePredictedFHThousandths( + DepthBucket(uDepthInPlies), + GapBetaBucket((SCORE)(iEval - iBeta)), + uRawImproveB, + ImproveBucket(iEnemyImprovement)); + } + else if (iEval <= iAlpha) + { + uLocalFH = _JointTablePredictedFHThousandths( + DepthBucket(uDepthInPlies), + 0, // hang bucket: assume nothing hung + GapCpBucket((SCORE)(iAlpha - iEval)), + 0, // late bucket: real whole-node sample + uRawImproveB); + } + + pi->eNodeType = g_NodeTypeGuessTable + [uAncestorGuess] + [FHThousandthsToBand(uLocalFH)] + [FHThousandthsToBand(pi->uParentMoveFHEstimate)]; + + return(uLocalFH); +} + // 2026-10-02: reverted from the DELIBERATE EXTREME EXPERIMENT values // (9000/8300/1000) back to the original p75/p25/p5-derived calibration // (72/5/1) -- that experiment was measured while the dynamic gate @@ -1734,6 +1839,20 @@ Return value: FLAG fThisMoveLMPPruned; FLAG fMoveIsPruneReduceEligible = FALSE; SCORE iScore; + // This move's own predicted FH% (thousandths), to be stashed onto + // the CHILD ply's PLY_INFO before we recurse into it -- see + // chess.h's PLY_INFO.uParentMoveFHEstimate comment. A real table + // lookup only ever happens for a leftover move that also clears + // CanWeReduceOrPruneMove's eligibility gate (just below), so: + // non-leftover (hash move, good capture, killer, etc) defaults to + // MOVE_FH_ESTIMATE_NONLEFTOVER -- deliberately a high-confidence + // band, not "unknown" (see that macro's comment) -- while a + // leftover move defaults to MOVE_FH_ESTIMATE_NO_DATA (real + // "unknown"), overwritten with an actual value below if the table + // has one. + ULONG uThisMoveFHEstimate = (TRUE == fIsLeftoverMove) ? + MOVE_FH_ESTIMATE_NO_DATA : + MOVE_FH_ESTIMATE_NONLEFTOVER; #ifdef PERF_COUNTERS ULONG uThisMoveJointExtraTier = 0; FLAG fThisMoveJointExtraEligible = FALSE; @@ -1837,6 +1956,9 @@ Return value: uRawGapB, uRawLateB, uRawImproveB); + // 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 @@ -1896,6 +2018,9 @@ Return value: #ifdef PERF_COUNTERS INC(ctx->sCounters.tree.u64LMPPruned); #endif + TDMove(ctx, uDepth, iAlpha, iBeta, + IS_CAPTURE_OR_PROMOTION(mv) ? "CAP" : "QUIET", + "LMP", 0.0, 0.0, 0, TRUE); UnmakeMove(ctx, mv); ASSERT(PositionsAreEquivalent(&ctx->sPosition, &pi->sPosition)); #ifdef PERF_COUNTERS @@ -1988,6 +2113,13 @@ Return value: pi->iExtensionAmount = iExtend; pf->iCumulativeExtend += iExtend; + // Stash this move's predicted FH% on the CHILD ply (MakeMove above + // already incremented ctx->uPly) -- see chess.h's PLY_INFO. + // uParentMoveFHEstimate comment. Must happen before the recursive + // Search() calls below, and after any point where mv could still + // change (it can't past here). + ctx->sPlyInfo[ctx->uPly].uParentMoveFHEstimate = uThisMoveFHEstimate; + // Compute the next search depth for this move/subtree. uNextDepth = uDepth - ONE_PLY + iExtend; if (uNextDepth >= MAX_DEPTH_PER_SEARCH) { @@ -2191,6 +2323,12 @@ Return value: } } #endif + TDMove(ctx, uDepth, iAlpha, iBeta, + fFullWindow ? "FIRST" : IS_CAPTURE_OR_PROMOTION(mv) ? "CAP" : "QUIET", + (iExtend < 0) ? "LMR" : (iExtend > 0) ? "EXT" : "FULL", + (iExtend < 0) ? -(double)iExtend / ONE_PLY : 0.0, + (iExtend > 0) ? (double)iExtend / ONE_PLY : 0.0, + iScore, FALSE); UnmakeMove(ctx, mv); pf->iCumulativeExtend -= iExtend; ASSERT(PositionsAreEquivalent(&ctx->sPosition, &pi->sPosition)); @@ -2302,6 +2440,15 @@ Return value: ctx->sMoveStack.mvf[x].mv = mv; // <-- update the move's flags, WouldGiveCheck is slow if (TRUE == MakeMove(ctx, mv)) { + // This loop recurses into Search() directly, not through + // SearchOneMove (which stashes this on every OTHER + // recursion -- see chess.h's PLY_INFO.uParentMoveFHEstimate + // comment). An IID rescore pass has no real leftover-move + // table lookup to report here -- MOVE_FH_ESTIMATE_NO_DATA + // ("unknown"), not MOVE_FH_ESTIMATE_NONLEFTOVER (which + // would falsely claim high confidence). + ctx->sPlyInfo[ctx->uPly].uParentMoveFHEstimate = + MOVE_FH_ESTIMATE_NO_DATA; if (-INFINITY_SCORE == iBestScore) { iScore = -Search(ctx, -iBeta, -iAlpha, uDepth); @@ -2670,7 +2817,6 @@ Return value: // describe structurally unreachable from this site, not just // defended against. UpdatePV(ctx, DRAWMOVE); - DTTRACE("<i bold=\"draw\"/>"); goto end; } @@ -2730,7 +2876,6 @@ Return value: // mechanism and the move.c:881 assert it produces). pi->mvBest.uMove = 0; pi->PV[ctx->uPly].uMove = 0; - DTTRACE("<toodeep/>"); goto end; } @@ -2993,12 +3138,28 @@ TryNullmovePruning(SEARCHER_THREAD_CONTEXT *ctx, ASSERT(pos->uToMove != pi->sPosition.uToMove); INC(ctx->sCounters.tree.u64NullMoves); + // This function recurses into Search() directly, not through this + // file's own SearchOneMove (which stashes this on every OTHER + // recursion -- see chess.h's PLY_INFO.uParentMoveFHEstimate + // comment). A null move has no per-move FH% table lookup to + // report -- MOVE_FH_ESTIMATE_NO_DATA ("unknown"), not + // MOVE_FH_ESTIMATE_NONLEFTOVER (which would falsely claim high + // confidence). Without this, the child ply's slot reads whatever + // was last left there -- caught by valgrind as a real + // uninitialised-value read inside search.c's _RefineNodeTypeGuess. + ctx->sPlyInfo[ctx->uPly].uParentMoveFHEstimate = MOVE_FH_ESTIMATE_NO_DATA; + // (Reduced depth) search under the nullmove; don't allow two // nullmoves in a row. ASSERT(pf->fAvoidNullmove == FALSE); pf->fAvoidNullmove = TRUE; *piNullScore = -Search(ctx, -iBeta, -iBeta + 1, uNullDepth); pf->fAvoidNullmove = FALSE; + // depth=uNullDepth here (the already-reduced depth passed to the + // probe), not the pre-reduction parent depth, which this function + // never receives -- r is left 0.0 rather than faked. + TDMove(ctx, uNullDepth, iAlpha, iBeta, "NULL", "NMP", 0.0, 0.0, + *piNullScore, FALSE); // Check the results if (*piNullScore >= iBeta) @@ -3108,6 +3269,8 @@ TryNullmovePruning(SEARCHER_THREAD_CONTEXT *ctx, if ((mv.cFrom == (pi-1)->mv.cTo) && ((pi-1)->iExtensionAmount < 0)) { ASSERT(GET_COLOR(mv.pMoved) == GET_COLOR((pi-1)->mv.pMoved)); + TDMove(ctx, uNullDepth, iAlpha, iBeta, "NULL", "NULLBAILOUT", + 0.0, 0.0, iAlpha - 1, FALSE); UnmakeMove(ctx, NULLMOVE); ASSERT(PositionsAreEquivalent(pos, &pi->sPosition)); *piNullScore = iAlpha - 1; |
