/** Copyright (c) Scott Gasch Module Name: testnnue.c Abstract: Correctness test for the NNUE accumulator's incremental maintenance (move.c's Lift/Place/Slide(WithoutSigs) hooks, plus MakeMove/UnmakeMove's king-move backup/recompute wrapper). Proves the one property nothing else checks: that the incrementally- maintained SEARCHER_THREAD_CONTEXT.sNnueAccum[2] always equals a fresh NnueAccumRecompute from scratch, through real move sequences -- captures, promotions, en passant, castling, plain king moves, and nested nesting of all of the above two plies deep (specifically to exercise PLY_INFO.sNnueAccumBackup's per-ply indexing: two king moves at different plies, not yet unmade, must not collide). Uses a deliberately NON-zero, distinctive weight table for the duration of this test -- the real embedding table (g_rgfNnueWeight/g_rgfNnueBias) is still an all-zero placeholder (no trained weights loaded yet), and an all-zero table would let a broken feature-index formula "pass" trivially (every row sums to zero regardless of which squares/pieces are involved, so a wrong index looks identical to a right one). Restored to zero afterward so this test has no side effect on anything that runs after it in the same process. g_rgfNnueWeight/g_rgfNnueBias are SHORT (quantized) and NNUE_ACCUMULATOR.rgiValue is INT -- all integer arithmetic, so incremental-vs-recompute comparison is exact (no float-rounding tolerance needed, unlike the original float-accumulator version of this test). Author: Scott Gasch (scott.gasch@gmail.com) 15 Oct 2026 **/ #ifdef TEST #include "chess.h" static void _FillTestNnueWeights(void) /** Routine description: Overwrites g_rgfNnueWeight/g_rgfNnueBias with distinctive, deterministic (not all-equal, not all-zero) values so a wrong feature index produces a detectably different accumulator sum rather than trivially matching. **/ { ULONG i, j; for (i = 0; i < NNUE_DIMENSIONS; i++) { for (j = 0; j < NNUE_ACCUMULATOR_SIZE; j++) { g_rgfNnueWeight[i][j] = (SHORT)(((i % 97) * 10) + ((j % 13) * 1) + 100); } } for (j = 0; j < NNUE_ACCUMULATOR_SIZE; j++) { g_rgfNnueBias[j] = (SHORT)((j % 7) * 50); } } static void _ZeroTestNnueWeights(void) /** Routine description: Restores the placeholder all-zero state so this test has no lingering effect on anything run afterward in the same process. **/ { memset(g_rgfNnueWeight, 0, sizeof(g_rgfNnueWeight)); memset(g_rgfNnueBias, 0, sizeof(g_rgfNnueBias)); } static void _CompareAccumToRecompute(SEARCHER_THREAD_CONTEXT *ctx, MOVE mv, CHAR *szLabel) /** Routine description: For both colors, recomputes a fresh accumulator from scratch against ctx->sPosition and compares it element-by-element against the incrementally-maintained ctx->sNnueAccum. Panics (via UtilPanic, same convention as every other self-test) on any mismatch. Integer arithmetic throughout (SHORT table, INT accumulator) means incremental and from-scratch recompute must match EXACTLY -- no rounding tolerance needed or wanted; any nonzero delta is a real indexing/logic bug. Parameters: SEARCHER_THREAD_CONTEXT *ctx CHAR *szLabel : identifies which step of the test this is, for the panic message if one fires Return value: void **/ { NNUE_ACCUMULATOR sFresh; ULONG uColor; ULONG i; for (uColor = 0; uColor < 2; uColor++) { NnueAccumRecompute(&sFresh, &ctx->sPosition, uColor); for (i = 0; i < NNUE_ACCUMULATOR_SIZE; i++) { INT iDelta = sFresh.rgiValue[i] - ctx->sNnueAccum[uColor].rgiValue[i]; if (iDelta != 0) { Trace("TestNnueAccumulator: MISMATCH at %s, color=%lu, " "index=%lu: incremental=%ld recompute=%ld " "mv.uMove=0x%lx cFrom=0x%x cTo=0x%x pMoved=%u " "pCaptured=%u pPromoted=%u bvFlags=0x%x FEN=%s\n", szLabel, uColor, i, (long)ctx->sNnueAccum[uColor].rgiValue[i], (long)sFresh.rgiValue[i], mv.uMove, mv.cFrom, mv.cTo, mv.pMoved, mv.pCaptured, mv.pPromoted, mv.bvFlags, PositionToFen(&ctx->sPosition)); UtilPanic(TESTCASE_FAILURE, NULL, "TestNnueAccumulator", NULL, NULL, __FILE__, __LINE__); } } } } static void _PlyTestNnue(SEARCHER_THREAD_CONTEXT *ctx, ULONG uDepth) /** Routine description: Recursively tries every legal move at the current position to uDepth plies, comparing the accumulator against a fresh recompute after every MakeMove and every UnmakeMove. Two-plus plies of recursion is specifically what exercises PLY_INFO.sNnueAccumBackup's per-ply indexing (two king moves at different, not-yet-unmade plies must not collide) -- a single-ply test could not catch that class of bug. Parameters: SEARCHER_THREAD_CONTEXT *ctx ULONG uDepth : plies left to recurse Return value: void **/ { ULONG uPly = ctx->uPly; MOVE mv; ULONG u; FLAG fInCheck = InCheck(&ctx->sPosition, ctx->sPosition.uToMove); if (uDepth == 0) { return; } mv.uMove = 0; GenerateMoves(ctx, mv, (fInCheck ? GENERATE_ESCAPES : GENERATE_ALL_MOVES)); for (u = ctx->sMoveStack.uBegin[uPly]; u < ctx->sMoveStack.uEnd[uPly]; u++) { mv = ctx->sMoveStack.mvf[u].mv; if (MakeMove(ctx, mv)) { _CompareAccumToRecompute(ctx, mv, "after MakeMove"); _PlyTestNnue(ctx, uDepth - 1); UnmakeMove(ctx, mv); _CompareAccumToRecompute(ctx, mv, "after UnmakeMove"); } } } void TestNnueAccumulator(void) /** Routine description: Entry point -- see this file's module comment. Parameters: void Return value: void **/ { // Curated to reach captures, promotions, en passant, castling // (both sides, both directions), and plain king moves within the // first couple of plies -- not just quiet middlegame shuffling, // which would never exercise the king-move backup/recompute path // this test exists to check. static const char *rgszFen[] = { "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1", "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R b KQkq - 0 1", "rnbqkbnr/ppp1p1pp/8/3pPp2/8/8/PPPP1PPP/RNBQKBNR w KQkq f6 0 3", "8/PPP4k/8/8/8/8/4Kppp/8 w - - 0 1", "8/PPP4k/8/8/8/8/4Kppp/8 b - - 0 1", "4k3/8/8/8/8/8/8/4K3 w - - 0 1", }; SEARCHER_THREAD_CONTEXT *ctx; POSITION pos; ULONG u; FLAG fSavedUseNnueEval = g_fUseNnueEval; Trace("Testing NNUE accumulator incremental maintenance...\n"); _FillTestNnueWeights(); // move.c's primitives only maintain the accumulator incrementally // when this is TRUE (see the ~50% NPS regression this gating was // added to fix) -- without it, incremental maintenance silently // no-ops and every comparison below trivially mismatches the // from-scratch recompute. g_fUseNnueEval = TRUE; ctx = SystemAllocateMemory(sizeof(SEARCHER_THREAD_CONTEXT)); ASSERT(ctx); for (u = 0; u < ARRAY_LENGTH(rgszFen); u++) { if (FALSE == FenToPosition(&pos, (char *)rgszFen[u])) { UtilPanic(TESTCASE_FAILURE, NULL, "FenToPosition", NULL, NULL, __FILE__, __LINE__); } InitializeSearcherContext(&pos, ctx); // This test's own responsibility, not anything move.c does // automatically: establish the correct starting accumulator // for this fresh position before any incremental maintenance // can be trusted to keep it that way. NnueAccumRecompute(&ctx->sNnueAccum[WHITE], &ctx->sPosition, WHITE); NnueAccumRecompute(&ctx->sNnueAccum[BLACK], &ctx->sPosition, BLACK); { MOVE mvNone; mvNone.uMove = 0; _CompareAccumToRecompute(ctx, mvNone, "initial setup"); } _PlyTestNnue(ctx, 2); } SystemFreeMemory(ctx); _ZeroTestNnueWeights(); g_fUseNnueEval = fSavedUseNnueEval; Trace(" NNUE accumulator test passed.\n"); } #endif // TEST