summaryrefslogtreecommitdiff
path: root/src/search.c
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-20 09:26:44 -0700
committerScott Gasch <[email protected]>2026-10-20 09:26:44 -0700
commitc8c6bbc2c1a103e5b07b079a0f8bdf66c7724d80 (patch)
treeb6c2137c9ac577fa83b1e948fa0311d53dfe445d /src/search.c
parent3b5d3677bb14ae14728a1c4b221176bb83667d92 (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.c162
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
}