/** Copyright (c) Scott Gasch Module Name: nnueeval.c Abstract: The FC head (2*L1 -> L2 -> L3 -> 1) that sits on top of the two accumulator halves maintained by nnueaccum.c, plus the loader for a real trained checkpoint exported by annotate_pgns/ export_nnue_weights.py. Reference-only right now -- not wired into Eval()/search, just enough to prove weights can actually be loaded and forward-propagated against a real position (see command.c's "nnue load"/"dump nnueeval"). Not hot-path (unlike nnueaccum.c's Add/Remove, called from every MakeMove) -- this only runs once per position actually evaluated, so it's written straight-line/unoptimized, same spirit as nnuefeatures.c's reference dump. Author: Scott Gasch (scott.gasch@gmail.com) 15 Oct 2026 **/ #include "chess.h" #include // g_rgfNnueWeight/g_rgfNnueBias (SHORT, quantized) and g_fNnueFtScale // are defined in nnueaccum.c. The FC head below is quantized to CHAR // (int8) per docs/NNUE.md's "Quantization plan" step 2 -- biases stay // float32 (tiny, added post-dequant). g_rgfNnueActScale/WeightScale // are read from the export file's header; defaults of 1.0f here only // matter before any real weights are loaded (the all-zero placeholder // table makes every activation 0 regardless of scale). CHAR g_rgfNnueFc1Weight[NNUE_L2][2 * NNUE_ACCUMULATOR_SIZE]; float g_rgfNnueFc1Bias[NNUE_L2]; CHAR g_rgfNnueFc2Weight[NNUE_L3][NNUE_L2]; float g_rgfNnueFc2Bias[NNUE_L3]; CHAR g_rgfNnueFc3Weight[NNUE_L3]; float g_rgfNnueFc3Bias; float g_rgfNnueActScale[3] = { 1.0f, 1.0f, 1.0f }; float g_rgfNnueWeightScale[3] = { 1.0f, 1.0f, 1.0f }; // Affine correction so the engine's consumed centipawn output tracks // Stockfish's own real score, not the net's nominal raw WDL-sigmoid // scale -- see export_nnue_weights.py's CalibrateScoreMapping and the // 2026-10-20 "keeping this stable across retrains" conversation. A // fixed K/lambda/loss-exponent training recipe is NOT enough on its // own to guarantee the search's consumed cp scale stays constant // across future retrains -- training variance (data, convergence) // still shifts the empirical raw-to-score mapping even with an // identical recipe. Re-fitting and re-baking this correction into // every export, rather than trusting a fixed multiplier, is what // actually decouples engine-side search tuning from needing the raw // net's internal scale to stay perfectly constant forever. Defaults // (0,1) are the identity transform -- the pre-2026-10-20 behavior -- // so an old build loading a newer file (or vice versa, before the // dimension check below even runs) degrades to the old bare-*100 // convention rather than silently miscalibrating. float g_fNnueCalibA = 0.0f; float g_fNnueCalibB = 1.0f; FLAG g_fNnueWeightsLoaded = FALSE; FLAG g_fUseNnueEval = FALSE; FLAG NnueLoadWeights(CHAR *szFilename) /** Routine description: Loads a weights file written by annotate_pgns/ export_nnue_weights.py: a 4-byte magic, a 4xuint32 dimension header (sanity-checked against this build's own NNUE_DIMENSIONS/ NNUE_ACCUMULATOR_SIZE/NNUE_L2/NNUE_L3 so a mismatched export can't silently misread), then the eight weight/bias arrays in a fixed order. g_fNnueWeightsLoaded is FALSE on any failure (including a partial read -- no half-loaded state is left live). Parameters: CHAR *szFilename Return value: FLAG : TRUE on success **/ { FILE *fp; CHAR szMagic[4]; ULONG rguDims[4]; g_fNnueWeightsLoaded = FALSE; fp = fopen(szFilename, "rb"); if (NULL == fp) { Trace("NnueLoadWeights: could not open %s\n", szFilename); return FALSE; } if ((1 != fread(szMagic, 4, 1, fp)) || (0 != memcmp(szMagic, "NNU4", 4))) { Trace("NnueLoadWeights: %s is not a valid NNUE weights file " "(bad magic -- expected \"NNU4\"; an older \"NNU1\"/" "\"NNU2\"/\"NNU3\" file predates the raw-to-score " "calibration fields and is no longer readable)\n", szFilename); fclose(fp); return FALSE; } if (1 != fread(rguDims, sizeof(rguDims), 1, fp)) { Trace("NnueLoadWeights: %s truncated (header)\n", szFilename); fclose(fp); return FALSE; } if ((rguDims[0] != NNUE_DIMENSIONS) || (rguDims[1] != NNUE_ACCUMULATOR_SIZE) || (rguDims[2] != NNUE_L2) || (rguDims[3] != NNUE_L3)) { Trace("NnueLoadWeights: %s dims (%lu,%lu,%lu,%lu) don't match " "this build's (%lu,%lu,%lu,%lu)\n", szFilename, rguDims[0], rguDims[1], rguDims[2], rguDims[3], (ULONG)NNUE_DIMENSIONS, (ULONG)NNUE_ACCUMULATOR_SIZE, (ULONG)NNUE_L2, (ULONG)NNUE_L3); fclose(fp); return FALSE; } if (1 != fread(&g_fNnueFtScale, sizeof(g_fNnueFtScale), 1, fp)) { Trace("NnueLoadWeights: %s truncated (ft_scale)\n", szFilename); fclose(fp); return FALSE; } if ((1 != fread(g_rgfNnueActScale, sizeof(g_rgfNnueActScale), 1, fp)) || (1 != fread(g_rgfNnueWeightScale, sizeof(g_rgfNnueWeightScale), 1, fp))) { Trace("NnueLoadWeights: %s truncated (act/weight scales)\n", szFilename); fclose(fp); return FALSE; } if ((1 != fread(&g_fNnueCalibA, sizeof(g_fNnueCalibA), 1, fp)) || (1 != fread(&g_fNnueCalibB, sizeof(g_fNnueCalibB), 1, fp))) { Trace("NnueLoadWeights: %s truncated (score calibration)\n", szFilename); fclose(fp); return FALSE; } #define READ_OR_FAIL(ptr, elsize, count) \ do { \ if ((size_t)(count) != fread((ptr), (elsize), (count), fp)) \ { \ Trace("NnueLoadWeights: %s truncated\n", szFilename); \ fclose(fp); \ return FALSE; \ } \ } while (0) READ_OR_FAIL(g_rgfNnueWeight, sizeof(SHORT), (size_t)NNUE_DIMENSIONS * NNUE_ACCUMULATOR_SIZE); READ_OR_FAIL(g_rgfNnueBias, sizeof(SHORT), NNUE_ACCUMULATOR_SIZE); READ_OR_FAIL(g_rgfNnueFc1Weight, sizeof(CHAR), (size_t)NNUE_L2 * 2 * NNUE_ACCUMULATOR_SIZE); READ_OR_FAIL(g_rgfNnueFc1Bias, sizeof(float), NNUE_L2); READ_OR_FAIL(g_rgfNnueFc2Weight, sizeof(CHAR), (size_t)NNUE_L3 * NNUE_L2); READ_OR_FAIL(g_rgfNnueFc2Bias, sizeof(float), NNUE_L3); READ_OR_FAIL(g_rgfNnueFc3Weight, sizeof(CHAR), NNUE_L3); READ_OR_FAIL(&g_rgfNnueFc3Bias, sizeof(float), 1); #undef READ_OR_FAIL fclose(fp); g_fNnueWeightsLoaded = TRUE; Trace("NnueLoadWeights: loaded %s\n", szFilename); return TRUE; } SCORE NnueForward(SEARCHER_THREAD_CONTEXT *ctx) /** Routine description: Runs the full forward pass -- ReLU(accumulator), own-perspective- first concatenation by side to move (matching nnue_model.py's own convention so the single scalar output is side-to-move-relative by construction), then the dense 2*L1 -> L2 -> L3 -> 1 FC head -- against ctx->sNnueAccum as it currently stands. Caller's responsibility to make sure that accumulator is actually correct for ctx->sPosition first (a fresh NnueAccumRecompute for both colors, if there's any doubt) -- this function trusts it as given. Parameters: SEARCHER_THREAD_CONTEXT *ctx Return value: SCORE : side-to-move-relative, in centipawns (the net's own raw output is in pawn units, same scale as the training label's score field -- see annotate_pgns/fit_k_and_label.py) **/ { POSITION *pos = &ctx->sPosition; ULONG uStm = pos->uToMove; ULONG uOther = FLIP(uStm); UCHAR rgbAct1[2 * NNUE_ACCUMULATOR_SIZE]; // quantized fc1 input UCHAR rgbAct2[NNUE_L2]; // quantized fc2 input UCHAR rgbAct3[NNUE_L3]; // quantized fc3 input float rgfL2[NNUE_L2]; float rgfL3[NNUE_L3]; float fOut; ULONG i, j; // Stage 0: dequantize the INT accumulator (scaled by // g_fNnueFtScale, see chess.h's NNUE_ACCUMULATOR comment) back to // float, apply ReLU, then requantize to UCHAR at AS1 // (g_rgfNnueActScale[0]) -- this is "fc1's input" in the Python // reference. Own-perspective-first concatenation matches the // side-to-move-relative output convention. for (i = 0; i < NNUE_ACCUMULATOR_SIZE; i++) { float v = (float)ctx->sNnueAccum[uStm].rgiValue[i] / g_fNnueFtScale; v = (v > 0.0f) ? v : 0.0f; rgbAct1[i] = (UCHAR)MAX(0, MIN(255, (int)(v * g_rgfNnueActScale[0] + 0.5f))); } for (i = 0; i < NNUE_ACCUMULATOR_SIZE; i++) { float v = (float)ctx->sNnueAccum[uOther].rgiValue[i] / g_fNnueFtScale; v = (v > 0.0f) ? v : 0.0f; rgbAct1[NNUE_ACCUMULATOR_SIZE + i] = (UCHAR)MAX(0, MIN(255, (int)(v * g_rgfNnueActScale[0] + 0.5f))); } // Stage 1: fc1, int8 dot product (UCHAR activation x CHAR weight) // accumulated in INT (32-bit) -- up to 512 terms, each at most // 255*127, nowhere near INT overflow. Dequantize by dividing out // both scales, add the (unquantized) bias, ReLU, requantize at // AS2 for fc2's input. for (j = 0; j < NNUE_L2; j++) { INT iSum = 0; for (i = 0; i < 2 * NNUE_ACCUMULATOR_SIZE; i++) { iSum += (INT)rgbAct1[i] * (INT)g_rgfNnueFc1Weight[j][i]; } float s = ((float)iSum / (g_rgfNnueActScale[0] * g_rgfNnueWeightScale[0])) + g_rgfNnueFc1Bias[j]; s = (s > 0.0f) ? s : 0.0f; rgfL2[j] = s; rgbAct2[j] = (UCHAR)MAX(0, MIN(255, (int)(s * g_rgfNnueActScale[1] + 0.5f))); } // Stage 2: fc2, same pattern. for (j = 0; j < NNUE_L3; j++) { INT iSum = 0; for (i = 0; i < NNUE_L2; i++) { iSum += (INT)rgbAct2[i] * (INT)g_rgfNnueFc2Weight[j][i]; } float s = ((float)iSum / (g_rgfNnueActScale[1] * g_rgfNnueWeightScale[1])) + g_rgfNnueFc2Bias[j]; s = (s > 0.0f) ? s : 0.0f; rgfL3[j] = s; rgbAct3[j] = (UCHAR)MAX(0, MIN(255, (int)(s * g_rgfNnueActScale[2] + 0.5f))); } // Stage 3: fc3, single output, no ReLU (final scalar score). { INT iSum = 0; for (i = 0; i < NNUE_L3; i++) { iSum += (INT)rgbAct3[i] * (INT)g_rgfNnueFc3Weight[i]; } fOut = ((float)iSum / (g_rgfNnueActScale[2] * g_rgfNnueWeightScale[2])) + g_rgfNnueFc3Bias; } return (SCORE)((g_fNnueCalibA + g_fNnueCalibB * fOut) * 100.0f); } void NnueDumpActivationStats(SEARCHER_THREAD_CONTEXT *ctx) /** Routine description: Diagnostic-only: re-runs NnueForward's same math but reports, per layer, what fraction of quantized activations are clamped to the [0,255] UCHAR rail (saturated) vs sitting strictly inside it, plus the pre-clamp float stats. Built in response to the "loses a whole queen and the eval barely moves" bug report, 2026-10-17 -- the working theory is that ordinary middlegame positions saturate most activations to the same rail regardless of material, so a single piece's worth of accumulator change never survives the clamp to reach the dense layers; only an extreme (near-decisive) material swing moves enough units across the rail to register. This function exists to confirm or refute that theory, not to fix anything -- reference/measurement-only, not wired into search. Parameters: SEARCHER_THREAD_CONTEXT *ctx Return value: void **/ { POSITION *pos = &ctx->sPosition; ULONG uStm = pos->uToMove; ULONG uOther = FLIP(uStm); UCHAR rgbAct1[2 * NNUE_ACCUMULATOR_SIZE]; UCHAR rgbAct2[NNUE_L2]; UCHAR rgbAct3[NNUE_L3]; ULONG i, j; ULONG uClampLow, uClampHigh; float fMin, fMax, fSum; // Stage 0 (fc1 input) -- same math as NnueForward, but track the // pre-clamp float range and how many of the 512 entries hit each // rail. uClampLow = uClampHigh = 0; fMin = FLT_MAX; fMax = -FLT_MAX; fSum = 0.0f; for (i = 0; i < NNUE_ACCUMULATOR_SIZE; i++) { float v = (float)ctx->sNnueAccum[uStm].rgiValue[i] / g_fNnueFtScale; v = (v > 0.0f) ? v : 0.0f; float q = v * g_rgfNnueActScale[0] + 0.5f; if (q <= 0.0f) uClampLow++; if (q >= 255.0f) uClampHigh++; if (q < fMin) fMin = q; if (q > fMax) fMax = q; fSum += q; rgbAct1[i] = (UCHAR)MAX(0, MIN(255, (int)q)); } for (i = 0; i < NNUE_ACCUMULATOR_SIZE; i++) { float v = (float)ctx->sNnueAccum[uOther].rgiValue[i] / g_fNnueFtScale; v = (v > 0.0f) ? v : 0.0f; float q = v * g_rgfNnueActScale[0] + 0.5f; if (q <= 0.0f) uClampLow++; if (q >= 255.0f) uClampHigh++; if (q < fMin) fMin = q; if (q > fMax) fMax = q; fSum += q; rgbAct1[NNUE_ACCUMULATOR_SIZE + i] = (UCHAR)MAX(0, MIN(255, (int)q)); } Trace("fc1 input (%u units): %u at 0, %u at 255, range [%.1f, %.1f], mean %.1f\n", 2 * NNUE_ACCUMULATOR_SIZE, uClampLow, uClampHigh, fMin, fMax, fSum / (float)(2 * NNUE_ACCUMULATOR_SIZE)); // Stage 1 (fc2 input) uClampLow = uClampHigh = 0; fMin = FLT_MAX; fMax = -FLT_MAX; fSum = 0.0f; for (j = 0; j < NNUE_L2; j++) { INT iSum = 0; for (i = 0; i < 2 * NNUE_ACCUMULATOR_SIZE; i++) { iSum += (INT)rgbAct1[i] * (INT)g_rgfNnueFc1Weight[j][i]; } float s = ((float)iSum / (g_rgfNnueActScale[0] * g_rgfNnueWeightScale[0])) + g_rgfNnueFc1Bias[j]; s = (s > 0.0f) ? s : 0.0f; float q = s * g_rgfNnueActScale[1] + 0.5f; if (q <= 0.0f) uClampLow++; if (q >= 255.0f) uClampHigh++; if (q < fMin) fMin = q; if (q > fMax) fMax = q; fSum += q; rgbAct2[j] = (UCHAR)MAX(0, MIN(255, (int)q)); } Trace("fc2 input (%u units): %u at 0, %u at 255, range [%.1f, %.1f], mean %.1f\n", (ULONG)NNUE_L2, uClampLow, uClampHigh, fMin, fMax, fSum / (float)NNUE_L2); // Stage 2 (fc3 input) uClampLow = uClampHigh = 0; fMin = FLT_MAX; fMax = -FLT_MAX; fSum = 0.0f; for (j = 0; j < NNUE_L3; j++) { INT iSum = 0; for (i = 0; i < NNUE_L2; i++) { iSum += (INT)rgbAct2[i] * (INT)g_rgfNnueFc2Weight[j][i]; } float s = ((float)iSum / (g_rgfNnueActScale[1] * g_rgfNnueWeightScale[1])) + g_rgfNnueFc2Bias[j]; s = (s > 0.0f) ? s : 0.0f; float q = s * g_rgfNnueActScale[2] + 0.5f; if (q <= 0.0f) uClampLow++; if (q >= 255.0f) uClampHigh++; if (q < fMin) fMin = q; if (q > fMax) fMax = q; fSum += q; rgbAct3[j] = (UCHAR)MAX(0, MIN(255, (int)q)); } Trace("fc3 input (%u units): %u at 0, %u at 255, range [%.1f, %.1f], mean %.1f\n", (ULONG)NNUE_L3, uClampLow, uClampHigh, fMin, fMax, fSum / (float)NNUE_L3); } // Calibration-only lazy-eval probe (docs/NNUE.md's "Lazy-evaluation // tiering plan"). A linear regression fit offline over fc1's own // input (512-wide: the ReLU'd, concatenated accumulator halves) -- // NOT a second trained net, just a cheap linear read of a // representation the real net already produces. Completely separate // weight file/format from NnueLoadWeights' production .nnue files; // this is measurement-only infrastructure, consulted only under // CALIBRATE_MARGIN_SAFETY (see eval.c's Eval()) and never used to // skip any real computation yet. float g_rgfNnueProbeMu[2 * NNUE_ACCUMULATOR_SIZE]; float g_rgfNnueProbeSigma[2 * NNUE_ACCUMULATOR_SIZE]; float g_rgfNnueProbeWeight[2 * NNUE_ACCUMULATOR_SIZE]; float g_fNnueProbeBias; FLAG g_fNnueProbeLoaded = FALSE; FLAG g_fUseNnueLazyProbe = FALSE; // Runtime overrides for experimenting with how loose the NNUE lazy/ // super-lazy margins can go without rebuilding -- 2026-10-16, see // docs/NNUE.md "paradox: safety doesn't win games" discussion. // g_iNnueLazyMarginOverride < 0 means "use eval.c's calibrated // per-material-bucket table as before"; >= 0 means "use this flat cp // value for every bucket instead". g_fUseNnueSuperLazy gates an even // cheaper pre-filter (pure material balance, no net at all) ahead of // the probe. g_iNnueSuperLazyMargin follows the identical convention // as of 2026-10-20 (was a flat-only override before that date, with no // per-bucket fit at all): < 0 means "use eval.c's calibrated // iNnueSuperLazyMarginByMatBucket table", >= 0 means "use this flat cp // value for every bucket instead". INT g_iNnueLazyMarginOverride = -1; // 2026-10-20: super-lazy defaults ON -- measured a clean win (nps up, // no consistent solve-rate cost) across all three curated ECM suites // at both sd 10 and sn 5000000, using the calibrated per-bucket // iNnueSuperLazyMarginByMatBucket table (eval.c), unlike the lazy // probe gate just above, which stays off by default (measured net // negative -- see its own table's comment in eval.c for the full // story). Still only takes effect when g_fUseNnueEval is also on. FLAG g_fUseNnueSuperLazy = TRUE; SCORE g_iNnueSuperLazyMargin = -1; FLAG NnueProbeLoadWeights(CHAR *szFilename) /** Routine description: Loads the lazy-eval probe's weights -- a flat file written by annotate_pgns/export_nnue_weights.py's sibling fitting script: a 4-byte magic ("NPRB"), a uint32 dimension (must equal 2*NNUE_ACCUMULATOR_SIZE), then mu/sigma/weight (each that many float32s) and a single float32 bias, in that order. Parameters: CHAR *szFilename Return value: FLAG : TRUE on success **/ { FILE *fp; CHAR szMagic[4]; ULONG uDim; g_fNnueProbeLoaded = FALSE; fp = fopen(szFilename, "rb"); if (NULL == fp) { Trace("NnueProbeLoadWeights: could not open %s\n", szFilename); return FALSE; } if ((1 != fread(szMagic, 4, 1, fp)) || (0 != memcmp(szMagic, "NPRB", 4))) { Trace("NnueProbeLoadWeights: %s is not a valid probe weights " "file (bad magic)\n", szFilename); fclose(fp); return FALSE; } if ((1 != fread(&uDim, sizeof(uDim), 1, fp)) || (uDim != 2 * NNUE_ACCUMULATOR_SIZE)) { Trace("NnueProbeLoadWeights: %s dimension %lu doesn't match " "this build's %lu\n", szFilename, uDim, (ULONG)(2 * NNUE_ACCUMULATOR_SIZE)); fclose(fp); return FALSE; } #define READ_OR_FAIL(ptr, count) \ do { \ if ((size_t)(count) != fread((ptr), sizeof(float), (count), fp)) \ { \ Trace("NnueProbeLoadWeights: %s truncated\n", szFilename); \ fclose(fp); \ return FALSE; \ } \ } while (0) READ_OR_FAIL(g_rgfNnueProbeMu, 2 * NNUE_ACCUMULATOR_SIZE); READ_OR_FAIL(g_rgfNnueProbeSigma, 2 * NNUE_ACCUMULATOR_SIZE); READ_OR_FAIL(g_rgfNnueProbeWeight, 2 * NNUE_ACCUMULATOR_SIZE); READ_OR_FAIL(&g_fNnueProbeBias, 1); #undef READ_OR_FAIL fclose(fp); g_fNnueProbeLoaded = TRUE; Trace("NnueProbeLoadWeights: loaded %s\n", szFilename); return TRUE; } SCORE NnueProbeScore(SEARCHER_THREAD_CONTEXT *ctx) /** Routine description: Cheap lazy-eval proxy: dequantizes+ReLUs the accumulator exactly like NnueForward's stage 0 (same own-perspective-first concatenation), standardizes by the probe's fitted mu/sigma, then a single 512-wide dot product -- no fc1/fc2/fc3 at all. One multiply-add per dimension plus the dequant/ReLU/standardize work, versus NnueForward's full ~17,440 MACs across three layers -- see docs/NNUE.md's latency estimate. Parameters: SEARCHER_THREAD_CONTEXT *ctx Return value: SCORE : side-to-move-relative, in centipawns, same convention as NnueForward (so the two are directly comparable) **/ { POSITION *pos = &ctx->sPosition; ULONG uStm = pos->uToMove; ULONG uOther = FLIP(uStm); float fSum; ULONG i; fSum = g_fNnueProbeBias; for (i = 0; i < NNUE_ACCUMULATOR_SIZE; i++) { float v = (float)ctx->sNnueAccum[uStm].rgiValue[i] / g_fNnueFtScale; v = (v > 0.0f) ? v : 0.0f; v = (v - g_rgfNnueProbeMu[i]) / g_rgfNnueProbeSigma[i]; fSum += v * g_rgfNnueProbeWeight[i]; } for (i = 0; i < NNUE_ACCUMULATOR_SIZE; i++) { float v = (float)ctx->sNnueAccum[uOther].rgiValue[i] / g_fNnueFtScale; v = (v > 0.0f) ? v : 0.0f; v = (v - g_rgfNnueProbeMu[NNUE_ACCUMULATOR_SIZE + i]) / g_rgfNnueProbeSigma[NNUE_ACCUMULATOR_SIZE + i]; fSum += v * g_rgfNnueProbeWeight[NNUE_ACCUMULATOR_SIZE + i]; } // fSum approximates NnueForward's pre-calibration raw fOut (it was // fit by ridge regression against exactly that target -- see // docs/NNUE.md's "Lazy-evaluation tiering plan") -- apply the same // (g_fNnueCalibA, g_fNnueCalibB) correction so this stays directly // comparable to NnueForward's actual output, per this function's // own doc comment above. return (SCORE)((g_fNnueCalibA + g_fNnueCalibB * fSum) * 100.0f); }