diff options
| author | Scott Gasch <[email protected]> | 2026-10-15 13:11:22 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-10-15 13:11:22 -0700 |
| commit | 993b6eeb2a9ac7b659c473b6883fa53af1ad3a0b (patch) | |
| tree | c5388824dec15a7691ce9952a6eb4af66e837127 /src/docs/NNUE.md | |
| parent | 53b380c658f828c4c0e096be2763e6b012c23abd (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.md | 1153 |
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. |
