summaryrefslogtreecommitdiff
path: root/src/docs/NNUE.md
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-15 13:11:22 -0700
committerScott Gasch <[email protected]>2026-10-15 13:11:22 -0700
commit993b6eeb2a9ac7b659c473b6883fa53af1ad3a0b (patch)
treec5388824dec15a7691ce9952a6eb4af66e837127 /src/docs/NNUE.md
parent53b380c658f828c4c0e096be2763e6b012c23abd (diff)
Phase A NNUE: feature-indexing spec, annotation/training pipeline, dump nnuefeatures
Builds out the NNUE Phase A prototype per docs/NNUE.md: - src/nnuefeatures.c: standalone HalfKAv2_hm feature-index reference implementation (new `dump nnuefeatures` command), sourced from Stockfish's half_ka_v2_hm.{h,cpp} (sf_16 tag), including the 0x88->64 square conversion typhoon's own board representation needs. - annotate_pgns/: sharded TWIC/self-play annotation harness (annotate.py), position extraction, quiet-position filtering (flat and game-shaped, plus an upstream --prefilter-quiet option), train/holdout game-level stratified split, WDL-sigmoid K fitting, the Python HalfKAv2_hm reference encoder (nnue_feature_spec.py) and its bit-for-bit cross-checker against the C side (cross_check_features.py), and a first PyTorch training pipeline (nnue_model.py, nnue_train.py, build_training_tensors.py) -- a 256x2-32-32-1-ish net trained locally on CPU against ~1.85M quiet TWIC positions as a pipeline/framework validation pass. - Finish an in-progress InitMagic/InitLMRTable/InitEval -> InitializeBitboardMagic/InitializeLMRTable/InitializeEval rename: chess.h's prototypes hadn't been updated to match the already-renamed definitions/call sites, leaving 4 implicit-function-declaration warnings. - tests/arasan.epd: new curated EPD suite. - docs/: add the project's design docs (previously untracked). Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/docs/NNUE.md')
-rw-r--r--src/docs/NNUE.md1153
1 files changed, 1153 insertions, 0 deletions
diff --git a/src/docs/NNUE.md b/src/docs/NNUE.md
new file mode 100644
index 0000000..0ab46ec
--- /dev/null
+++ b/src/docs/NNUE.md
@@ -0,0 +1,1153 @@
+# Replacing Eval() with an NNUE evaluator
+
+Status: planning / discussion only. No code written yet. This document
+records the decisions made so far and the open items that still need
+resolving before implementation starts. See `MOVE_GUESSER.md` for the
+related-but-separate move-prediction ("policy") model idea, which is
+tracked independently and is not a dependency of this work.
+
+## Why
+
+Current `Eval()` is hand-tuned (see `eval_tune/`'s DNA tuning pipeline,
+622/879 ECM after a first tuning pass vs 606/879 baseline). NNUE-style
+learned evaluation is the next large step up in evaluation quality,
+at the cost of a meaningfully larger implementation + retraining project.
+
+## Architecture decisions made so far
+
+- **Feature set: HalfKAv2 (with the `_hm` horizontal-mirroring variant as
+ the likely final choice)**, not HalfKP and not a custom-invented
+ encoding. HalfKA includes the kings themselves as features (unlike
+ HalfKP, which excludes them), which captures king-vs-king spatial
+ relationships -- material for endgame/mating-net precision. `_hm`
+ roughly halves parameter count by exploiting left-right board symmetry.
+ Chose a known, documented format over inventing one specifically so
+ existing reference trainers/tooling (e.g. nnue-pytorch) and existing
+ write-ups are usable as a sanity check, rather than debugging both a
+ novel architecture and a novel implementation simultaneously.
+- **King bucketing: use HalfKAv2_hm's standard bucketing scheme as-is**,
+ not a custom bucket count. (Background: bucketing groups king squares
+ so sub-tables of weights are shared across squares in the same bucket
+ -- fewer buckets means less king-position nuance but faster training
+ convergence per bucket and a smaller table; more buckets is the
+ opposite tradeoff. Not worth re-deriving when a settled default
+ exists.)
+- **Net topology: start from a known, previously-validated small
+ architecture** (the kind of 256x2-32-32-1-ish shape used in early
+ nnue-pytorch default configs) rather than choosing dimensions from
+ scratch. Revisit sizing only after a first working net exists.
+- **Output convention: single scalar, side-to-move-relative**, matching
+ current `Eval()`'s convention. Achieved the standard way: two
+ king-relative accumulator halves (one per color's perspective) are
+ concatenated in an order determined by side-to-move (own-perspective
+ half first) before the hidden layers, so the net's one scalar output
+ is side-to-move-relative by construction. Explicitly rejected a
+ two-output (white-score, black-score) design -- that reintroduces a
+ combination-rule-design problem the standard trick already avoids,
+ and we already committed to following known designs over inventing
+ our own.
+- **SIMD target: AVX2 + FMA.** Confirmed on the actual build box (AMD
+ Ryzen 9 3900X, FreeBSD, clang 19.1.7): `clang -march=native -dM -E -`
+ reports `__AVX2__`, `__FMA__`, `__SSE4_1__`/`__SSE4_2__` defined, no
+ AVX512 (Zen 2 doesn't have it). Quantization/SIMD inference code
+ should target AVX2 as the baseline, not assume AVX512 availability.
+- **Quantization: int16 for the (sparse) input/accumulator layer, int8
+ for the dense hidden layers**, matching why Stockfish's own
+ implementation splits it this way -- the input layer's cost is
+ dominated by sparse incremental updates (only ~a handful of features
+ change per move), where int16's SIMD width cost is small, while the
+ dense later layers benefit more from int8's 2x SIMD lane packing.
+ Narrower quantization = faster but lossier; this split is the
+ standard compromise. Needs a quantization-aware scaling pass per
+ layer during export, not just truncate-and-ship.
+- **Lazy evaluation / fast first-pass filter: reuse a cheap linear
+ subcomponent of the SAME trained net (its PSQT/embedding-sum term,
+ no hidden-layer nonlinearity), not a second, separately-trained
+ smaller net.** Rationale: a separately-trained small net would need
+ its own calibration against alpha/beta margins, and that calibration
+ would almost certainly disagree with the full net's (nonlinear,
+ WDL-sigmoid-derived) output scale -- exactly the problem raised about
+ mixing material-cp lazy skips with a WDL-calibrated net output (see
+ below). A cheap *partial evaluation* of the one trained net lives in
+ the same output space as the full net by construction, so there is
+ no second calibration curve to reconcile. Only fall through to the
+ full forward pass (hidden layers) when the cheap linear term is
+ inconclusive relative to alpha/beta.
+ - Open experiment, explicitly not decided against: a super-lazy
+ *material-only* pre-filter ahead of even the cheap linear net
+ term, purely for search speed. Known risk: current `Eval()`'s
+ super-lazy/lazy/full three-tier system works because all three
+ tiers share units (centipawns, roughly linearly related to
+ material). A material-only prefilter ahead of an NNUE net does
+ NOT have that guarantee once the net's output has gone through
+ WDL-sigmoid calibration -- a position could look "way above beta"
+ in raw material and still not clearly fail high in the net's
+ (compressed, nonlinear) output space. Worth trying, worth
+ measuring carefully (fail-high-rate-at-the-margin style
+ instrumentation, not just aggregate solve counts -- see
+ CLAUDE.md's "Dynamic move ordering experiments" methodology),
+ explicitly allowed to fail and be reverted.
+- **Training data score space: train against win/draw/loss outcomes via
+ a sigmoid-squashed WDL-style target**, not raw centipawn regression,
+ extending the same sigmoid-squared-error approach `tune_eval_dna.py`
+ already uses. Rationale: raw centipawn MSE misweights the loss (a
+ +500-vs-+700 error matters far less to the actual game outcome than a
+ +20-vs-+80 error, but unweighted centipawn MSE penalizes them
+ similarly) -- WDL-style targets are naturally calibrated to what
+ actually matters (winning the game) and match the training signal
+ already available from game outcomes. Requires a calibrated
+ inverse-sigmoid conversion back to centipawns for use inside
+ alpha-beta search (search needs additive, mate-aware scores, not raw
+ probabilities) -- that conversion constant needs its own calibration
+ pass and is a likely source of subtle bugs if done carelessly.
+- **Exact scores (mate distance, EGTB/recognizer hits) are never run
+ through the WDL-sigmoid conversion, in either direction.** These are
+ game-theoretically exact, not noisy evidence to fit a sigmoid against.
+ The net itself never needs to learn "is this a mate" -- that detection
+ stays entirely in the search/TB-probe layer exactly as it does today;
+ the net is only ever consulted at ordinary, non-exact nodes. This is a
+ training-data-labeling concern (don't mix exact-value positions into
+ the sigmoid-fit loss, or into material-lazy-skip calibration), not an
+ architecture concern.
+ - **Resolved**: `<TT>`/`<EGTB>`/`<REC>` are sentinel pseudo-moves
+ (`HASHMOVE`, `EGTBMOVE`, `RECOGNMOVE` in `chess.h`) inserted into
+ the PV by `UpdatePV()` (called from `search.c` at the hash-hit,
+ recognizer, and EGTB-probe sites) to record provenance of the line
+ that follows. Distinction that matters for training labels:
+ **`<EGTB>`/`<REC>` are terminal and exact** -- the node's value
+ literally *is* that recognizer/tablebase result, so a position
+ whose PV ends in one of these tags should have its score excluded
+ from the sigmoid-fit WDL loss (treated as ground truth, not noisy
+ evidence). **`<TT>` is NOT an exactness marker** -- it means a
+ hash hit occurred and the remainder of the printed PV was
+ reconstructed by walking the hash table's stored continuation,
+ which can be a fail-high/fail-low *bound*, not necessarily an
+ exact score. A `<TT>` appearing anywhere in a PV should be treated
+ as an ordinary search score for labeling purposes, not specially
+ excluded.
+
+## Accumulator maintenance design
+
+- The NNUE input state lives as a **persistent "current" blob per
+ thread** (analogous to `POSITION`'s always-current board state), not a
+ recomputed-per-call structure, stored in `SEARCHER_THREAD_CONTEXT` so
+ each `MP=1` helper thread has its own copy.
+- **Normal (non-king) moves**: MakeMove incrementally adds the changed
+ feature deltas into the live accumulator; UnmakeMove incrementally
+ subtracts the same deltas back out. This is correct without any extra
+ bookkeeping because search's Make/Unmake calls are already properly
+ nested (a recursive call stack) -- UnmakeMove always undoes exactly the
+ delta the matching MakeMove applied.
+- **King moves**: every feature in HalfKA is king-relative, so a king
+ move invalidates the entire accumulator, not just a delta. Handling:
+ **a per-ply backup**, stored in the existing `PLY_INFO sPlyInfo[MAX_PLY_PER_SEARCH+1]`
+ array already present in `SEARCHER_THREAD_CONTEXT` (`chess.h`) --
+ add a field there rather than a new parallel array, following the
+ same pattern as `PLY_INFO`'s existing sig-stamped, conditionally-valid
+ fields (`u64FullEvalSig`, `iKingScore`).
+ - On a king MakeMove: memcpy live accumulator -> `sPlyInfo[uPly]`'s
+ backup slot, then recompute live from scratch.
+ - On that same king move's UnmakeMove: memcpy the backup slot back
+ into live (cheaper than recomputing from scratch -- a sequential
+ copy of a ~1-2KB blob vs. ~30 per-piece weight-table lookups/adds
+ across both perspectives).
+ - **Must be indexed by ply, not a single shared backup slot** -- king
+ moves can nest (either side's king can move more than once within
+ a single search line before any of those moves is unmade), and a
+ single shared slot would silently get overwritten by a second,
+ deeper king move before the first one's UnmakeMove runs, producing
+ a wrong-but-undetected restore with no assertion to catch it. Per-
+ ply indexing (bounded by `MAX_PLY_PER_SEARCH`, in practice far
+ fewer slots actually used) avoids this by construction.
+- **Null move**: moves no piece, so it is a true no-op for the
+ accumulator -- confirm this explicitly in the implementation rather
+ than assuming it falls out automatically.
+- **Audit required before implementation**: confirm nothing in the
+ current search bypasses paired MakeMove/UnmakeMove to mutate position
+ state directly (e.g. any bulk board-state copy used by aspiration
+ re-search, singular extension, or the `MP=1` split-search machinery --
+ see the recent `SearchOneMove`/`SPLIT_INFO` refactor). Any such path
+ would desync the accumulator silently and is exactly the kind of bug
+ that would not show up until much later.
+- **Performance follow-up, not required for correctness**: eager
+ accumulator maintenance on every MakeMove wastes work at nodes that
+ get pruned by a cheap lazy filter before ever consulting the net (see
+ the super-lazy-prefilter experiment above). A lazy/deferred
+ accumulator scheme (record "what moved" cheaply per ply, only replay
+ deltas forward from the nearest materialized ancestor when a node
+ actually needs a net evaluation) is the standard answer if profiling
+ shows this matters. Defer until there's a working eager version to
+ measure against.
+
+## Breaking external dependencies on Eval()'s internal signals
+
+`cTrapped`/`cEnprise` (and any other signal currently read outside
+`Eval()` by move ordering -- hung-piece-escape logic, `FLEE_BONUS`, the
+countermove-table evidence gating from the 2026-08-29 move-ordering
+session) must be decoupled from `Eval()`'s computation entirely before
+`Eval()` can be replaced, since NNUE has no equivalent intermediate
+signals to expose.
+
+- **Enumeration done, 2026-10-15** (grepped `cTrapped`/`pTrapped`/
+ `cEnprise`/`pEnprise` across every live `.c`/`.h` file -- `save/` and
+ `changes/` hold old snapshots, not live code, and were excluded; then
+ verified consumers by call site, not just by grep match, since one
+ memorable-sounding candidate turned out to be dead):
+ - **`FLEE_BONUS` (the 2026-08-29 session's leftover-move en-prise
+ nudge, `generate.c:4673`'s doc comment) is dead code, not a live
+ consumer** -- `chess.h:2949` says so explicitly ("separately dead
+ code in movesup.c's SelectBestWithHistory (commented out) --
+ retired after the GOOD_MOVE-tier version it replaced was shown...
+ to perform at the plain-leftover baseline"); confirmed in
+ `movesup.c`, only a commented-out declaration remains
+ (`movesup.c:1439`). Likewise, `search.c:184-194`'s null-move-
+ calibration-harness comment confirms en-prise/trapped state *was*
+ tried as an axis of the joint fail-high prediction table in an
+ earlier (2026-09-24) revision, found "not a safety signal at all"
+ for that purpose, and explicitly is **not** bucketed in the
+ current revision. Neither of these is a real dependency today.
+ - **Consumers that ARE live** (read-only, via `dynamic.c`'s accessor
+ functions -- none need to change regardless of who produces the
+ data):
+ 1. `generate.c:5342` (QSearch-specific move ordering, a different
+ function from the dead `FLEE_BONUS` one): nudges a move whose
+ origin square is the cached `cEnprise` hint, applied only on
+ top of a move SEE has already classified as winning/even --
+ SEE runs first, the hint is a secondary nudge.
+ 2. **`EstimateHangingMaterialCp`** (`searchsup.c:246-413`, called
+ from `searchsup.c:1826`/`1905`) -- feeds the "dumb move" axis
+ of the live joint fail-high prediction table (the LMR/reduction
+ soundness machinery; see CLAUDE.md's "LMR sd11 vs sn10M
+ protocol" and search.c:242's "live, in-progress collection run
+ as of this writing"). Structured as a cascade of checks, most
+ of them bitboard-based, falling through to a `cTrapped`
+ half-credit hint only as the last resort (`searchsup.c:403`) --
+ see the dedicated finding below, since 3 of its 5 checks turned
+ out to share `_EvalTrappedPieces`'s dependency, not just the
+ final fallback.
+ 3. `ValueOfMaterialInTroubleDespiteMove` (`dynamic.c:619`, called
+ once, from `search.c:2237`) -- a **binary QSearch stand-pat
+ gate** ("material in danger -> don't let this side stand pat"),
+ not an FH%-bucket. Reads `cEnprise` (two-slot) and `cTrapped`
+ (one-slot) together into one `iInTrouble` value, with no
+ bitboard check of its own -- trusts the cached hint directly,
+ validated only by an eval-signature match.
+ - **`EstimateHangingMaterialCp` finding, 2026-10-15**: its 5 checks
+ split 2 independent / 3 Eval()-entangled, and the entanglement is
+ bigger than just the `cTrapped` fallback:
+ 1. Pawn-attacks-destination -- independent (static attack tables +
+ raw pawn bitboard).
+ 2. Enemy-king-adjacency -- independent (static tables + `see.c`'s
+ on-demand `_WhoAttacksSquareBB`, the same on-demand
+ board-geometry lookup SEE itself uses -- not an eval-cached
+ bitboard).
+ 3. Minor-attacks-destination -- **depends on
+ `bbMinorAttacksSnapshot`**, which `search.c:606-609` populates
+ straight from `pos->bbMinorAttacks`, **gated on
+ `fParentAttackTablesAreFresh`** (`search.c:602-603`: true only
+ if the parent node's `Eval()` call was a full eval, not lazy).
+ 4. Rook-attacks-destination -- same dependency, `bbRookAttacks`.
+ 5. The `cTrapped` half-credit fallback (what prompted this
+ check) -- depends on `_EvalTrappedPieces`, same as `cTrapped`'s
+ producer below.
+ **So the real dependency here isn't just the `cTrapped` fallback
+ -- it's `bbMinorAttacks`/`bbRookAttacks` themselves** -- but
+ **this is not a blocker, and not a new condition an eval swap
+ introduces**: `fParentAttackTablesAreFresh` is already false
+ today on every node where the parent's `Eval()` call took a
+ lazy/super-lazy exit (today's normal three-tier behavior, not a
+ hypothetical) -- `EstimateHangingMaterialCp` already falls back to
+ just its 2 independent checks on those nodes, routinely, right
+ now. An NNUE swap just makes that already-live degraded path the
+ permanent one, not a newly-broken one. The actual effect, per the
+ function's own return-value contract (cp-loss-or-zero, consumed
+ only through `HangCpBucket`): losing checks 3-5 means some moves
+ that would have bucketed as "a little dumb" instead bucket as "not
+ dumb" (bucket 0) -- a **precision/recall shift in the joint
+ fail-high prediction table's "dumb move" axis, used for pruning/
+ reduction/node-type decisions, not a correctness bug.** Whether
+ this matters enough to rebuild is an empirical question, not an
+ a-priori blocker: measure what fraction of real search nodes
+ already hit the degraded (no-fresh-tables) path today, and how
+ much predictive power the joint table actually loses if that
+ fraction goes to 100%, before deciding whether building the
+ independent bitboard pass is worth it versus just accepting the
+ less-pessimistic FH% estimate this already-tolerated degraded mode
+ produces.
+ - **Producers -- two genuinely different cases, not one**, which
+ changes the decoupling cost estimate:
+ 1. **`cEnprise`'s cheap source is already fully decoupled from
+ `Eval()` today**: `searchsup.c:3032`'s null-move-refutation
+ path (`RecordEnprisePieceAtPly`, inside
+ `_VerifyNullMove`/nullmove handling) flags a piece en prise
+ purely from *the move that refuted a null-move search*
+ (`mv.pCaptured`) -- no eval attack-table dependency at all,
+ pure search/move-list logic. This half needs **no change** to
+ survive an eval swap.
+ 2. **`cTrapped`'s only source, and `cEnprise`'s *other* source,
+ are both inside `eval.c`'s `_EvalTrappedPieces`**
+ (`eval.c:6188`), and this one is genuinely entangled with
+ `Eval()`'s own term-by-term computation, not cleanly
+ separable by just moving a function: it reads
+ `pos->bbPawnAttacks`/`bbMinorAttacks`/`bbRookAttacks`/
+ `bbQueenAttacks`/`bbKingAttacks` (built incrementally by
+ earlier per-piece-type eval passes) and
+ `pos->cTrapped[uColor][]`/`pos->bbTrappedEscapes[uColor][]`
+ (a *nomination* list populated earlier still, during
+ low-mobility-piece detection, specifically deferred there
+ because full attack information isn't available until later
+ passes finish -- see `_EvalBishop`'s "carefully" comment).
+ `_WhoControlsSquareFast` (used both here and at the dead
+ `_EvalLookForDanger`, `#if 0`'d out) also wants those same
+ attack bitboards. None of this exists independently of
+ `Eval()` running its normal term-by-term pass today.
+ - **Revised estimate, 2026-10-15 -- not a blocker.** `cEnprise`'s
+ null-move-refutation source is free (already independent) with no
+ caveats. Everything routed through `_EvalTrappedPieces` --
+ `cTrapped`, `cEnprise`'s other source, and 3 of
+ `EstimateHangingMaterialCp`'s 5 checks -- degrades gracefully
+ rather than breaking: an eval swap just makes permanent a
+ low-information fallback path (`fParentAttackTablesAreFresh ==
+ FALSE`) that already fires routinely today on lazy/super-lazy
+ eval exits. Net effect is a less-pessimistic "dumb move" axis in
+ the joint fail-high prediction table -- a tuning/precision
+ question, not correctness. **Decision: do not build the
+ independent bitboard pass speculatively.** Ship the eval swap
+ first with these signals simply going permanently degraded, then
+ measure with the same joint-table instrumentation already in
+ `search.c` whether the prediction table's actual pruning/reduction
+ behavior suffers enough to be worth recovering. Revisit building a
+ slimmed `bbMinorAttacks`/`bbRookAttacks`-plus-trapped-piece pass
+ only if that measurement says so -- not before.
+- Preferred approach, revised: **no independent tactical-signals pass
+ is planned up front.** The earlier plan here (build a slimmed
+ bitboard pass before the eval swap, to protect `cTrapped`/`cEnprise`
+ consumers) is deferred per the decision above -- it's optional,
+ data-gated follow-up work, not a precondition for replacing
+ `Eval()`. What *does* still apply: the 2026-08-29 move-ordering
+ session's countermove-table evidence gating is unaffected by any of
+ this (doesn't touch `cTrapped`/`cEnprise` at all); `FLEE_BONUS`
+ itself was already dead code before this investigation started, so
+ there was never anything of its to protect. The null-move-
+ refutation `cEnprise` source needs no changes at all, regardless.
+
+## Feature-indexing parity (Python trainer vs. C inference)
+
+The single most likely way to silently wreck this project: the Python
+training code computes HalfKAv2 feature indices one way, the C inference
+code computes them a different way (square-numbering off-by-one,
+different king-bucket formula, different perspective ordering), and the
+net trains with a perfectly reasonable loss curve but performs randomly
+in the engine, because the trained weights are being read back out
+against the wrong feature slots.
+
+- Write one canonical feature-indexing spec (square numbering,
+ king-bucket formula, per-perspective feature ordering, HalfKAv2_hm's
+ mirroring rule) **once**, in this document or a linked one, before
+ either the Python encoder or the C encoder is written.
+- Before any real training run: build a small cross-checker that dumps
+ the feature vector for N known test positions from both the Python
+ encoder and a standalone C test harness and diffs them bit-for-bit.
+ Do this before training, not after a training run produces confusing
+ results.
+- Confirm the output sign/perspective convention (side-to-move-relative,
+ per the architecture decision above) the same way -- a sign flip only
+ manifests as "plays like it wants to lose," which is a much harder bug
+ to localize after the fact than a direct feature-vector diff.
+
+## Canonical HalfKAv2_hm feature-indexing spec (2026-10-15)
+
+Per the "known, documented format" architecture decision above, this
+is **not a reinvented scheme** -- it is Stockfish's own `HalfKAv2_hm`
+feature set, pinned to a specific historical implementation
+(`src/nnue/features/half_ka_v2_hm.{h,cpp}` at git tag `sf_16`, fetched
+directly from `official-stockfish/Stockfish` 2026-10-15, quoted
+verbatim below) rather than re-derived from memory or a secondary
+description. Current Stockfish `master` has moved on to a different,
+newer feature set entirely (`k32q2`/`pp_3wide`/`full_threats`) -- do
+not fetch "latest Stockfish" expecting to find `HalfKAv2_hm` there.
+
+**Square numbering (Stockfish side, the convention both the Python
+encoder and this spec's formulas use)**: `Square = file + 8*rank`,
+0-indexed, `SQ_A1 = 0` ... `SQ_H8 = 63` (`a1..h1` = 0-7, `a2..h2` =
+8-15, ..., `a8..h8` = 56-63).
+
+**typhoon's own square numbering is different and must be converted**:
+typhoon uses a 0x88 mailbox representation (`chess.h`): `A1 = 0x70`,
+`RANK(c) = 8 - (c >> 4)` (1-indexed rank), `FILE(c) = c & 0x0F`
+(0-indexed file). The C encoder must convert every typhoon `SQUARE`
+to the Stockfish-style 0-63 index before applying any formula below:
+`sq64 = FILE(c) + 8 * (RANK(c) - 1)`. Getting this one line wrong
+(e.g. an off-by-one on `RANK(c) - 1`, or forgetting typhoon's rank
+numbering runs opposite to its own nibble ordering) is exactly the
+class of silent bug this whole spec exists to prevent -- it must be
+the first thing the cross-checker below verifies, in isolation,
+before testing any king-bucket/orientation logic that builds on top
+of it.
+
+**Piece-square plane index** (`PieceSquareIndex`, verbatim from
+`half_ka_v2_hm.h`) -- `SQUARE_NB = 64`:
+```
+PS_NONE=0, PS_W_PAWN=0, PS_B_PAWN=64, PS_W_KNIGHT=128, PS_B_KNIGHT=192,
+PS_W_BISHOP=256, PS_B_BISHOP=320, PS_W_ROOK=384, PS_B_ROOK=448,
+PS_W_QUEEN=512, PS_B_QUEEN=576, PS_KING=640, PS_NB=704
+```
+Per-perspective piece->plane lookup (own/enemy colors swap when
+Perspective=BLACK; **both kings share the single `PS_KING` plane** --
+own-king and enemy-king features differ only by which square within
+that one 64-square block is set, not by which plane):
+```
+Perspective=WHITE: {pawn:PS_W_PAWN, knight:PS_W_KNIGHT, bishop:PS_W_BISHOP,
+ rook:PS_W_ROOK, queen:PS_W_QUEEN, king:PS_KING} for White(=own) pieces;
+ {pawn:PS_B_PAWN, ..., king:PS_KING} for Black(=enemy) pieces.
+Perspective=BLACK: own/enemy swapped (own=Black pieces use the PS_W_*
+ constants' *role*, i.e. the W/B labels in this table mean "us/them",
+ not literal color -- see the "convention: W-us, B-them" comment in
+ the source).
+```
+
+**King buckets** -- 32 buckets per perspective (not 64; this is
+where `_hm`'s horizontal mirroring halves the table vs. plain
+HalfKAv2). `KingBuckets[Perspective][[Square 0..63]]`, each entry
+`bucket_index * PS_NB` (so it's already a feature offset, not a bare
+bucket number) -- verbatim from the source, printed one visual board
+row per line, **rank 1 first** (i.e. row *i* of this literal = Squares
+`8*i .. 8*i+7`, matching Stockfish's own `rank*8+file` ordering, not a
+top-down board diagram):
+```
+KingBuckets[WHITE] (value = bucket*704):
+ rank1: 28 29 30 31 31 30 29 28
+ rank2: 24 25 26 27 27 26 25 24
+ rank3: 20 21 22 23 23 22 21 20
+ rank4: 16 17 18 19 19 18 17 16
+ rank5: 12 13 14 15 15 14 13 12
+ rank6: 8 9 10 11 11 10 9 8
+ rank7: 4 5 6 7 7 6 5 4
+ rank8: 0 1 2 3 3 2 1 0
+KingBuckets[BLACK] (value = bucket*704) -- mirror image of WHITE's table
+ (own king's bucket decreases as it advances up the board *from that
+ perspective*, same intent, opposite physical ranks):
+ rank1: 0 1 2 3 3 2 1 0
+ rank2: 4 5 6 7 7 6 5 4
+ rank3: 8 9 10 11 11 10 9 8
+ rank4: 12 13 14 15 15 14 13 12
+ rank5: 16 17 18 19 19 18 17 16
+ rank6: 20 21 22 23 23 22 21 20
+ rank7: 24 25 26 27 27 26 25 24
+ rank8: 28 29 30 31 31 30 29 28
+```
+Only 4 distinct bucket values per rank (not 8) because of the
+horizontal mirror: king on file a/h share a bucket, b/g share one,
+c/f share one, d/e share one -- the actual file-level distinction
+survives through the *orientation* step below, not the bucket.
+
+**Orientation / mirroring** (`OrientTBL`, verbatim) -- gives the
+XOR mask applied to every piece's square (including the king's own),
+keyed by the *perspective's own king's actual square*:
+```
+OrientTBL[WHITE][ksq]: SQ_H1 (=7) if ksq's file is a-d (king on the
+ queenside half)
+ SQ_A1 (=0) if ksq's file is e-h (kingside half)
+ (applies to ksq on ANY rank -- only ksq's FILE selects the branch)
+OrientTBL[BLACK][ksq]: SQ_H8 (=63) if ksq's file is a-d
+ SQ_A8 (=56) if ksq's file is e-h
+```
+`7 = 0b111` flips only the file bits (pure horizontal mirror);
+`56 = 0b111000` flips only the rank bits (vertical flip, i.e. "view
+from Black's side" with no horizontal mirror); `63 = 0b111111` flips
+both (Black's view, horizontally mirrored too). Net effect matches
+the header comment: **the perspective's own king always ends up on
+the e-h files** after orientation, regardless of which side it
+started on or which color is to move.
+
+**Feature index formula** (`make_index`, verbatim):
+```
+index(Perspective, s, piece_at_s, ksq) =
+ (s XOR OrientTBL[Perspective][ksq])
+ + PieceSquareIndex[Perspective][piece_at_s]
+ + KingBuckets[Perspective][ksq]
+```
+where `s` is every occupied square's Stockfish-style 0-63 index
+(including `ksq` itself, and including the *other* king's square),
+and `ksq` is fixed per perspective as that perspective's own king's
+*actual, unoriented* square (the bucket/orient lookups are keyed by
+the real square -- only the piece square `s` gets XORed).
+
+**Per-perspective ordering / total dimensions**: `Dimensions =
+SQUARE_NB * PS_NB / 2 = 64 * 704 / 2 = 22528` features per
+perspective (the `/2` is exactly the `_hm` mirror halving 64 king
+positions down to 32 buckets). Two independent 22528-wide sparse
+input vectors are produced per position, one per color's perspective
+(White's accumulator half and Black's accumulator half), concatenated
+own-perspective-first per the side-to-move-relative output convention
+already decided above. Up to 32 features are active at once per
+perspective (`MaxActiveDimensions`) -- one per piece on the board,
+including both kings (max 32 pieces total on a legal board).
+
+**Not yet decided, deliberately deferred**: whether typhoon's trained
+net keeps Stockfish's literal `HalfKAv2_hm(Friend)` hash/name
+convention or adopts its own -- irrelevant until a `.nnue`-compatible
+serialization format is chosen, which isn't needed for this project's
+own from-scratch C inference code. The spec above only needs to be
+byte-for-byte *internally* consistent between typhoon's Python encoder
+and typhoon's C encoder; it does not need to produce files
+interchangeable with real Stockfish `.nnue` binaries.
+
+**Bit-for-bit Python/C cross-checker built and passing, 2026-10-15**
+(closes milestone 1):
+- **C side**: `src/nnuefeatures.c`, a new `dump nnuefeatures` engine
+ command (wired into `command.c`'s existing `dump` dispatcher,
+ declared in `chess.h`, added to `GNUmakefile`'s `OBJS`).
+ Deliberately **not** wired into eval/search/the accumulator --
+ reference-math-only, operating on the already-trusted
+ `g_RootPosition`/`rgSquare` board state so no new FEN-parsing code
+ was needed. Implements the 0x88->64 conversion
+ (`CoorToSq64`), `KingBucket`, `OrientMask`, and the index formula as
+ small standalone static functions, each matching one piece of the
+ spec above 1:1. Prints `NNUE_FEATURES white <idx>...` /
+ `NNUE_FEATURES black <idx>...` for the current position.
+- **Python side**: `annotate_pgns/nnue_feature_spec.py`, an
+ independent from-scratch implementation of the identical formulas
+ using `python-chess`'s own native 0-63 square numbering (no 0x88
+ conversion needed on this side -- that's purely a typhoon-C-side
+ translation concern).
+- **Driver**: `annotate_pgns/cross_check_features.py` spawns the real
+ engine binary via `--batch --command "setboard <fen>; dump
+ nnuefeatures"`, diffs its output against the Python encoder,
+ index-set for index-set, over 9 hand-picked FENs chosen to catch a
+ perspective/orientation mixup specifically (not just the symmetric
+ starting position, which can't distinguish a White-perspective bug
+ from a Black-perspective bug since it's symmetric under exactly the
+ transform between perspectives): both kings queenside (a-d files),
+ both kingside (e-h), each combination of one king on each half (the
+ cases most likely to expose an orientation/bucket bug), a dense
+ midgame position exercising every piece type, a sparse king-and-pawn
+ endgame (3 active features, near the low end of the range), and a
+ multi-queen position. **All 9 positions pass on both
+ perspectives.** Also re-verified passing against the release binary
+ rebuilt by `precommit_check.sh` (which itself passed both gates),
+ not just a one-off debug build.
+- Run it again with `python3 cross_check_features.py` from
+ `annotate_pgns/` any time the C or Python side of this formula
+ changes -- cheap (a few seconds), should be treated as a required
+ gate before trusting any future feature-vector output from either
+ side.
+
+## Training data pipeline
+
+- **Score target**: sigmoid-squashed WDL-style, per the decision above.
+ Mixed with or validated against actual game outcome where available
+ (standard NNUE bootstrapping practice), with exact
+ mate/EGTB-recognizer positions excluded from the sigmoid-fit loss (see
+ above).
+- **Move-played-as-successor-inversion technique** (confirmed sound):
+ to get a score for the move actually played in a human game (no
+ guarantee the engine's own search would pick the same move), score the
+ resulting position after the move and negate:
+ `score_before_move_from_mover's_perspective = -eval_or_search(position_after_move)`.
+ This is just the standard negamax relationship -- well-posed regardless
+ of whose move it was, and does not require the engine to agree the
+ move was objectively best.
+- **Data sources and current state** (checked 2026-10-14):
+ - `../pgn/self_play_games.pgn`: 166,640 total games, but only
+ **26,347 have score annotations** (started "a couple of weeks
+ ago" per conversation) -- roughly 2.36M annotated `{ comment }`
+ positions total. The other ~140k pre-annotation games are also
+ pre-dating a recent change that scrambles opening positions more,
+ so they are likely heavily duplicate/near-duplicate and not worth
+ careful deduplication effort yet. Self-play is ongoing
+ concurrently with this planning, so this pool grows on its own.
+ - `../pgn/lichess_elite_*.pgn` (2013-2020, many files) and
+ `../pgn/twic_filtered.pgn` (642,268 games, >=2400 Elo / >40 ply,
+ 556MB) -- far larger and more diverse than the current self-play
+ pool, not sourced from this engine's own play, so free of the
+ "net just re-learns the current eval's blind spots" risk that
+ bootstrapping directly off self-play data would have.
+- **Recommended phasing** (standard NNUE bootstrapping order, not novel):
+ 1. **Phase 1 -- bootstrap from human games.** Train the first net
+ primarily on `twic_filtered.pgn`/`lichess_elite_*` positions.
+ Training on the current self-play pool first would mostly teach
+ the net to reproduce the current hand-tuned `Eval()`'s own blind
+ spots, since that's what generated those games -- not useful
+ signal for replacing it.
+ 2. **Phase 2 -- self-play with the new net.** Once a first net
+ exists, use it (not the old `Eval()`) to generate much larger,
+ more targeted self-play volume -- this is where self-play data
+ becomes valuable, since it now reflects the new net's own
+ tactical gaps rather than the old eval's.
+- **Search depth for annotation: `sd 10`, fixed depth (not `st` time-based),
+ decided 2026-10-14.** Measured on a 300-position sample drawn from
+ `twic_filtered.pgn` (`/tmp/typhoon/nnue_pilot/`): `sd 10` with a 32MB
+ hash runs at roughly 7 positions/sec single-threaded (~6/sec at the
+ default 256MB hash -- smaller hash is faster per-position because
+ every `setboard` triggers a full `ClearHashTable()` memset via
+ `SetBoardCommand`'s `PreGameReset(FALSE)` call, so a smaller table is
+ cheaper to clear). At 16 single-threaded shards (`--cpus 1 --hash 32m
+ --logfile -` each), 5M positions is projected at ~12.4 hours.
+ `--logfile -` was confirmed to already skip `BeginLogging`'s
+ backup-file dance entirely (`main.c`), no code change needed there.
+ The `script` command's periodic checkpoint-report dump (every 5
+ positions, `script.c`'s `CHECKPOINT_GO_INTERVAL`, kept for crash
+ resilience on long joint-table-collection runs) was measured and found
+ to cost nothing meaningful at this scale (43.9s vs 42.7s/300 positions
+ with it disabled) -- not worth adding a flag for.
+ - Depth-adequacy check: compared `sd 10` vs `sd 16` scores on the
+ same 21 positions (a subsample of the 300; the full 300-position
+ `sd 16` comparison was not completed -- it was running at ~14s/
+ position, ~100x slower than `sd 10`, confirming `sd 16`+ is not
+ feasible for bulk annotation at the volumes this project needs
+ regardless of any quality difference). Result: mean |delta| 19cp,
+ median 10cp; in win-probability terms (what actually matters for
+ WDL-sigmoid training labels) mean |delta| was ~2.3%, only 1 of 21
+ exceeded a 10% win-probability swing, and that one outlier was
+ already a clearly-winning position at both depths (didn't change
+ position *character*). Two apparent "sign flips" were both
+ near-equal/balanced positions where a small score wobble flips
+ which side nominally leads -- noise at the margin, not a missed
+ tactic. Small sample (n=21), not weighted toward tactically sharp
+ positions the way ECM is, but consistent with the expectation that
+ ordinary human-game middlegame positions (unlike curated tactical
+ suites) are not especially depth-sensitive for the net's purpose
+ -- the net's job is positional judgment on these; finding forced
+ tactics is the search's job at inference time, not the
+ training-label-generation search's job to get perfectly right.
+ Conclusion: proceed with `sd 10` for the bootstrap annotation pass.
+- **Annotation harness**: build a TWIC/lichess annotation tool reusing
+ `eval_tune/tune_eval_dna.py`'s `Engine`/subprocess pattern (xboard
+ protocol driver, persistent-context `eval` command, SysV-semaphore
+ pitfalls already solved there -- see that file's existing
+ `Engine`/`EnginePool` context-manager requirement). This is buildable
+ and runnable immediately, independent of all engine-internals work
+ below.
+- **Quiet-position filtering, applied at training-set assembly time (not
+ at annotation time)**: exclude positions where the side to move is in
+ check, or where the best/played move is an SEE-positive capture
+ (signals the position is mid-exchange, not settled). Two reasons: (1)
+ a position dominated by a forced tactical resolution tests "find the
+ tactic," which is quiescence search's job, not the net's positional
+ judgment -- training heavily on these wastes net capacity re-deriving
+ something SEE already gives for free; (2) a forcing capture sequence
+ sampled at plies N, N+1, N+2 produces several highly correlated
+ near-duplicate samples of one tactical event, not independent data
+ points, inflating apparent corpus diversity without adding real
+ signal. Keep annotating every ply regardless (the raw PV/score corpus
+ has standalone value for analysis); apply this filter only when
+ building the actual training set from that corpus. Note this does not
+ create a train/inference mismatch -- the net still gets invoked at
+ many non-quiet-looking nodes during `QSearch()` at inference time, but
+ quiescence search is specifically designed to walk down to a settled,
+ stand-pat-ish leaf before a position's evaluation is actually trusted,
+ so the distribution of positions where the score is relied upon is
+ itself mostly quiet-ish by construction.
+- **Held-out test set: resolved, already built.** `split_holdout.py`'s
+ stratified game-level split (result x length-quartile, 10% sampled
+ independently per stratum -- see Status below) is the holdout pool:
+ **26,995 train games / 2,298,648 positions** vs. **3,005 holdout
+ games / 255,245 positions**, split by *game* (not position, avoiding
+ cross-split leakage from one game's correlated positions) and never
+ touched when fitting the WDL-sigmoid K constant. This is a separate
+ pool from `eval_tune/tuned.best_holdout.dna` (that one is
+ DNA-tuning's own flat, position-level holdout, built for a different
+ pipeline) -- the two are not reused across projects; NNUE training/
+ validation/gating all draw from this `annotate_pgns/` pool instead.
+
+## Status (2026-10-14)
+
+- **Sharded annotation harness built**: `../annotate_pgns/annotate.py`.
+ Shards an input PGN across N single-threaded engine instances, scores
+ every position in every game at a fixed `sd <depth>` (depth is a
+ plain CLI arg, so re-running at `sd 12`/`sd 16` later needs no code
+ changes, just a different `--depth`/`--outdir`), and reassembles an
+ annotated PGN matching the self-play comment format (`{ +score/depth
+ PV }`). Validated end-to-end on an 8-game/4-shard smoke test before
+ trusting it at scale. `load_games()` skips `Result "*"` (unterminated)
+ games -- can't derive a WDL label from an unknown outcome. Short
+ agreed-draw games are not separately filtered: `twic_filtered.pgn`'s
+ existing `>40 ply` / `>=2400 Elo` filter (`eval_tune/filter_pgn.py`)
+ already excludes those by construction, confirmed empirically (a
+ 5000-game sample has a hard floor at 40-49 plies, nothing shorter).
+- **Stage A (pipeline-validation) run in progress.** Target: ~2M
+ annotated positions (~30,000 games from `twic_filtered.pgn`, the
+ first 30,000 in file order) at `sd 10`, 16 single-threaded shards
+ (`--cpus 1 --hash 32m --logfile -` each). Launched 2026-10-14 ~15:05.
+ - Output location: `../annotate_pgns/runs/sd10_twic_2M/annotated.pgn`
+ (written once all 16 shards finish and the driver reassembles
+ them). Intermediate per-shard files kept in the same directory:
+ `shard_NN.script` (generated engine command scripts),
+ `shard_NN.out` (raw engine stdout per shard). Driver progress log:
+ `../annotate_pgns/runs/sd10_twic_2M.driver.log`.
+ - Actual yield came in higher than the small smoke-test estimate:
+ 30,000 games produced 2,553,893 total positions to score (~85/game
+ average, vs. the ~67/game estimated from the 8-game smoke sample)
+ -- expect final usable count after quiet-filtering (applied later,
+ at training-set-assembly time, not during annotation) to land
+ somewhat under 2,553,893, comfortably still in/above the ~2M
+ target.
+ - Measured throughput came in slower than the single-shard pilot
+ extrapolation: ~75 positions/sec aggregate across all 16 shards
+ (vs. ~112/sec extrapolated from the single-shard pilot's 7/sec x
+ 16), most likely from 16-way contention on this shared box
+ (cache/memory bandwidth) rather than any bug -- progress checks
+ showed clean, steady, monotonic counts with no errors/aborts
+ across all 16 engine processes.
+ - As of 2026-10-14 16:47: 459,156 / 2,553,893 positions done (18.0%).
+ Revised ETA: ~7.7 more hours from that point (~9.4 hours total
+ from the 15:05 launch), finishing roughly 00:30 rather than the
+ original ~5-hour back-of-envelope guess.
+- **Stage A run finished 2026-10-14 ~00:40** (~9.6 hours, close to the
+ revised ETA): 30,000 games, 2,553,893 positions scored across all 16
+ shards with no crashes/aborts.
+- **Bug found and fixed in `annotate.py`'s output parser, discovered
+ only after the full run completed (would have been cheap to catch in
+ the smoke test -- lesson for next time: smoke-test specifically needs
+ a position or two that hits a forced mate/EGTB hit, not just ordinary
+ middlegame positions).** `TELLOTHERS_RE` only matched plain-float
+ scores; the engine's own `+MATE<N>`/`-MATE<N>` notation (and `<EGTB>`/
+ `<REC>` PV-terminal sentinel moves, which can carry an ordinary-
+ looking float score when real moves precede the sentinel, e.g.
+ `PV=Kf6 Bc3+ Kxg6 Kf8 Kxh5 <EGTB>`) didn't match, so those result
+ lines were silently dropped from the parsed list while staying in the
+ position index. Since `annotate_shard` zips the two lists
+ *positionally*, one dropped line didn't just lose its own
+ annotation -- it desynced every position after it in that shard,
+ attaching the wrong score/PV to the wrong FEN for the rest of the
+ shard's output. All 16 shards hit this (975-1,265 bad lines each,
+ 16,994 total, ~0.67% of all scored positions), confirmed via a full
+ scan of every `tellothers` line across all 16 `shard_NN.out` files
+ (2,553,893 total lines, 0 that failed to match the outer line format
+ at all -- the only two non-numeric score tokens that ever appear are
+ `+MATE<N>` (9,986 occurrences) and `-MATE<N>` (7,008), confirming
+ there's no other silent-drop source hiding in this run's data).
+- **Recovered without re-running the search.** `annotate_pgns/extract_positions.py`
+ mines `shard_NN.script` (the ordered list of `setboard <fen>` lines
+ already written for that shard) zipped positionally against
+ `shard_NN.out`'s `tellothers` results -- the engine processes `go` in
+ strict script order with no interleaving (see CLAUDE.md), and
+ `setboard`-count equals `tellothers`-count exactly once mate/EGTB
+ lines are captured as real results instead of dropped, so this
+ recovers a clean, correctly-aligned dataset straight from the
+ existing raw logs. PGN reconstruction (what `annotate.py` was doing)
+ turned out to be an unnecessary detour for a project that only ever
+ needed (FEN, score) pairs, not game trees -- it's also what
+ introduced the misalignment risk in the first place.
+ Output: `runs/sd10_twic_2M/positions.jsonl`, one JSON object per
+ position (`fen`, `depth`, `score`, `score_raw`, `pv`, `exact`,
+ `exact_reason`). Exactness is detected from the PV's *last move
+ token* being `<EGTB>`/`<REC>` (not the whole-PV string or the score
+ field -- a real line of moves can precede the sentinel, and the
+ score alongside it can look like an ordinary centipawn value even
+ though the position is game-theoretically exact) or the score being
+ `+-MATE<N>`. Result: 2,553,893 positions total, 48,407 flagged
+ `exact` (zero `<REC>` occurrences this run, all exact rows were mate
+ scores or `<EGTB>`-terminated PVs), **2,505,486 usable rows** for
+ sigmoid-fit training -- matches the original run's full target yield.
+ `annotate.py` itself still has the original bug (not yet patched --
+ low priority now that `positions.jsonl`, not its PGN output, is the
+ actual training artifact; would still need fixing before trusting any
+ of its PGN output for other purposes).
+- **Related engine-side bug found and fixed while investigating the
+ `+315.92`-style non-exact-looking EGTB scores above**: `probe.c`'s
+ `EGTB_WIN_SCORE`/`EGTB_LOSS_SCORE` used to be `NMATE - VALUE_QUEEN`
+ (deliberately kept below the `NMATE` threshold so it wouldn't be
+ mistaken for a literal forced mate elsewhere in the engine), which is
+ why EGTB hits printed as ordinary-looking floats instead of
+ `+-MATE<N>`. Changed to literal `+-NMATE` so `FormatScore` (`util.c`)
+ renders every EGTB win/loss as a flat `+-MATE100` uniformly, matching
+ the already-observed bare-`<EGTB>` case and making all EGTB-terminated
+ PVs parseable by the same plain `+-MATE\d+` regex going forward --
+ no separate PV-suffix check will be needed for *future* runs (this
+ run still needs it, since it predates the fix). Caveat checked before
+ trusting this: `ADJUST_MATE_IN_N` (the `#ifdef` in `hash.c`'s
+ `StoreExactScore` that would ply-adjust a score assuming it encodes
+ mate distance -- which a flat EGTB sentinel does not) is not defined
+ anywhere in this build (confirmed via grep across all headers and
+ `GNUmakefile`), so the hash-store path this change now exercises for
+ every EGTB hit is the safe `#else` branch (downgrades to a
+ `HASH_FLAG_LOWER`/`HASH_FLAG_UPPER` bound entry with a flat `+-NMATE`
+ value, no ply arithmetic) -- confirmed this exercises a **second,
+ previously-dormant bug** in that same `#else` branch: the `iValue <=
+ -NMATE` case was pairing `HASH_FLAG_UPPER` with `sNew.iValue =
+ +NMATE` (should be `-NMATE` -- compare the mirror-image win-case
+ branch just above it, which correctly pairs `HASH_FLAG_LOWER` with
+ `+NMATE`). Previously dormant because the old `NMATE - VALUE_QUEEN`
+ EGTB-loss score never satisfied `iValue <= -NMATE` on its own (only a
+ literal engine-found forced mate-against could reach this branch);
+ the `EGTB_LOSS_SCORE = -NMATE` change makes every EGTB-loss position
+ hit it. In a release build this was harmless (a `+NMATE` upper bound
+ is vacuously loose, never produces an incorrect cutoff, just wastes a
+ hash write); in a `DEBUG` build it would trip `search.c`'s
+ `VERIFY_HASH_HIT` macro's `ASSERT(iScore < +NMATE)` on later
+ retrieval under the `HASH_FLAG_UPPER` case. Fixed (`hash.c`:
+ `sNew.iValue = -NMATE`).
+- **Four more real engine bugs found and fixed in the same session**,
+ all via `precommit_check.sh`-verified fixes, committed together as
+ `ef2c2f6` ("Fix several latent search/move-generation bugs found via
+ NNUE annotation tooling"):
+ - **`san.c`**: a checking castle move (`O-O+`/`O-O-O+`) printed as
+ `+-O`/`+-O-O` -- the check-suffix pointer (`p = szTextMove;`) was
+ captured *before* `strcpy` filled the buffer with `"O-O"`, so
+ `*p++ = '+'` overwrote the leading `O` instead of appending after
+ it. Fixed by recomputing `p` from `strlen(szTextMove)` after the
+ `strcpy`.
+ - **`search.c`/`searchsup.c`**: all five PV sentinel-recording sites
+ (`MATEMOVE`, `DRAWMOVE` x2 -- stalemate and the separate
+ repetition/50-move site, `RECOGNMOVE`/`EGTBMOVE` x2 -- main
+ search and QSearch) were gated by the same strict
+ `(iAlpha < iScore) && (iScore < iBeta)` window check used for
+ ordinary heuristic PV moves. Traced live (temporary `Trace()`
+ instrumentation on a real mate-in-2 from the corpus, removed
+ after): once a forced line's value is already known (from an
+ earlier, shallower iterative-deepening pass, or the hash table),
+ later visits to it are typically probed with a PVS scout/null
+ window built *around* that known value, so the terminal node's
+ real score *ties* the window rather than strictly beating it --
+ failing this gate by equality, not by being out of range. But
+ these five values are exact/game-theoretic facts, not heuristic
+ bounds -- unlike an ordinary move, they can't be "wrong" for
+ having been reached outside the window. Fixed by recording them
+ unconditionally. This alone did not fully fix `<#>`/`<=>` display,
+ though -- see the next bullet.
+ - **`util.c` (`WalkPV`)**: traced further and found the *actual final
+ move* of a forced mate (one ply shallower than the terminal
+ sentinel) hits the identical tie, through the completely separate
+ *ordinary* move-acceptance code path (`iScore > iAlpha` at
+ `search.c`'s main move loop) -- so the recorded PV is often
+ missing its own concluding move, not just the sentinel after it.
+ Rather than touch that hot, per-node path (every interior node of
+ the entire search tree), `WalkPV` now reconstructs the missing
+ tail directly from the board when the recorded PV runs out one
+ ply short of (or exactly at) a forced mate/stalemate/draw: the
+ position at that point already reflects every printed move (each
+ was `MakeMove`'d during the walk), so it checks `IsDraw`, or
+ failing that, finds the one legal move the shrinking mate
+ distance guarantees must exist. Runs once per improved root
+ score, not in the search tree, so the extra legal-move
+ verification is cheap. Verified on mate-in-1 (`Rd8+ <#>`), a real
+ mate-in-6 (13-move chain, no gaps anywhere in the middle, not
+ just at the end), and a constructed 50-move-rule draw
+ (`Kc1 <=>`).
+ - **`gamelist.c`/`board.c`/`testsup.c`**: `SetRootPosition` used to
+ parse a `setboard` FEN straight into the global `g_RootPosition`,
+ so a *rejected* FEN (malformed syntax, or -- newly caught --
+ describing an illegal position) still left partially-parsed
+ garbage in `g_RootPosition`, because `GetRootPosition()` hands
+ out a stable pointer straight to it with no defensive copy
+ (removing one was a deliberate, confirmed-correct fix for a
+ *different*, unrelated bug -- see that function's own comment).
+ The next `go` searched that garbage regardless of the earlier
+ rejection, discovered independently two ways: an `InvalidGenerator`
+ dispatch panic (`generate.c`) and, separately, a debug-build
+ assert in `_ScoreAllMoves`'s MVV/LVA scoring (`PIECE_VALUE(KING)`
+ overflowing past `VALUE_KING` -- a pseudo-legal "capture the enemy
+ king" move, generated because nothing stops the generator from
+ producing one against an opponent king already left in check).
+ Root cause of *that*: a hand-built FEN leaving the side not on
+ move in check is illegal (unreachable through real play), and
+ nothing validated for it. Fixed: `SetRootPosition` now parses into
+ a local staging `POSITION` and only commits to `g_RootPosition` if
+ `FenToPosition` succeeds *and* `IsBoardLegal` (pawns never on
+ rank 1/8, side not on move never in check) *and*
+ `VerifyPositionConsistency` (internal struct consistency -- piece
+ lists, material counts, signatures) both pass. Both checks now
+ run unconditionally, in every build profile, not just
+ `TEST`/`DEBUG` -- `IsBoardLegal` had to be moved from `testsup.c`
+ (only linked under `TEST`/`EVAL_DUMP`) to `board.c` (always
+ linked) to make this possible; `VerifyPositionConsistency` is
+ DEBUG-gated at its many *other* (hot, per-move) call sites for
+ performance, but this one is cold (once per game/`setboard`, not
+ per node), so the cost is worth it here specifically.
+ **Important, deliberately NOT a bug**: a separate illegal-FEN
+ repro that looked like a second instance of the same class
+ (`8/8/8/4kp2/5P2/4K3/8/8 w - - 99 60`, locked pawn pair adjacent
+ to both kings) turned out to just be a badly hand-built test FEN
+ of my own -- White's own pawn on f4 already attacked the square
+ Black's king sat on while it was White's move, which is the same
+ "opponent in check" illegality, not a new generator bug. Confirmed
+ by reverting all of this session's changes (`git stash`) and
+ reproducing the identical crash on unmodified `master` -- notable
+ only as a reminder that a hand-built FEN needs to be checked for
+ basic legality before trusting a crash it causes.
+- **`extract_positions.py` exactness-tagging gap found and fixed** (see
+ the earlier bullet on the `<EGTB>`/`<REC>` MATE-regex bug): it also
+ missed `<=>` (`DRAWMOVE` -- a claimed repetition/50-move draw) and the
+ `"only"` sentinel (`root.c`'s single-legal-move-at-the-root shortcut,
+ which skips searching entirely and hardcodes `score=0`/`depth=0` --
+ not a real evaluation, not even indeterminate, just never searched).
+ A draw-by-repetition/50-move claim is a fact about the *game's move
+ history*, not the position -- the same position reached a different
+ way might not be a draw at all -- so it's excluded from training for
+ the same reason `<EGTB>`/`<REC>` are. Re-running extraction with both
+ fixed: exact-row count went from 48,407 to **93,415** (47,304 of the
+ increase is `<=>`), leaving 2,460,478 usable rows (down from
+ 2,505,486).
+- **Quiet filter built and run on the full corpus** (`quiet_filter.py`,
+ reusing `eval_tune/tune_eval_dna.py`'s pre-existing
+ `static_exchange_eval`/`has_hanging_material` rather than
+ reimplementing): excludes in-check positions and positions whose PV's
+ first move is an SEE-positive (>=50cp) capture. Full-corpus result:
+ 2,553,893 total rows -> 48,407 exact -> 112,405 excluded (in check) ->
+ 311,949 excluded (SEE-positive first move) -> **2,080,911 quiet
+ positions** (`runs/sd10_twic_2M/positions.quiet.jsonl`), ~81.5%
+ retention.
+- **Shared quiet-predicate module factored out** (`quiet_common.py`):
+ `position_is_quiet(board, move)` plus the SEE-threshold constant,
+ used by all three consumers below so the exclusion logic (in-check,
+ SEE >=50cp capture) lives in exactly one place. The two call sites
+ differ only in *which move* gets SEE-checked, and this is a real,
+ tracked distinction, not an implementation detail -- the engine's
+ own PV first move (the two post-hoc filters, below) vs. the move
+ actually played in the source game (the new pre-filter, below) are
+ two different training distributions; rows produced under the two
+ should not be silently mixed into one training run.
+- **Quiet filter now also applied to the game-shaped labeled files**
+ (`quiet_filter_games.py`), closing the gap this file previously
+ flagged as open -- re-applies the identical PV-based predicate
+ directly to each position already embedded in a game record, rather
+ than joining against `positions.quiet.jsonl` (which carries no
+ row/game identity, and a repeated FEN across different games/plies
+ would make a content-based join silently misattribute rows). A game
+ that loses every position to filtering is dropped entirely (none
+ were, in this corpus). Run on both labeled splits 2026-10-15:
+ **holdout**: 255,245 -> **215,134** positions (9,476 in-check +
+ 30,628 SEE-positive-capture + 7 indeterminate excluded), 3,005/3,005
+ games kept -- `games.holdout.labeled.quiet.jsonl`. **train**:
+ 2,298,648 -> **1,932,713** positions (88,278 in-check + 277,593
+ SEE-positive-capture + 64 indeterminate excluded), 26,995/26,995
+ games kept -- `games.train.labeled.quiet.jsonl`. Combined retention
+ across both splits is 2,147,847 / 2,553,893 = 84.1%, noticeably
+ higher than the flat-file filter's 81.5% -- **not noise, a real
+ behavioral difference**: `quiet_filter.py` (flat) drops exact rows
+ (mate/EGTB/`<=>`) from its output entirely, while
+ `quiet_filter_games.py` always keeps them (only the in-check/SEE
+ criterion applies to non-exact rows; exact rows are real,
+ game-theoretic ground truth, not something the quiet filter has any
+ basis to exclude -- NNUE.md already excludes them from the
+ sigmoid-fit *loss* elsewhere, which is a separate, correct exclusion
+ from excluding them from the dataset altogether). The game-shaped
+ filter's behavior is the one intended going forward; the flat file's
+ stricter exact-row drop was never a deliberate design choice, just
+ an artifact of how `quiet_filter.py` was first written.
+- **Quiet pre-filtering moved upstream into `annotate.py`**
+ (`--prefilter-quiet` flag; `quiet_common.position_is_quiet` applied
+ inside `positions_for_game` before a position is ever written into a
+ shard script), so a *future* annotation run never pays `sd N` search
+ cost on a position that would only be discarded afterward -- making
+ the post-hoc game-shaped filter above unnecessary for runs going
+ forward. **Deliberate semantic difference from the post-hoc
+ filters**: no PV exists before the search has run, so the pre-filter
+ SEE-checks the move *actually played* in the source game instead of
+ a PV's first move (see `quiet_common.py`'s module docstring).
+ `build_games.py` gained a matching `--prefilter-quiet` flag that
+ **must match** whatever the originating `annotate.py` run used -- it
+ recomputes per-game ply counts via the same `positions_for_game` call
+ to re-slice `positions.jsonl`, and a mismatched flag would silently
+ misalign every position after the first game that differs. Verified
+ end-to-end on a 4-game/2-shard/`sd 6` smoke test: pre-filtering wrote
+ 253 of the games' total plies into the shard scripts, all 253 came
+ back annotated with no count-mismatch warnings, and `build_games.py
+ --prefilter-quiet` reproduced the identical per-game slice
+ boundaries. The already-completed Stage A run below predates this
+ flag and was not pre-filtered -- this only changes the *next*
+ annotation run.
+- **Python SEE cross-checked against the engine's real `SEE()`**
+ (`check_see_against_engine.py`, using the bulk `seescores` command):
+ 2,000 sampled positions, 4,032 actual capture comparisons -- 31.0%
+ exact match, 92.7% sign match, 7.3% sign mismatch. Investigated one
+ mismatch in depth (`Bxa6` in a real position, engine said +100,
+ Python said -230) via temporary `Trace()` instrumentation in
+ `_SEEFull`/`SEE()`: **not an engine bug** -- the engine correctly
+ finds that after `Bxa6 Rxa6`, White's queen, freshly revealed by the
+ bishop's own departure (x-ray down the same diagonal), recaptures the
+ rook, so Black's actual best reply is to decline the recapture
+ entirely (net -100, not -230). Python's `static_exchange_eval`
+ computes all attacker lists once, off the pre-move board, so it never
+ sees a piece the mover's own departure uncovers -- exactly the
+ "ignores... discoveries" limitation its own docstring already
+ confessed. The 7.3% mismatch rate is overwhelmingly (likely entirely)
+ this same known, accepted limitation, not a new one -- confirms the
+ cross-check tool did its job, not that anything is broken.
+- **Game-container reconstruction built** (`build_games.py`): recovers
+ per-game grouping that `positions.jsonl` lost (flat list, no
+ `game_id`) by re-loading the same PGN with the same
+ `max_games`/`--shards` (pure parsing, no search, deterministic) and
+ slicing the already-extracted rows back into per-game chunks via each
+ game's known position count. Output shape -- `{game_id, result,
+ positions: [...]}`, one line per game -- deliberately matches the
+ shape a self-play PGN with per-move score/PV comments already has
+ (self-play annotation is a separate, ongoing effort outside this
+ project, run every time the engine plays a self-play match), so a
+ future loader for that source can emit the same schema and both mix
+ freely at training-set-assembly time. Verified exact: 30,000 games,
+ 2,553,893 positions, matching `positions.jsonl`'s count precisely.
+ Output: `runs/sd10_twic_2M/games.jsonl`.
+- **Stratified game-level train/holdout split built and run**
+ (`split_holdout.py`): splits by *game*, not position (every position
+ from one game lands on the same side -- avoids the leakage a flat
+ position-level shuffle has, which `eval_tune/tune_eval_dna.py`'s own
+ holdout split still does). Stratified on two axes so a 10% sample's
+ composition mirrors the full pool rather than risking (by chance) too
+ many decisive games, too few draws, or a skew toward short/long games:
+ result (White win/Black win/Draw) x game-length quartile (quartile
+ cutpoints derived from the actual corpus: 63/80/102 annotated plies,
+ not guessed). 10% sampled independently within each of the 12
+ resulting strata. Result: **26,995 train games / 2,298,648 positions**
+ (`games.train.jsonl`), **3,005 holdout games / 255,245 positions**
+ (`games.holdout.jsonl`).
+- **WDL-sigmoid target conversion built and run** (`fit_k_and_label.py`):
+ per NNUE.md's existing decision, reuses `tune_eval_dna.py`'s
+ `fit_k`/ternary-search-over-K approach, but K is fit directly against
+ the already-computed `sd 10` search scores (no engine round-trips
+ needed, unlike the original `fit_k`, which called live `Eval()`), and
+ **only on the train split** -- fitting K on holdout data would leak
+ information into what's supposed to be an untouched generalization
+ check; the same fitted K is then applied (never refit) to label
+ holdout positions too. **Caveat hit and fixed**: the first attempt
+ copied `tune_eval_dna.py`'s own search bracket (`[100, 2000]`,
+ calibrated for *centipawn*-scale `Eval()` scores) verbatim and got
+ `K=10.00` -- exactly its lower bound, a clear sign of a clamped,
+ not-actually-converged fit, not a real answer. Root cause: this
+ project's own scores are stored in *pawn* units (e.g. `+0.47`, not
+ `47`), a 100x unit mismatch. Refit with an appropriately-scaled
+ bracket (`[0.05, 20]`) landed well inside the range: **K = 1.3123**
+ (pawns), MSE improved from 0.1401 (clamped) to 0.1175 (real optimum).
+ Sanity-checks as plausible for >=2400-level play: a 1-pawn edge ->
+ `sigmoid(1/1.3123) ~= 0.68` win probability, a 2-pawn edge -> `~= 0.82`
+ -- modest material deficits are often held or converted at this
+ level, not auto-losses. Deliberately does **not** blend `p_search`
+ (the sigmoid-derived estimate) with `r_outcome` (that position's own
+ game's actual final result, side-to-move-relative) into a single
+ target yet -- both are stored separately per position so the blend
+ weight (lambda in `target = lambda*p_search + (1-lambda)*r_outcome`)
+ remains a free parameter to sweep later without re-deriving anything
+ from scratch. Verified correct on spot-checked positions (result
+ flips correctly to each position's own side-to-move perspective).
+ **Lambda decided for Phase A: lambda=1.0 (pure `p_search`, no
+ `r_outcome` blend), 2026-10-15.** Phase A's goal is pipeline/
+ framework validation on a small local-CPU training run, not the
+ best achievable net -- a free parameter with no data yet to tune it
+ against would just be guessed, so defer the sweep to Phase B/the
+ larger GPU-trained run, where there's enough data and enough training
+ iterations for a lambda sweep to produce a real signal rather than
+ noise. `r_outcome` stays stored per position regardless (already
+ computed, free to use later) so the sweep needs no re-derivation.
+ Output: `runs/sd10_twic_2M/games.train.labeled.jsonl` (26,995 games,
+ 2,298,648 positions, 84,302 exact rows left unlabeled),
+ `games.holdout.labeled.jsonl` (3,005 games, 255,245 positions, 9,113
+ exact rows left unlabeled).
+- **Not yet built**: the Python training script itself (the loss
+ function, quantization-aware export pass), the feature-indexing-spec
+ cross-checker, any `SEARCHER_THREAD_CONTEXT`/accumulator plumbing
+ (still correctly gated behind a dedicated branch, not started), and
+ the lambda sweep/selection for blending `p_search`/`r_outcome`. The
+ quiet-filter gap previously noted here (not yet applied to the
+ game-shaped/labeled files) is closed -- see `quiet_filter_games.py`
+ above -- and a future annotation run can skip the post-hoc step
+ entirely via `annotate.py --prefilter-quiet`.
+
+## Training compute
+
+- No reliable local GPU. Decided against Google Colab's notebook-session
+ model (session timeouts, no persistent SSH access) in favor of **hourly
+ GPU rental with real shell access** -- vast.ai, RunPod, or Lambda Cloud.
+ Rough order-of-magnitude expectation: a Stockfish-sized net (low
+ millions of parameters) over tens of millions of positions is on the
+ order of 1-2 hours/epoch on a single consumer-class rented GPU, full
+ run under a day; CPU-only training of the same workload is roughly
+ 20-50x slower (no tensor cores/wide SIMD at that scale), making a full
+ run impractical locally but a small-scale pipeline-smoke-test (a few
+ million positions, few epochs) plausible in hours-to-a-day on CPU if
+ needed for early iteration before committing to a paid GPU run.
+- **Decided 2026-10-15**: Phase A uses exactly that local-CPU
+ pipeline-smoke-test path -- PyTorch on CPU, against the ~2M-position
+ quiet-filtered train/holdout pool already built, purely to prove the
+ training script/data loader/export pass work end-to-end and to get
+ fast local iteration when something breaks. GPU rental is deferred
+ to the real, larger Phase B training run.
+
+## Integration into search
+
+- Lazy-eval tier restructuring: today's three-tier super-lazy/lazy/full
+ `Eval()` system does not map 1:1 onto NNUE, which doesn't have
+ comparable internal tiers the way the handcrafted eval's term-by-term
+ structure does. Tentative new tiering: (optional, experimental)
+ material-only super-lazy prefilter -> cheap linear PSQT-term-of-the-net
+ first pass -> full net forward pass. Decide how much of `eval.c`'s
+ existing tiering logic is kept vs. deleted once this is actually
+ implemented, not before.
+- Futility/EFP/LMR/NMP margins are all tuned against the current
+ `Eval()`'s score distribution and will need full recalibration against
+ the new net's score distribution -- expect this to be a comparably
+ sized project to the LMR/NMP tuning already referenced in CLAUDE.md,
+ not a quick follow-up step.
+
+## Validation and rollout
+
+- **Gate using `eval_tune/match_play.py`'s existing LOWER95 methodology**
+ (already used for DNA-tuning candidates) -- head-to-head match at
+ fixed search depth, gate on `LOWER95 >= 0.5`, not raw score, for the
+ same non-determinism reasons already documented for DNA tuning.
+- Snapshot a fresh `head_reference`-style baseline before any
+ search-affecting plumbing (accumulator maintenance, cTrapped/cEnprise
+ decoupling) lands, per the existing `head_reference/` protocol in
+ CLAUDE.md -- this is a broad enough change to want a clean before/after
+ reference point, same as any other search-affecting change.
+- Keep both evaluators coexisting behind a toggle for as long as
+ practical, so regressions can be isolated to "is it the net" vs. "is
+ it the recalibrated margins" vs. "is it the accumulator plumbing"
+ rather than landing all three at once.
+- Do not retire the handcrafted `Eval()` until NNUE wins convincingly and
+ consistently under the `match_play.py` gate.
+
+## Sequencing / milestones
+
+1. **Done 2026-10-15.** Canonical HalfKAv2_hm feature-indexing spec
+ written down + Python/C cross-checker built and passing on known
+ test positions.
+2. **Done 2026-10-15, concluded not a blocker.**
+ `cTrapped`/`cEnprise` consumers and producers enumerated (see
+ "Breaking external dependencies" above). `cEnprise`'s null-move-
+ refutation source needs no change. Everything else -- `cTrapped`'s
+ only source, `cEnprise`'s other source, and 3 of
+ `EstimateHangingMaterialCp`'s 5 checks -- is entangled in
+ `_EvalTrappedPieces`'s attack-bitboard logic, but degrades
+ gracefully (the same low-information fallback path already fires
+ routinely today on lazy/super-lazy eval exits) rather than breaking
+ -- a precision shift in the joint fail-high prediction table's
+ "dumb move" axis, not a correctness bug. **Decision: ship the eval
+ swap first, measure the actual pruning/reduction impact with
+ existing joint-table instrumentation, and only build an independent
+ bitboard pass if that measurement says it's worth it.** No
+ speculative tactical-signals pass planned up front.
+3. MakeMove/UnmakeMove accumulator plumbing (ply-indexed king-move
+ backup in `PLY_INFO`) built and proven correct/fast **with the old
+ `Eval()` still live** -- prove the plumbing before any trained net
+ exists to evaluate against it. **Do this on a separate git branch**,
+ not on `master`, given how much live search-path code it touches;
+ snapshot `head_reference` first.
+4. TWIC/lichess annotation harness built; `<TT>`/`<EGTB>`/`<REC>` tag
+ semantics investigated and resolved; first large annotated training
+ pool assembled (Phase 1 data, per above). **Unblocked now, independent
+ of steps 1-3** -- no engine-internals risk, can start immediately.
+5. First bootstrap net trained (rented GPU), quantization-aware export
+ pass built.
+6. C inference code (forward pass + quantized weight loader) written and
+ validated against the Python model's output on held-out positions.
+7. Wired into search behind a toggle; margins recalibrated.
+8. `match_play.py` gate; iterate; retire old `Eval()` only after a
+ convincing, repeated win.
+
+## Immediately actionable, unblocked work
+
+Not gated behind any engine-internals decision above:
+
+- Build the annotation harness and begin scoring `lichess_elite_*`/
+ `twic_filtered.pgn` games (Phase 1 training data).
+- Investigate `<TT>`/`<EGTB>`/`<REC>` tag semantics in the self-play PV
+ printer.
+- Finalize the written HalfKAv2_hm feature-indexing spec (design-only,
+ no code).
+
+Gated behind opening a dedicated branch (per discussion -- not yet
+started):
+
+- Any `SEARCHER_THREAD_CONTEXT`/`PLY_INFO`/MakeMove/UnmakeMove changes.