summaryrefslogtreecommitdiff
path: root/src/docs/NNUE.md
blob: 0ab46eca0ccbcbb4bf95d08ce2f572f8d4767d96 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
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.