# 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**: ``/``/`` 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: **``/`` 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). **`` 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 `` 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 ...` / `NNUE_FEATURES black ...` 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 ; 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 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`/`-MATE` notation (and ``/ `` 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 `) 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` (9,986 occurrences) and `-MATE` (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 ` 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 ``/`` (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`. Result: 2,553,893 positions total, 48,407 flagged `exact` (zero `` occurrences this run, all exact rows were mate scores or ``-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`. Changed to literal `+-NMATE` so `FormatScore` (`util.c`) renders every EGTB win/loss as a flat `+-MATE100` uniformly, matching the already-observed bare-`` 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 ``/`` 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 ``/`` 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; ``/``/`` 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 ``/``/`` 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.