diff options
| author | Scott Gasch <[email protected]> | 2026-10-20 09:26:44 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-10-20 09:26:44 -0700 |
| commit | c8c6bbc2c1a103e5b07b079a0f8bdf66c7724d80 (patch) | |
| tree | b6c2137c9ac577fa83b1e948fa0311d53dfe445d /src/search.c | |
| parent | 3b5d3677bb14ae14728a1c4b221176bb83667d92 (diff) | |
Wire NNUE eval into search, build the training pipeline, and add a fast
move/eval-agreement benchmark against Stockfish (tests/lop)
Implements the architecture decisions in src/docs/NNUE.md: HalfKAv2_hm
feature set, int16/int8 quantization, per-ply accumulator backup for
king moves, and a lazy-eval-ready integration into search.c/eval.c.
Adds the full annotate_pgns/ training pipeline (extraction, K-fit/
labeling, quiet filtering, tensor building, training, export) plus an
end-to-end orchestrator (run_nnue_pipeline.py) and validation tooling
(sanity_check_net.py, compare_vs_stockfish_net.py). Adds tests/lop:
a 10k-position list-of-positions benchmark comparing engine eval rank
and move choice against pre-computed Stockfish/lc0 references, much
cheaper than a full self-play match for day-to-day progress checks.
First sd10-matched self-play result against classic HCE: LLR +2.28
(H1=+2.94), -72=119+169 over 360 games, confirming the net is
"smarter per node" at matched depth. A real-time (--st 4) match is
currently weaker (candidate loses ~3 plies of depth to a ~3.3-3.7x
nps gap against an unrecalibrated search), consistent with
NNUE.md's adoption-criterion section -- pruning/reduction constants
still need retuning for the new eval's score scale before a fair
real-time comparison is possible.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/search.c')
| -rw-r--r-- | src/search.c | 162 |
1 files changed, 148 insertions, 14 deletions
diff --git a/src/search.c b/src/search.c index 8404d9d..7d8d686 100644 --- a/src/search.c +++ b/src/search.c @@ -66,6 +66,7 @@ Revision History: **/ #include "chess.h" +#include <math.h> // DumpQSearchFutilityCalibration's sqrt extern ULONG g_uIterateDepth; extern FLAG g_fCanSplit[MAX_PLY_PER_SEARCH]; @@ -713,7 +714,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, uRawImproveB, uRawEnemyImproveB); if ((uBetaPredictedFHThousandths != 0xFFFF) && - (uBetaPredictedFHThousandths > 95000)) + (uBetaPredictedFHThousandths > 96000)) { INC(ctx->sCounters.tree.u64QuickNullMoveSuccess); @@ -1371,6 +1372,30 @@ Return value: static UINT64 g_uQFutTries[QFUT_GATE_COUNT][QFUT_DIST_BUCKETS][QFUT_POS_BUCKETS][EVAL_POSITIONAL_SOURCE_COUNT]; static UINT64 g_uQFutSurprises[QFUT_GATE_COUNT][QFUT_DIST_BUCKETS][QFUT_POS_BUCKETS][EVAL_POSITIONAL_SOURCE_COUNT]; +// 2026-10-16 diagnostic (see the raw-pre-clamp iFutility capture +// above): is MAX0 clamping iFutility to 0 for most/all of a given +// source's population, making FUTILITY_BASE_MARGIN_BY_SOURCE's exact +// value irrelevant for that source regardless of what it's set to? +static UINT64 g_uQFutRawPreClampCount[EVAL_POSITIONAL_SOURCE_COUNT]; +static UINT64 g_uQFutRawPreClampNonPositive[EVAL_POSITIONAL_SOURCE_COUNT]; +static INT64 g_uQFutRawPreClampSum[EVAL_POSITIONAL_SOURCE_COUNT]; + +// 2026-10-16: mean/stdev/max of the actual surprise MAGNITUDE +// (iScore - iAlpha, when a rejected move's diagnostic re-search +// raised alpha after all) by source -- distinguishes "surprises are +// consistently small (eval noise near the boundary)" from +// "occasional huge swings (real missed tactics, e.g. a mate the +// shallow SEE-based rejection couldn't see)". Not clamped -- a real +// mate score legitimately belongs in this population, not an outlier +// to exclude. +static UINT64 g_uQFutSurpriseMagCount[EVAL_POSITIONAL_SOURCE_COUNT]; +static INT64 g_uQFutSurpriseMagSum[EVAL_POSITIONAL_SOURCE_COUNT]; +static double g_dQFutSurpriseMagSumSq[EVAL_POSITIONAL_SOURCE_COUNT]; +static INT64 g_uQFutSurpriseMagMax[EVAL_POSITIONAL_SOURCE_COUNT]; +// Cheap shape check without a full histogram: how many surprises are +// "clearly a real tactic" (>100cp) vs "plausibly just noise" (<=100cp). +static UINT64 g_uQFutSurpriseMagOver100[EVAL_POSITIONAL_SOURCE_COUNT]; + static ULONG _QFutDistanceBucket(IN SCORE iDistance) /* iDistance: how far short of the relevant threshold this move was @@ -1397,6 +1422,21 @@ _QFutPositionalBucket(IN SCORE iPositional) return(3); } +static CHAR *g_szQFutGateNames[QFUT_GATE_COUNT] = +{ + "generic capture/promo", + "checking capture/promo (VALUE_ROOK)", + "quiet check (VALUE_BISHOP)", +}; +static CHAR *g_szQFutSourceNames[EVAL_POSITIONAL_SOURCE_COUNT] = +{ + "full eval", + "regular lazy", + "super lazy", + "nnue full", + "nnue lazy probe", +}; + static void _QFutDiagnoseReject(IN SEARCHER_THREAD_CONTEXT *ctx, IN ULONG uMoveNum, @@ -1466,24 +1506,54 @@ Return value: g_uQFutTries[uGate][uDistBucket][uPosBucket][uPositionalSource]++; if (iScore > iAlpha) { + SCORE iMagnitude; g_uQFutSurprises[uGate][uDistBucket][uPosBucket][uPositionalSource]++; + + iMagnitude = (SCORE)(iScore - iAlpha); + g_uQFutSurpriseMagCount[uPositionalSource]++; + g_uQFutSurpriseMagSum[uPositionalSource] += (INT64)iMagnitude; + g_dQFutSurpriseMagSumSq[uPositionalSource] += + (double)iMagnitude * (double)iMagnitude; + if ((INT64)iMagnitude > g_uQFutSurpriseMagMax[uPositionalSource]) + { + g_uQFutSurpriseMagMax[uPositionalSource] = (INT64)iMagnitude; + } + if (iMagnitude > 100) + { + g_uQFutSurpriseMagOver100[uPositionalSource]++; + } + + // 2026-10-16: a few concrete real examples, capped, so a + // surprise-rate gap between sources (e.g. nnue full's 7.41% + // vs classic full's ~1.91% in the same gate) can be looked at + // directly instead of only as an aggregate percentage. Only + // samples the two NNUE sources -- classic's own numbers are + // already understood from the original derivation. + if ((uPositionalSource == EVAL_POSITIONAL_SOURCE_NNUE_FULL) || + (uPositionalSource == EVAL_POSITIONAL_SOURCE_NNUE_LAZY)) + { + static ULONG uSampleCount[EVAL_POSITIONAL_SOURCE_COUNT]; + if (uSampleCount[uPositionalSource] < 10) + { + CHAR *pszFen = PositionToFen(&ctx->sPosition); + if (NULL != pszFen) + { + uSampleCount[uPositionalSource]++; + Trace("QFUT_SURPRISE_SAMPLE source=%s gate=%s " + "move=0x%lx iAlpha=%d iScore=%d iDistance=%d " + "fen=%s\n", + g_szQFutSourceNames[uPositionalSource], + g_szQFutGateNames[uGate], + mv.uMove, (int)iAlpha, (int)iScore, (int)iDistance, + pszFen); + SystemFreeMemory(pszFen); + } + } + } } } -static CHAR *g_szQFutGateNames[QFUT_GATE_COUNT] = -{ - "generic capture/promo", - "checking capture/promo (VALUE_ROOK)", - "quiet check (VALUE_BISHOP)", -}; -static CHAR *g_szQFutSourceNames[EVAL_POSITIONAL_SOURCE_COUNT] = -{ - "full eval", - "regular lazy", - "super lazy", -}; - void DumpQSearchFutilityCalibration(void) /** @@ -1612,6 +1682,51 @@ Return value: } } } + + Trace("\nSurprise magnitude (iScore - iAlpha, when a rejected move's " + "diagnostic re-search raised alpha) by source:\n"); + for (s = 0; s < EVAL_POSITIONAL_SOURCE_COUNT; s++) + { + UINT64 u64Count = g_uQFutSurpriseMagCount[s]; + double dMean, dVariance, dStdev; + if (0 == u64Count) + { + Trace(" %s: no surprises\n", g_szQFutSourceNames[s]); + continue; + } + dMean = (double)g_uQFutSurpriseMagSum[s] / (double)u64Count; + dVariance = (g_dQFutSurpriseMagSumSq[s] / (double)u64Count) - + (dMean * dMean); + if (dVariance < 0.0) + { + dVariance = 0.0; + } + dStdev = sqrt(dVariance); + Trace(" %-16s: n=%8" COMPILER_LONGLONG_UNSIGNED_FORMAT + " mean=%7.1f stdev=%7.1f max=%6" COMPILER_LONGLONG_UNSIGNED_FORMAT + " >100cp=%5.2f%%\n", + g_szQFutSourceNames[s], u64Count, dMean, dStdev, + (UINT64)g_uQFutSurpriseMagMax[s], + 100.0 * (double)g_uQFutSurpriseMagOver100[s] / (double)u64Count); + } + + Trace("\nRaw pre-clamp iFutility by source (is MAX0 saturating this " + "source's population, making the margin constant moot?):\n"); + for (s = 0; s < EVAL_POSITIONAL_SOURCE_COUNT; s++) + { + UINT64 u64Count = g_uQFutRawPreClampCount[s]; + if (0 == u64Count) + { + Trace(" %s: no samples\n", g_szQFutSourceNames[s]); + continue; + } + Trace(" %-16s: n=%10" COMPILER_LONGLONG_UNSIGNED_FORMAT + " mean=%7.1f clamped(<=0)=%5.2f%%\n", + g_szQFutSourceNames[s], u64Count, + (double)g_uQFutRawPreClampSum[s] / (double)u64Count, + 100.0 * (double)g_uQFutRawPreClampNonPositive[s] / + (double)u64Count); + } } #endif // CALIBRATE_QSEARCH_FUTILITY @@ -1891,6 +2006,8 @@ static const SCORE FUTILITY_BASE_MARGIN_BY_SOURCE[EVAL_POSITIONAL_SOURCE_COUNT] FUTILITY_BASE_MARGIN_FULL, // EVAL_POSITIONAL_SOURCE_FULL FUTILITY_BASE_MARGIN_LAZY, // EVAL_POSITIONAL_SOURCE_LAZY FUTILITY_BASE_MARGIN_SUPERLAZY, // EVAL_POSITIONAL_SOURCE_SUPERLAZY + FUTILITY_BASE_MARGIN_NNUE_FULL, // EVAL_POSITIONAL_SOURCE_NNUE_FULL + FUTILITY_BASE_MARGIN_NNUE_LAZY, // EVAL_POSITIONAL_SOURCE_NNUE_LAZY }; @@ -2292,6 +2409,23 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx, iFutility = iAlpha - (FUTILITY_BASE_MARGIN_BY_SOURCE[pi->uLastPositionalSource] + rgiPositional[pos->uToMove]) - iEval; +#ifdef CALIBRATE_QSEARCH_FUTILITY + // 2026-10-16 diagnostic: is iFutility actually being clamped + // to 0 by MAX0 below for this source, making the margin + // constant irrelevant regardless of its value? See the + // investigation into NNUE_FULL's margin change having zero + // measured effect. + { + ULONG uSrc = pi->uLastPositionalSource; + ASSERT(uSrc < EVAL_POSITIONAL_SOURCE_COUNT); + g_uQFutRawPreClampCount[uSrc]++; + g_uQFutRawPreClampSum[uSrc] += (INT64)iFutility; + if (iFutility <= 0) + { + g_uQFutRawPreClampNonPositive[uSrc]++; + } + } +#endif iFutility = MAX0(iFutility); #endif } |
