summaryrefslogtreecommitdiff
path: root/src/docs/NNUE.md
blob: dbc5d54f0beea1d78cb91993fc2c7715c4b7a87a (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
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
# 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).
- **Update 2026-10-15: all of the below is now done** (see "Status
  (2026-10-15)" further down for the full writeup) -- the training
  script, feature-indexing cross-checker, accumulator plumbing, and
  the first trained/exported/wired-in/match-played net all exist now.
  **Still not done**: the lambda sweep/selection for blending
  `p_search`/`r_outcome` (still correctly deferred to Phase B, per the
  lambda decision above -- no new information has arrived that would
  change that), and quantization (see "Quantization plan"). 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`.

## Status (2026-10-15): training run finished, net wired into real search, first match-play result

- **Phase A training run finished.** `annotate_pgns/nnue_train.py` against
  the ~2.08M-row quiet-filtered pool above, 20 epochs, PyTorch on CPU
  (per the Phase A local-CPU decision below) -- final train MSE 0.0065,
  holdout MSE ~0.022. Checkpoints: `runs/sd10_twic_2M/nnue_ckpt/
  ckpt_epoch000.pt` through `ckpt_epoch019.pt`.
- **Feature-indexing cross-checker built and passing**
  (`cross_check_features.py` against `nnue_feature_spec.py`'s Python
  reference encoder and `src/nnuefeatures.c`'s C reference dump, 9 FENs,
  all matching) -- closes milestone 1 below.
- **Weight export pass built** (`export_nnue_weights.py`): flat binary
  format, 4-byte `"NNU1"` magic, `<4I` dims header
  (`NNUE_DIMENSIONS`/`L1`/`L2`/`L3`), then the eight weight/bias arrays
  in a fixed order. `ckpt_epoch019.pt` exported to
  `runs/sd10_twic_2M/nnue_ckpt/weights.nnue` (23MB).
- **Accumulator plumbing built, tested, and proven fast** (on the
  `nnue-accumulator-plumbing` branch, per milestone 3's branch
  requirement) -- `SEARCHER_THREAD_CONTEXT` gained `sNnueAccum[2]`;
  all 8 of `move.c`'s Lift/Place/Slide(WithoutSigs) primitives
  maintain it incrementally (chose primitive-hooking over re-deriving
  move-kind from `mv` after discussion -- these primitives already
  couple tightly with Zobrist hashing, and `GenerateMoves`/
  `MakeMove`/`UnmakeMove` are too hot to afford re-branching); king
  moves trigger a full `NnueAccumRecompute` for their *own* perspective
  only (every feature in that perspective is own-king-relative), with
  a `PLY_INFO`-indexed backup/restore for `UnmakeMove`. `testnnue.c`'s
  `TestNnueAccumulator` plays real games from 7 curated FENs chosen to
  hit captures/promotions/en passant/castling/plain king moves within
  2 plies, comparing incremental maintenance against a from-scratch
  `NnueAccumRecompute` after every move -- caught two real bugs on its
  first-ever runs (a king-capture backup-ordering bug where the
  backup was taken after the capturing `LiftPiece` instead of before
  it; and, when the perf-gating fix below landed, the test's own
  failure to set `g_fUseNnueEval` before exercising the gated code).
  `setboard`'s downstream effect is covered by the same
  `ReInitializeSearcherContext` choke point every `go`/`ponder` already
  calls -- confirmed this is the one place that needs a hook, not
  `SetRootPosition` itself.
- **Load/call infrastructure and real search integration built**:
  `NnueLoadWeights` (`nnueeval.c`) loads an exported `.nnue` file,
  dimension-checked against the build's own constants; `NnueForward`
  runs the real FC head (ReLU(accumulator) -> own-perspective-first
  concat by side-to-move -> dense 2L1->L2->L3->1). `Eval()` now
  short-circuits to `NnueForward` when `g_fUseNnueEval &&
  g_fNnueWeightsLoaded`. Reachable via the `nnue load|eval|on|off`
  command and a `--nnueweights <file>` CLI flag (the latter needed
  because `match_play.py` spawns engines with fixed args -- no chance
  to send interactive commands first).
- **Critical performance regression found and fixed.** The accumulator
  maintenance added to move.c's primitives ran unconditionally on the
  (unverified, wrong) assumption that it was cheap -- cost ~50% of
  engine throughput even with NNUE off (673k nps measured vs. the
  expected 1.4-1.5M baseline). Gated all of it behind `g_fUseNnueEval`.
  Confirmed via `bench`: classic HCE back to ~1.52M nps, NNUE eval a
  fair ~410-411k nps.
- **`test_vs_head.sh` extended with `NNUE_WEIGHTS`**: wraps the
  candidate binary in a thin exec shim that always injects
  `--nnueweights $NNUE_WEIGHTS`, since `match_play.py`'s `Engine` class
  passes identical `engine_args` to both sides and spawns the given
  path directly via `Popen` (no shell), so a shebang wrapper script in
  the candidate's place works unmodified -- no `match_play.py` patch
  needed.
- **First real match-play result, stopped by hand at 70 games**: `-45
  =0 +25` (score 0.357), llr trending toward H0 but nowhere near
  resolved (far too few games for this SPRT's bounds). At `--st 4`,
  the NNUE candidate searched ~3.4-3.8 plies shallower than the HCE
  head (avg depth ~10.1-10.5 vs ~13.7-13.9) and at roughly 4.6-4.8x
  fewer nps (~285-293k vs ~1.32-1.41M) -- yet still won 25 of 70 games
  and held a stable ~0.30-0.36 score across the run rather than
  eroding toward 0 as the sample grew, and ran a *tighter* tree despite
  running under HCE-tuned pruning margins (cumulative ebf
  ~3.8-3.97 vs. head's ~4.59-5.01). Read as: the net's per-position
  judgment looks genuinely promising, but is not yet being given a
  fair fight -- two known confounds stack against it simultaneously
  (raw search-depth handicap from the speed gap, and futility/
  delta-pruning/aspiration-window margins all tuned against HCE's
  score distribution, not NNUE's). **Zero draws is not a red flag** --
  expected signature of a large strength/depth mismatch (the weaker
  side tends to lose material in sharp lines well before either side
  would reach a balanced technical ending), not evidence anything is
  broken.
- **Notably, draws were exactly zero** across all 70 games -- see
  above, this is explained by the depth/strength gap, not a bug.
- **Quantization investigation started** (not yet implemented -- see
  "Quantization plan" below for the concrete next steps):
    - AVX2 build support added (`AVX2=1` GNUmakefile option, scoped
      opt-in like `GPROF=1`, not folded into the default/`-mtune=native`
      set -- same portability reasoning as the existing `-march=native`
      rejection). Confirmed clang's `-O3 -ffast-math` already
      autovectorizes both `NnueForward` and `NnueAccumAddFeature`/
      `RemoveFeature` to SSE (4-wide) without this flag; `AVX2=1`
      widens them to real 256-bit `ymm` ops (verified via `objdump` on
      the linked binary, not the `.o` -- the `.o` files are LTO
      bitcode, `objdump -d *.o` shows nothing meaningful).
    - **AVX2 is a ~3% win for classic HCE but a measured no-op for
      NNUE** (`bench`: 410,380 -> 411,402 nps, within noise). This is
      a real, useful negative result: it confirms the NNUE bottleneck
      is memory-bound (cache misses gathering rows out of the ~23MB
      `g_rgfNnueWeight` embedding table on every accumulator
      add/remove -- the per-*move* hot path, far more frequent than
      the once-per-evaluated-leaf FC head), not FP throughput -- wider
      SIMD can't help a stall waiting on a cache-line fetch.
    - **Quantization safety-checked against the real checkpoint before
      committing to any scale constants**, since this net trains with
      plain (unclipped) ReLU, not Stockfish's clipped ReLU that makes
      its own int16/int8 scheme near-lossless by construction. Loaded
      `ckpt_epoch019.pt` directly: `ft.weight` max|.|=5.01,
      std~=1.00; FC weights all max|.| < 0.36. Sampled 2000 trials of
      32 active features (a realistic full-board HalfKAv2_hm count)
      summed through `ft.weight` + bias: worst-case accumulator
      magnitude ~26. **Conclusion: safe to quantize this checkpoint
      directly, no retrain needed** -- even a generous fixed-point
      scale (256-512) leaves int16 (+-32767) comfortable headroom
      against the realistic worst case (~6,600-13,300), nowhere near
      saturation.

## Quantization plan (not yet implemented)

Priority order, chosen from the AVX2 experiment above: the embedding
table (per-*move* cost, memory-bound) matters more than the FC head
(per-evaluated-*leaf* cost, compute-bound and already partly
vectorized), even though the FC head is the easier, more isolated
change. Planned sequence:

1. **Int16-quantize the embedding table first** (`g_rgfNnueWeight`,
   `g_rgfNnueBias`) -- halves its footprint (~23MB -> ~11.5MB),
   directly targets the measured memory-bound bottleneck via improved
   cache hit rate on `NnueAccumAddFeature`/`RemoveFeature`'s per-move
   gather. Fixed-point scale TBD from the 256-512 range checked safe
   above; `NnueAccumRecompute`'s accumulation itself can likely stay
   int32 (cheap, avoids overflow entirely) even once the table it
   gathers from is int16.
2. **Int8-quantize the FC head second** (`nnueeval.c`'s `NnueForward`)
   -- isolated to one file, runs once per evaluated leaf (not per
   move), and gets the largest *relative* SIMD win (int8 dot products
   are 32-wide under AVX2 vs. SSE's 4-wide float32) once it's worth
   doing at all.
3. **New weight-file format version required.** `NnueLoadWeights`
   currently validates magic (`"NNU1"`) + dims but has no dtype/format
   field -- an int16/int8 export with the same dims would silently be
   read as float32 and produce garbage, exactly the failure class the
   existing dim-check exists to prevent. Bump to `"NNU2"` (or add an
   explicit format word) before shipping any quantized export.
4. **New correctness test required -- `TestNnueAccumulator` will not
   catch quantization error.** It only compares incremental
   maintenance against `NnueAccumRecompute`; once both sides are
   quantized identically they still agree with each other regardless
   of whether quantization degraded the actual eval. Need a separate
   check: float `NnueForward` vs. quantized `NnueForward`, same
   position, across a few hundred FENs, comparing the resulting
   centipawn deltas against some acceptance threshold (TBD).
5. **Side benefit, not the goal**: integer incremental accumulator
   add/subtract is exact (no float drift), so quantizing removes both
   `_CompareAccumToRecompute`'s 0.01f epsilon tolerance and any
   long-move-sequence floating-point drift question entirely.
6. Re-run `test_vs_head.sh` with `NNUE_WEIGHTS` after each step to
   measure the real nps delta before moving to the next step -- same
   reasoning as checking AVX2 in isolation before quantizing: don't
   stack two speed changes in one measurement.
7. Once NNUE's effective search depth is no longer handicapped by
   several plies at the standard time control, re-run the
   `match_play.py` gate for a real read on judgment quality, largely
   decoupled from the raw-speed confound -- and only then decide
   whether margin recalibration (see "Integration into search" above)
   is worth doing before or after a Phase B (GPU-trained, larger net)
   run.

## 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. **Done, on `nnue-accumulator-plumbing`.** MakeMove/UnmakeMove
   accumulator plumbing (ply-indexed king-move backup in `PLY_INFO`)
   built and proven correct via `testnnue.c`'s `TestNnueAccumulator`,
   and proven fast after finding and fixing a real ~50% throughput
   regression (see "Status (2026-10-15)" above) -- gated behind
   `g_fUseNnueEval` so it costs nothing with NNUE off. `head_reference`
   snapshot already existed per the existing protocol.
4. **Done.** TWIC annotation harness built and run (Stage A, 2.55M
   positions from 30,000 `twic_filtered.pgn` games); `<TT>`/`<EGTB>`/
   `<REC>`/`<=>`/`"only"` tag semantics investigated and resolved (see
   "Status (2026-10-14)" above, including the real engine bugs found
   along the way).
5. **Done for Phase A (CPU-trained bootstrap, not the GPU-trained
   Phase B net).** First net trained (`nnue_train.py`, 20 epochs,
   train MSE 0.0065 / holdout MSE ~0.022) and exported
   (`export_nnue_weights.py` -> `weights.nnue`). **Quantization-aware
   export pass is explicitly NOT done yet** -- current export is
   plain float32; see "Quantization plan" above for the concrete,
   not-yet-started next steps.
6. **Done**, modulo quantization. C inference code (`nnueeval.c`'s
   `NnueForward` + `NnueLoadWeights`) written and running against real
   search positions via the `nnue eval` command and live search
   integration. **Not yet validated against the Python model's output
   position-by-position** -- worth doing (a batch of FENs run through
   both `nnue_model.py`'s `forward()` and `NnueForward`, comparing
   scores) before trusting the C side's numerical correctness beyond
   "it loads and produces plausible-looking scores," which is all
   that's been checked so far.
7. **Done.** Wired into search behind `g_fUseNnueEval`/
   `g_fNnueWeightsLoaded` (`Eval()`'s short-circuit). **Margins NOT
   yet recalibrated** -- still running under HCE-tuned futility/EFP/
   LMR/NMP margins, which the first match-play result's analysis
   flags as a likely real confound, not yet quantified.
8. **In progress, first result in hand, inconclusive by design (stopped
   early).** `match_play.py` gate: 70 games, `-45 =0 +25`, llr nowhere
   near either SPRT bound. Not resumed yet -- current priority is
   closing the speed gap (quantization) before spending more match
   time on a comparison known to be confounded by it. Old `Eval()`
   stays live and is the default (`g_fUseNnueEval` defaults `FALSE`)
   until NNUE wins convincingly and consistently under this gate.

## Status (2026-10-15, quantization session): both quantization steps done, validated, and measured

Both steps of the "Quantization plan" above are now implemented, on
the `nnue-accumulator-plumbing` branch, in this order:

- **Step 1 -- embedding table int16, done.** `g_rgfNnueWeight`/
  `g_rgfNnueBias` (`chess.h`/`nnueaccum.c`) are now `SHORT`, at a fixed
  scale of 4096 (checked against `ft.weight`'s real max|.|=5.01 --
  `5.01*4096 ~= 20502`, well under `MAX_SHORT`, ~1.6x headroom for a
  larger Phase B net). `NNUE_ACCUMULATOR.rgiValue` (was `rgfValue`) is
  `INT` (int32), not `SHORT` -- summing up to 32 active features'
  `SHORT` rows can exceed `SHORT`'s range even though each individual
  row fits (measured worst-case real accumulator magnitude ~26 in
  float-equivalent units, i.e. ~106,500 at this scale -- 3x past
  `MAX_SHORT`). `NnueForward` converts back to float (divides by
  `g_fNnueFtScale`) only where the accumulator is read, never where
  it's maintained -- incremental `MakeMove`/`UnmakeMove` updates stay
  purely additive integer arithmetic, exact, no float drift.
  `testnnue.c`'s `TestNnueAccumulator` updated to match (integer test
  weights, exact-equality comparison instead of an epsilon tolerance --
  a real correctness upgrade this dtype change enabled, not just a
  mechanical port). Passes clean under `DEBUG=1 TEST=1`.
- **Step 2 -- FC head int8, done.** `g_rgfNnueFc1Weight`/
  `g_rgfNnueFc2Weight`/`g_rgfNnueFc3Weight` (`chess.h`/`nnueeval.c`)
  are `CHAR` (int8, signed), one weight scale per layer derived
  exactly from that layer's own max|.| (no sampling needed, weights
  are fixed and known). Biases stay `float32` (tiny arrays, added
  post-dequant, not worth quantizing). Activations are UCHAR (uint8,
  unsigned -- confirmed all three stages are strictly post-ReLU,
  `min=0.0000` on a real 50k-position sample, so no bits wasted on a
  sign). `NnueForward` rewritten stage-by-stage: dequantize accumulator
  -> ReLU -> requantize to UCHAR at AS1 -> int8x8 dot product
  accumulated in `INT` -> dequantize (divide by `AS*WS`) + bias -> ReLU
  -> requantize at AS2 -> repeat through fc2/fc3. Matches a Python
  numpy simulation of the identical quantized math, built and checked
  before touching any C code (see below).
    - **Activation scales (AS1/AS2/AS3) are calibrated from real
      sampled positions, not a theoretical bound** -- unlike weights,
      activation ranges aren't knowable from the checkpoint alone.
      `export_nnue_weights.py`'s `CalibrateActivationScales` runs
      50,000 real positions from `tensors_train/` through the float
      model with forward hooks, takes the observed max per stage, and
      applies a 1.3x headroom multiplier before deriving the scale
      (`AS = 255 / (observed_max * 1.3)`). Real observed maxes:
      `fc1_in` 27.12, `fc2_in` 19.11, `fc3_in` 7.72 (p99.9 well below
      max in each case -- confirms the max is tail-driven, headroom is
      doing real work, not padding noise).
    - **File format bumped to `"NNU3"`** (`NnueLoadWeights` rejects
      both older `"NNU1"` and `"NNU2"` files explicitly, not silently).
      Header now carries `ft_scale` (1x float), `act_scale` (3x float:
      AS1/AS2/AS3), `w_scale` (3x float: WS1/WS2/WS3) before the
      weight arrays themselves.
    - **Accuracy measured before touching any C code, same discipline
      as step 1's safety check**: a numpy simulation of the exact
      quantized int8 math (uint8 activations, int8 weights, int32
      accumulation, per-layer scales) against the real float model,
      5000 real holdout positions: **mean |delta| 1.55cp, median
      0.99cp, max 20.91cp, zero clipping anywhere** (weights or
      activations, all three layers, out of 2.56M+160K+1K+32 values
      checked). Context: the project's own `sd10`-vs-`sd16`
      depth-adequacy check (training-label noise already tolerated)
      measured mean |delta| 19cp -- this quantization's error is
      **over 10x smaller** than noise already baked into the training
      data. Confirmed again directly in C after implementation: `nnue
      eval` on 4 hand-picked real FENs vs the Python float reference,
      deltas of 1/2/11/0 cp -- consistent with the offline simulation,
      no sign of an implementation bug.
- **AVX2+FMA confirmed as a real, separate win once real weights are
  loaded** (not the "measured no-op for NNUE" result from the
  pre-quantization investigation above -- that measurement predated
  knowing which function actually dominated runtime, see the
  profiling finding below). `gmake AVX2=1`: `objdump` on the linked
  binary confirms `NnueForward`'s dot-product loops widen from 4-wide
  `xmm`/`mulps`+`addps` to 8-wide `ymm` with 99 real `vfmadd*` fused
  instructions. Measured: int16-table-only build went from ~381-384k
  nps (no `AVX2=1`) to ~409-410k nps (`AVX2=1`) -- a real, repeatable
  ~7% win, zero retrain, zero risk.
- **Profiling (`docs/PROFILE.md`'s FreeBSD `pmcstat -P` + `gprof` path)
  found `NnueForward` -- the FC head, not the embedding table --
  dominates runtime: 34.0% of total self-time, alone**, more than
  double the next entry (`SlidePiece`, 12.6%) and roughly on par with
  *all* accumulator-maintenance-related functions combined even when
  generously over-attributing their non-NNUE move-bookkeeping cost to
  NNUE. `NnueAccumAddFeature`/`RemoveFeature` don't even appear as
  separate flat-profile line items -- small enough to get inlined into
  their `SlidePiece`-family callers. This directly revised the
  quantization plan's priority ordering: the embedding table (step 1)
  was assumed memory-bound-dominant from the AVX2 experiment's
  "no-op for NNUE" result, but that result just meant AVX2 doesn't
  help *that* code path -- it didn't establish the table was the
  actual bottleneck. `fc1`'s matmul (`2*L1 x L2 = 2*256*32 = 16,384`
  multiply-adds per evaluated leaf) being far larger than `fc2`
  (1,024) or `fc3` (32) explains why: cost at each layer boundary
  scales with the *product* of adjacent layer widths, and the
  512-wide-to-32 transition is the one expensive step in this
  topology, more frequently hit (once per evaluated leaf) and more
  expensive per-call than the table gather (once per move, but only
  a handful of 256-element row reads).
- **Net bench result, chaining every change in this session**:

  | Build state | nps (real weights, `nnue on`) |
  |---|---|
  | Float32 everywhere (pre-session baseline) | ~411,000 |
  | int16 table, no `AVX2=1` | ~381,000-384,000 |
  | int16 table + `AVX2=1` | ~409,000-410,000 |
  | int16 table + int8 FC head + `AVX2=1` | **~559,000-563,000** |

  Net effect: **~37% faster** than the pre-session float32 baseline,
  closing the gap to classic HCE (~1.51M nps) from 3.9x down to 2.7x.
  Correctness verified at every step (self-tests, offline Python
  simulations before touching C, then direct C-vs-Python spot checks
  after).
- **`WS1`/`WS2`/`WS3` derivation note**: unlike the embedding table
  (one global scale, since it's one homogeneous array), each FC layer
  gets its *own* weight scale, derived from that layer's own max|.| --
  `fc1.weight` max 0.350, `fc2.weight` max 0.361, `fc3.weight` max
  0.334 (all much smaller than `ft.weight`'s 5.01, but the relevant
  comparison for int8 resolution isn't the max, it's the *step size*
  relative to each layer's std -- at full-range scale this works out
  to roughly 18 quantization levels per standard deviation for the FC
  weights, versus thousands for the int16 table. The 1.55cp measured
  result shows this coarser per-weight resolution doesn't translate
  1:1 into coarser *output* accuracy -- dot-product summation over
  many terms (512/32/32) partially cancels independent rounding
  errors, and ReLU's sparsity (46-69% exact zeros measured on real
  activations) contributes zero error for free on a large fraction of
  terms.

## Lazy-evaluation tiering plan (started 2026-10-15, not yet wired into search)

Classic `Eval()` has three real exits -- super-lazy (material only),
lazy (material + a defined subset of positional terms), full -- each
gated by a margin derived from a *measured population* of real
positions, not a modeled/assumed distribution. `Eval()`'s current NNUE
short-circuit (added when NNUE was first wired into search, see
"Status (2026-10-15): training run finished" above) bypasses all of
this -- it's an unconditional full `NnueForward()` call, with a comment
explicitly noting the margin machinery below it "has not been
recalibrated against NNUE's score distribution yet." This section
plans closing that gap.

**The mechanism, read directly out of the live code** (not
reconstructed from memory -- see `eval.c`'s
`iSuperLazyResidualPessimisticByOwnArmy`/`OptimisticByOwnArmy`/
`P50ByOwnArmy` tables and their use in `Eval()`'s super-lazy exit,
~line 7150):

- Every one of these tables is **literally empirical percentiles read
  off a measured population of real positions** -- the comment blocks
  above several of them show raw population stats before the table
  (e.g. `n=104187433 min=-68 p1=-10 ... p50=10 ... max=165`). No
  distribution shape is assumed; the percentile is read directly off
  sorted real data, per material bucket (`_MaterialBucket`/per-side
  army-scaler bucket, 8 buckets in the existing tables).
- **Two different statistics for two different jobs, not one margin**:
  - **p50 is a bias correction** -- the typical/unbiased value fed to
    `iPositional` (a lazy exit's best-guess estimate of the missing
    positional terms, consumed downstream by e.g. QSearch's own
    futility margins). Current NNUE short-circuit reports a flat
    `{0, 0}` here -- the "intentionally least-specific but safe"
    placeholder, not a real estimate. A calibrated p50 of the probe's
    own residual could improve this once real residual data exists.
  - **p2/p98 (not p5/p95) is the soundness bound** -- answers "how far
    could the true full-eval score plausibly be from this cheap
    estimate, in the realistic tail," and is what actually gates
    whether the lazy exit is safe to take. The exact comment: *"p50 is
    the right statistic for a bias correction, p2/p98 is the right
    statistic for a soundness bound."*
  - Direction is asymmetric and combines two terms before a `MAX0()`
    floor: checking against `alpha` (could this fail low?) uses the
    **optimistic** tail (most generous plausible correction in the
    mover's favor); checking against `beta` (could this fail high?)
    uses the **pessimistic** tail (least favorable plausible
    correction). Getting this sign/direction wrong would silently
    invert the safety guarantee -- needs care, not pattern-matching,
    when implemented for real.
- This two-statistic pattern (p50 bias, p2/p98 safety bound) is what
  the NNUE lazy tier should reuse, not a new invention -- same
  methodology the existing calibration harness
  (`CALIBRATE_MARGIN_SAFETY`, `RecordSuperLazyMarginSafetySwing`, swing
  histograms bucketed by material) already proved out for classic
  `Eval()`'s own tiers.

**The proposed NNUE lazy-tier signal**: not a second, separately
trained net (that reintroduces the calibration-mismatch problem
already rejected in the "Architecture decisions" section above) --
instead, a **linear probe fit directly on the real net's own
accumulator** (`fc1`'s input: the ReLU'd, concatenated 512-wide
accumulator halves), which is already computed and resident at every
node for free as a side effect of `nnueaccum.c`'s incremental
maintenance, before `NnueForward`'s expensive `fc1->fc2->fc3` pass
(34% of runtime, see profiling finding above) ever runs.

**Progress so far**:
- **Offline probe fit, done, preliminary.** Ridge regression (512
  features + bias, lambda=1.0) fit against 80,000 real positions from
  `tensors_train/`, targeting the real float net's own raw output
  (`forward()`, pre-sigmoid). Saved to
  `annotate_pgns/probe_weight.npy`/`probe_bias.npy`.
- **Measured residual (full net score minus probe score), 20,000 held-out
  positions from the same sample**: mean |residual| 50.10cp, median
  33.09cp, p2=-166.79cp, p50=0.33cp, p98=+169.14cp. **An order of
  magnitude coarser than the int8 quantization's 1.55cp** -- expected,
  since a flat linear projection discards all of `fc1->fc2->fc3`'s
  nonlinear structure, unlike int8 which preserves the same
  computation at lower numeric precision. This sets real expectations:
  the implied safety margin (~150-200cp) is large relative to typical
  eval swings, so the lazy exit will only fire on nodes already
  lopsided relative to the search window -- a real but likely narrower
  slice of total nodes than hoped going in.
- **Real fit-vs-validation gap observed** (mean 37.85cp on the fit
  split vs 50.10cp held out, p98 153cp vs 195cp) -- larger than
  expected given 80k examples against 512 features plus ridge
  regularization. Not yet root-caused (could be regularization
  strength not cross-validated, could be real limits of a flat linear
  fit over this representation, could be this offline sample's size).
  **Flagged as unresolved, not yet trusted as final** -- this was an
  offline, single-position-sample check, explicitly a preliminary
  step, not the real calibration.

**Not yet done -- the actual next steps, in order**:
1. Root-cause the fit/validation gap above (cross-validate `lambda`,
   or just use more fitting data) before trusting any residual
   numbers from this probe as final.
2. **Build the real calibration pass**: a `CALIBRATE_MARGIN_SAFETY`-
   style harness for the NNUE lazy probe specifically -- same pattern
   as classic `Eval()`'s existing one (run the full `NnueForward` pass
   anyway even when the probe-based exit would fire, record the swing,
   restore the real build's behavior before returning) -- against
   **real search-node traffic**, not another offline position sample.
   This is the measurement that actually matters; the offline check
   above was only ever a cheap sanity gate before investing in this.
3. From that real population, derive p50 (bias correction) and p2/p98
   (safety bound) -- possibly bucketed by material/phase the way
   HCE's own tables are, possibly not. **Whether NNUE's accumulator
   already implicitly encodes phase** (unlike HCE's explicitly
   material-gated lazy terms) **is an open, empirically-answerable
   question from this same population** -- check whether the residual
   distribution actually shifts meaningfully bucket-to-bucket before
   committing to that complexity.
4. Wire the gate into `Eval()`'s existing NNUE short-circuit, using
   `iAlpha`/`iBeta` already passed into `Eval()` -- no new interface
   needed, this is a parallel early-return block next to the current
   unconditional bypass.
5. Ship it instrumented-only first (swing recorded, real build
   behavior unaffected) before trusting it live, same rollout
   discipline as every other change in this project.

The super-lazy (material-only, no net at all) idea from the
"Architecture decisions" section above remains separate, more
tentative, and explicitly not decided either way -- the calibration
risk there (no shared units with the WDL-sigmoid-calibrated net
output) is structurally different from this linear-probe tier, which
shares the real net's own trained representation and units by
construction.

## Status (2026-10-16): lazy probe gate shipped live, QSearch futility margins fixed, same-depth ECM win confirmed, overnight match queued

Picking up from the "Lazy-evaluation tiering plan" section above --
the fit-vs-validation gap was root-caused as pure sampling noise (more
data + the real `tensors_holdout/` split made it vanish; lambda barely
mattered, the model was never near overfitting), and everything from
"build the real calibration pass" onward is now done.

- **Fit/validation gap resolved**: 500k-example fit + real `tensors_
  holdout/` evaluation (not another ad-hoc split) showed holdout
  *better* than fit (38.68cp vs 41.09cp mean) -- the earlier 50.10cp
  number was noise from a too-small single sample, not overfitting.
- **Real calibration done properly, twice**: first pass used the three
  curated tactical suites (91M samples) -- too narrow a slice (hand-
  picked for sharp tactics), confirmed by a second pass against
  `tests/cross_sectional_200k.ep_` (200,000 diverse positions, 16
  parallel single-threaded shards, `sd 6`, 3.09 BILLION real search-
  node samples) landing close to but meaningfully more trustworthy
  than the first (p98 margins `[180, 200, 190, 180, 170, 160, 150,
  150]` by material bucket vs the tactical-suite-only `[190, 200, 200,
  190, 190, 170, 150, 150]` -- also surfaced real tail outliers (max
  534cp) the narrower sample never saw).
- **A real, separate bug found and fixed along the way**: chaining
  multiple commands after `script <file>` in one `--batch --command`
  string (e.g. `script a.ep_; script b.ep_; calibrate marginsafety`)
  silently truncates every search except the last to a shallower depth
  than requested -- `PushNewInput` (`input.c`) splits and queues
  *everything* up front, and `searchsup.c`'s mid-search
  `NumberOfPendingInputEvents()` check (legitimate for live xboard/
  WinBoard responsiveness) fires on that queued-but-irrelevant trailing
  text, treating it as "stop, something wants my attention." Root
  cause confirmed by direct repro (`sd 10` silently landing at depth 8,
  8x fewer nodes) -- **not fixed in the engine** (the interactive-
  responsiveness behavior is legitimate and deliberately left alone;
  the input-queueing semantics are too sensitive to touch for a
  testing-only problem that already has a zero-risk workaround).
  **Workaround, now the required pattern for any multi-step batch
  calibration**: never chain unrelated commands after `script` in one
  `--command` string; if a command needs to run after a suite finishes
  in the same process (e.g. `calibrate marginsafety`), put it as its
  own line *inside* the `.ep_` file instead -- `script.c`'s own
  `_ProcessSuiteLine` already dispatches any non-"go" line via the
  queue-free `ExecuteCommandLineDirectly`, so it runs in-order with no
  truncation risk. For N-way sharded runs (per CLAUDE.md's existing
  convention), each shard gets its own single `script` call + its own
  `calibrate marginsafety <path>` line inside the file, in its own
  process -- then combine the N raw histogram files after the fact.
- **The "super lazy" (material-only) tier was investigated and
  rejected as a live feature** -- `pos->iMaterialBalance[color]` is
  already in the same cp units as `NnueForward`'s output
  (`VALUE_PAWN=100`), so no fit/regression was needed at all (unlike
  the accumulator probe) -- straight subtraction, same harness. Real
  measured residual against the full net, same 3.09B-sample
  cross-sectional pool: mean 124-344cp, p98 510-1230cp by bucket --
  **6-8x larger than the accumulator probe's** (47-77cp mean, 150-200cp
  p98). Shape is an inverted-U by material bucket (peaks in the
  middle, smaller at both bare-king endgames and near-full-material
  positions), not the simple monotonic-opposite-of-the-probe trend
  originally guessed. Conclusion: material alone is too crude a proxy
  for *this* net's output to fire often enough to be worth it -- NNUE's
  accumulator captures far more positional judgment relative to
  material than a handcrafted eval does, unlike HCE where material
  really is close to the final answer. Not built as a live gate.
- **Lazy probe gate shipped live** (`nnue lazyprobeon`/`lazyprobeoff`,
  defaults `FALSE` same as `g_fUseNnueEval`): `Eval()`'s NNUE short-
  circuit now actually skips `NnueForward` when
  `NnueProbeScore() +/- margin` confidently resolves fail-high/fail-low
  against `[iAlpha, iBeta]`, dual-mode via `#ifdef CALIBRATE_MARGIN_
  SAFETY` (calibration build always still runs the full pass for
  measurement; release build actually skips). Held-out validation
  (`ecm_confident_sample2.ep_`, not used for calibration) showed
  exceeded rates mostly at or under the ~2% the p98 choice implies
  (buckets 2-7: 0.87-2.24%); buckets 0-1 ran hotter (3.47-4.67%,
  consistent with those being the smallest-sample buckets in
  calibration, so the noisiest estimates).
- **Measured lazy-probe exit rate on real search traffic** (`twic_
  sample.ep_`, `sd 6`): **14-23% of NNUE eval calls resolve via the
  cheap probe**, varying by material bucket (peak ~20-23% in buckets
  3-5, low 8.7% in bucket 7/near-full-material). Most calls still pay
  for the full forward pass.
- **Real, clean, sequential (not parallel -- see below) same-depth
  measurement, `ecm_hard_quick`, `sd 10`, gate off vs on**:

  | | OFF | ON |
  |---|---|---|
  | Solved | 32/90 | 33/90 |
  | Nodes | 47,790,608 | 50,438,386 |
  | Time | 112.4s | 107.1s |
  | nps | 425,271 | 470,938 |

  **~10.7% real per-node throughput win, no solve-rate regression**
  (if anything +1). Note nodes went *up* while time went *down* --
  the gate changes which nodes get pruned (approximate score at some
  nodes shifts alpha-beta cutoffs), so node count isn't held constant
  the way `sd` depth is; nps is the cleaner isolated signal.
  **Caveat on parallel-run timing noise, confirmed twice this
  session**: running gate-off and gate-on as simultaneous background
  jobs on a shared box can be contaminated by real external load (cron
  jobs, rsyncs, other users) even when each job's own `--cpus 1` leaves
  plenty of idle capacity -- the fix was a clean *sequential* run on an
  otherwise-idle box, not avoiding parallelism itself (6-way `--cpus 1`
  parallel jobs were confirmed fine once the box was actually quiet).
- **Full three-suite combined result, same clean methodology**:
  108/191 (56.5%) solved OFF vs 112/191 (58.6%) ON, total time 247.7s
  vs 240.3s (-3.0%) -- consistent with the single-suite sequential
  number, no regression anywhere.
- **Independently, at the SAME fixed `sd 10` depth (no lazy gate,
  separate from all of the above): NNUE solves substantially more ECM
  tactics than classic HCE** -- 108/191 (56.5%) vs HCE's 72/191
  (37.7%) across all three curated suites. Since depth is held
  constant, this isn't explained by NNUE's remaining speed deficit.
  **Correction, 2026-10-17: do not read this as evidence the net
  plays better chess.** Every real head-to-head game match run
  since (see "Status (2026-10-17)" below -- five independent
  configurations: two different nets, `st 4` and `sd 10`, with and
  without the lazy probe) has NNUE losing decisively to classic HCE,
  including at matched search depth where this ECM result would
  predict an NNUE edge. ECM is a narrow slice (forced-tactic/mate
  puzzles, where qsearch/extensions/move-ordering machinery shared
  by both evaluators does most of the work) and evidently does not
  generalize to real-game strength. As of this writing, solving more
  ECM puzzles than HCE is the *only* thing either NNUE net has been
  shown to do better than HCE -- it has not won a single head-to-head
  match, at any depth or time control tried.
- **QSearch futility-pruning margins needed their own investigation,
  separate from the eval-exit-gate margins above** -- `PLY_INFO.
  uLastPositionalSource` (the per-ply tag `search.c`'s
  `FUTILITY_BASE_MARGIN_BY_SOURCE[]` reads to decide how much slack to
  give QSearch's delta/futility pruning) was **never being set at all**
  by either NNUE exit -- every NNUE-sourced score was silently
  inheriting a stale value from the last classic-`Eval()` call at that
  ply slot (or the C zero-init default, `EVAL_POSITIONAL_SOURCE_FULL`,
  if none ever ran). Fixed: added `EVAL_POSITIONAL_SOURCE_NNUE_FULL`/
  `NNUE_LAZY`, set at both NNUE return sites, each with its own
  `FUTILITY_BASE_MARGIN` entry and always-on exit-rate counters
  (`calibrate exitrate` extended to report NNUE's own lazy-vs-full
  split, separate from the eval-exit gate's).
    - **First real measurement** (`twic_sample.ep_`, 1500 positions,
      `sd 6`, the same pool/depth the *original* classic margins were
      derived from): `nnue full`'s surprise rate was 7.41% in the
      checking-capture/promo gate at the 450 starting placeholder --
      well above anything in classic's documented history. Widening to
      650 tested as a complete no-op (identical 7.41%, identical `n`).
    - **Root-caused, not just observed**: added a direct diagnostic
      (raw pre-clamp `iFutility` value, before `MAX0`) and confirmed
      `nnue full`'s population is **100.00% clamped to the `MAX0`
      floor** (mean pre-clamp -589.3) -- the margin constant is never
      actually read for this population, so no value could have shown
      an effect. Classic `full eval`, measured the identical way on
      the identical pool, is clamped 91.05% of the time (not 100%,
      and less deeply negative: mean -282.5) -- saturation is a real,
      general QSearch structural property (not NNUE-specific: classic
      `regular lazy`'s 24.95% clamp rate matches `nnue lazy probe`'s
      24.76% almost exactly), but NNUE's `FULL` tier saturates somewhat
      more completely than classic's.
    - **Surprise magnitude (not just rate) measured too**, resolving
      a live "is NNUE just noisier" vs "does NNUE miss bigger things"
      question: `nnue full`'s surprises, when they happen, run
      *smaller* than classic `full eval`'s (mean 477.5cp vs 938.7cp,
      `>100cp` 9.88% vs 28.74%) -- same pattern for `nnue lazy probe`
      vs classic `regular lazy` (5085.3cp vs 11030.2cp mean; 19.45% vs
      69.73% `>100cp`). So the net effect is: NNUE_FULL fails *more
      often* but *less expensively* each time -- consistent with more
      frequent small eval jitter near the decision boundary, not with
      NNUE being tactically blind to bigger things than HCE already
      is. One sample verified by hand (a genuine `Qh7+` mate-in-1,
      confirmed independently via a real `sd 16` search) to make sure
      the diagnostic's rare huge-magnitude entries were real finds, not
      a measurement bug.
    - **Decision: `FUTILITY_BASE_MARGIN_NNUE_FULL` reverted to 450**
      (matching classic `FULL`) rather than kept at the untested 650 --
      the saturation finding means neither value was actually
      validated by this data, and 450 is the more defensible default
      given NNUE_FULL's measured surprises skew smaller than HCE's own.
      **`FUTILITY_BASE_MARGIN_NNUE_LAZY=200` kept** -- this one *is*
      genuinely validated (only 24.76% clamped, moved the expected
      direction when tightened from 350, landed in a sane 1.3-2.1%
      surprise-rate range, smaller surprise magnitudes than classic's
      own `regular lazy`).
    - **TODO, not yet done**: find and calibrate against the actual
      non-saturated `NNUE_FULL` population specifically (the ~0% slice
      this test pool never surfaced) before trusting any `NNUE_FULL`
      margin as real, NNUE-specific-validated rather than a reasonable
      borrowed default. Likely candidate: nodes where `iEval` sits
      close to `iAlpha` rather than comfortably above it (the
      saturated case is "`iEval` way above `iAlpha`" -- look at the
      complement).
- **Match-play CLI wiring added, needed before any NNUE+lazy-gate
  match can run at all**: `--nnueweights` already loaded weights and
  set `g_fUseNnueEval=TRUE` at startup (for `match_play.py`'s fresh-
  process-per-game spawning, no interactive commands available) --
  but there was no equivalent for the probe/lazy gate at all. Added
  `--nnueprobeweights <file>` (same convention, sets
  `g_fUseNnueLazyProbe=TRUE` automatically), and extended `eval_tune/
  test_vs_head.sh`'s `NNUE_WEIGHTS` wrapper with a new
  `NNUE_PROBE_WEIGHTS` env var that injects `--nnueprobeweights` into
  the same shim. Verified working end-to-end (both flags load and
  enable correctly) before trusting it for an unattended run.
  **Gotcha**: `test_vs_head.sh`'s default (no candidate path given)
  rebuilds the candidate itself via `gmake clean && gmake release
  MP=1 PERF_COUNTERS=1` -- **without `AVX2=1`** -- silently losing the
  ~7% AVX2/FMA win if not passed a pre-built candidate path instead.
- **Overnight `match_play.py` run queued** (not yet run/reported) --
  NNUE+lazy-gate candidate vs classic HCE `head_reference`, using the
  new `NNUE_WEIGHTS`/`NNUE_PROBE_WEIGHTS` env vars and an explicit
  pre-built `AVX2=1` candidate binary to avoid the gotcha above.

## Next up

1. Read the overnight match result once it's in -- first real
   judgment-quality read with both the quantization work and the lazy
   probe gate live together, at a real time control rather than fixed
   `sd` depth (so the remaining ~2.7-3.2x speed gap is a live confound
   again, same as the original 70-game result, but now with a smaller
   gap and the added lazy-gate throughput).
2. The `NNUE_FULL` futility-margin TODO above -- find the non-saturated
   population and calibrate against it specifically, rather than
   continuing to rely on a borrowed classic-HCE default.
3. Decide whether the ~3-11% nps win from the lazy probe gate
   (measured) is worth the complexity on its own, or whether it's more
   valuable combined with further quantization work on the FC head's
   remaining float32 pieces (biases) or other speed levers -- revisit
   once the overnight match gives a real strength read to weigh
   against.

## Status (2026-10-17): material-blindness fixed and verified, but zero
## head-to-head wins against classic HCE in five independent trials

**Summary, stated plainly up front**: a real, measured defect (the net
could not distinguish losing a pawn from losing a queen in crowded
midgame positions) was found and fixed this session. The fix is
verified to work, at the population level, with a new reusable tool.
But **no NNUE configuration tried today -- old net, new net, with or
without the lazy probe, at `st 4` or at matched `sd 10` depth -- won
or even drew competitively against classic HCE.** The only thing
either net has been shown to do better than HCE is solve more ECM
puzzles at matched depth (see the correction just above). Do not
read anything below as evidence the net plays winning chess yet.

### The material-blindness bug, found and fixed

- **Found via `sanity_check_net.py`** (new tool, `annotate_pgns/`):
  loads a raw training checkpoint directly (no export/C round-trip
  needed -- the earliest point in the pipeline a net can be checked)
  and removes one piece at a time from hand-picked and (better) real
  sampled positions, reporting the score delta. On the Phase A net
  trained with plain `λ=1.0` MSE against WDL-sigmoid targets: removing
  a pawn, a knight, a rook, or a queen from the same midgame position
  produced nearly indistinguishable score drops (-0.56 to -0.70) --
  directionally correct, magnitude-blind.
- **Root cause, two compounding mechanisms, neither one specific to
  which corpus a position came from**:
  1. Sigmoid-gradient argument (later found incomplete on its own --
     see below): training loss is MSE against `sigmoid(raw/K)`, and
     sigmoid's gradient vanishes once a position is already clearly
     lost/won.
  2. **Feature dilution / shortcut learning in crowded positions**
     (the more complete explanation): with ~20+ active embedding
     features summed per position, a net can match training labels
     well without correctly attributing any single piece's marginal
     value, because real games never present a controlled
     single-piece-ablation pair to force that attribution. Confirmed
     empirically: the bug was severe in crowded midgame positions and
     essentially absent in sparse endgames (2-6 pieces), where each
     piece dominates a much larger share of the accumulator.
- **Data rebalancing (TWIC downsampled to match self-play 1:1 by game
  count) was necessary but not sufficient.** It measurably helped the
  sparse-endgame case (which TWIC's human-resignation bias
  structurally starves) but did nothing for the crowded-midgame case,
  confirming the bug was a representation/optimization problem, not
  purely a data-coverage gap.
- **Two cheap levers tried and only partially effective on their
  own**:
  - `loss_exponent=2.6` (Stockfish's own documented value, reweights
    the loss toward large errors): modest effect, spread narrowed but
    monotonicity still failed, bishop/rook order still inconsistent
    across test positions.
  - λ-sweep (blend in `r_outcome`, game result): **made things
    actively worse** -- λ=0.5/0.0 flipped the sign of several
    deltas (removing a piece appeared to *help* the score). See the
    "λ-sweep methodology was weaker than later standards" note below
    before trusting this conclusion, though -- it has not been
    properly re-tested since.
  - **Lesson from this part of the investigation**: a single
    hand-picked test position cannot reliably settle a question like
    "does the net value a bishop more than a rook" -- one test showed
    bishop > rook, a different hand-picked position showed the
    reverse. This is per-position confounding (an active bishop vs.
    a boxed-in rook on that specific square/position), not
    necessarily evidence about the net's general prior.
- **Fix that worked: an auxiliary material-probe loss**
  (`nnue_model.py`'s `material_probe`, a plain `Linear(512, 1)`
  reading the same concatenated accumulator `fc1` does). Trained
  jointly with the main WDL loss: `loss = wdl_loss +
  material_loss_weight * mse(material_probe(accumulator),
  material_balance)`, where `material_balance` is computed directly
  from the FEN (pawn=1..queen=9, mover-relative, no sigmoid --
  deliberately non-saturating). `material_loss_weight=0.01` chosen to
  keep the material term's raw contribution smaller than the WDL
  loss's throughout training (per the user's explicit concern about
  over-fixating on material, HCE-style) -- confirmed: material loss
  contributed ~0.019 vs. WDL's ~0.044 at epoch 0, ~0.007 vs. ~0.022 at
  epoch 19.
  - **Mechanistically why this is safer than adding material to the
    output directly**: the probe loss only updates `material_probe`
    itself and (via backprop) the shared `ft` embedding table -- it
    never touches `fc1`/`fc2`/`fc3`, the actual eval head. The eval
    head stays free to deviate from pure material counting for
    positional reasons; only the *representation underneath it* is
    forced to keep material linearly recoverable.
  - Needed more epochs than the baseline run to show its full effect
    -- material-probe MSE was still falling at epoch 19 (not
    plateaued), extending to epoch 34 took it from a single hand-picked
    test's queen delta of -0.815 (already beating rook's -0.727) to
    a much larger, cleaner separation.

### `sanity_check_net.py --cross-sectional`: the real validation tool

Single hand-picked positions were repeatedly shown to be unreliable
(see above) -- replaced with a population-level check: sample N real
positions from a holdout pool, remove one random piece of each
present type per position, pool deltas by piece type across the whole
sample, report mean/median/std/p25/p75. On the matprobe net
(2000 sampled holdout positions): **population means came out cleanly
monotonic in piece value, both directions** -- pawn -0.747/+0.786,
knight -1.363/+1.581, bishop -1.500/+1.710, rook -2.483/+2.907, queen
-4.160/+5.024 (own-piece-removed / opponent-piece-removed). The
earlier bishop-vs-rook inconsistency vanished entirely at the
population level, confirming it was per-position noise, not a real
defect. Scaling is compressed relative to nominal chess piece values
(queen/pawn ratio ~5.6x here vs. nominal 9x) -- expected and likely
correct, not a bug, since training targets are WDL-sigmoid-calibrated
scores, not raw material counts, and a large material edge's marginal
effect on win probability is genuinely smaller than its point value.

This tool (`annotate_pgns/sanity_check_net.py`) should be run on any
future checkpoint before trusting a single hand-picked-position read
-- `--cross-sectional --n-positions 2000` against a holdout pool,
`python3 sanity_check_net.py --ckpt <path>` for the quick hand-picked
smoke test.

### Export and C-side validation: clean

`export_nnue_weights.py` (already quantizing directly to the `NNU3`
format, no separate float32-only export step in the current pipeline)
ran against the matprobe checkpoint with zero code changes needed --
it only reads `ft`/`fc1`/`fc2`/`fc3` by name, so the auxiliary
`material_probe` layer is silently and correctly ignored at export
time. 6 real holdout FENs spot-checked, Python float vs. C
int16/int8-quantized: deltas of 0.3-4.7cp, same ballpark as the
1.55cp-mean/20.91cp-max already measured and accepted for the first
net's quantization step. No sign/implementation bug found.

### Five independent head-to-head trials, all losses

All via `eval_tune/test_vs_head.sh` with `NNUE_WEIGHTS` set (and, for
two of the five, `NNUE_PROBE_WEIGHTS` pointing at the OLD net's lazy
probe -- see the stale-probe caveat below), against the classic-HCE
`head_reference`-equivalent head binary:

| # | net | time control | result (at stop) | llr | candidate depth vs head |
|---|---|---|---|---|---|
| 1 | matprobe (new) | `st 4` | -27 =2 +1 (n=30), score 0.067 | -2.07 | 12.16 vs 15.08 |
| 2 | v3 (old) | `st 4` | -29 =1 +0 (n=30), score 0.017 | -13.05 | 10.74 vs 13.51 |
| 3 | v3 (old) | `sd 10` | -28 =2 +0 (n=30), score 0.067 | -1.52 | 9.57 vs 9.23 |
| 4 | matprobe (new) | `sd 10` | -24 =4 +2 (n=30), score 0.133 | -0.97 | 9.81 vs 9.70 |
| 5 | v3 (old) + lazy probe | `sd 10` | -30 =0 +0 (n=30), score 0.000 | (total wipeout) | 9.63 vs 9.25 |

**Key findings**:
- **The new (matprobe) net consistently loses less badly than the old
  net, under every condition tested** (trial 1 vs 2 at `st 4`: score
  0.067 vs 0.017; trial 4 vs 3 at `sd 10`: score 0.133 vs 0.067). The
  material fix is a real, reproducible improvement -- just nowhere
  near enough to close the gap with HCE.
- **At matched `sd 10` depth (trials 3 and 4), NNUE still loses
  decisively** -- this directly contradicts the working hypothesis
  (built from the ECM result above) that the real-time losses were
  purely a depth/speed-handicap artifact. They are not purely that.
  Matching the final depth number does not mean matching search
  quality throughout the tree -- EFP/LMR/NMP/futility margins are
  still HCE-tuned in every one of these trials (see "Integration into
  search" above, still not done) and could still be causing bad
  cutoffs specific to NNUE's score distribution even when the
  reported depth matches.
- **The lazy probe used in trial 5 is STALE and the result should be
  discounted**: `annotate_pgns/nnue_probe.bin` was fit against the OLD
  net's (v3) accumulator specifically, before this session's retrain.
  It happens to be the correct vintage for trial 5 (v3 + v3's own
  probe), so that comparison is internally consistent, but no probe
  exists yet for the matprobe net -- `typhoon_nnue`'s wrapper script
  was updated this session to point at the matprobe net by default
  and to NOT fall back to the stale probe file (previously silent and
  wrong). A new probe would need to be fit + calibrated from scratch
  against the matprobe net's accumulator before it could be used in a
  trial 4-equivalent run.
- **A user-remembered "NNUE competitive or better" result could not
  be reproduced under any of these five conditions.** Likely
  explanations, unconfirmed: a different match configuration entirely
  (not `st 4` or `sd 10` against this head binary), or a misremembered
  result. Do not plan further work assuming that result is real until
  it can be reproduced under a specific, documented configuration.

### Pipeline sanity check: ruled out a structural bug

Given five straight losses, including at matched depth where ECM
predicted an edge, the obvious question was "is the training pipeline
itself broken" (bad feature indexing under real gradient flow, broken
accumulator math, a training-loop bug) rather than a genuine
net-quality gap. Tested directly (`annotate_pgns/
overfit_sanity_check.py`, new tool): sampled 3000 real positions,
scored each with the engine's own classic `Eval()` (fast
persistent-context `eval` command), and trained the *same*
architecture to directly regress raw output against that score (plain
MSE, no sigmoid, no K, no λ, no quiet filter, no quantization -- the
simplest possible test of "can this architecture memorize a small,
clean, noiseless target at all"). **Result: clean overfit** -- RMSE
collapsed from 307cp to ~3-5cp over 400 epochs on the fixed 3000-point
set (max error 46cp). **No structural bug found.** The five
head-to-head losses are real evidence about net/training quality, not
a pipeline defect.

### Revised understanding of why: likely bootstrapping off HCE's own
### search sets a ceiling, not (as earlier argued) label noise or
### architecture capacity

Two explanations floated earlier in this investigation don't hold up
well under scrutiny:
- **Architecture capacity**: the 256x2-32-32-1 shape is the literal
  one Stockfish's own early, successful NNUE work used. Not a good
  suspect.
- **"Search-score labels are noisy"**: standard practice (`sd 8`-ish
  search-score training is exactly what real NNUE projects use); the
  depth/source of search isn't inherently suspect.

**The sharper version of the concern**: every label in the current
training pool -- TWIC and self-play both -- comes from THIS SAME
engine's own classic `Eval()` run through its own `sd 10` search, and
training used `λ=1.0` (pure `p_search`, zero weight on actual game
`r_outcome`). A net trained to perfectly reproduce "HCE's static eval,
distilled to one forward pass" can at best approach HCE's *positional
judgment* -- it has no training signal that could let it exceed HCE's
actual playing strength, which is that judgment plus a mature,
decades-tuned full search. This is single-generation distillation
from the exact opponent being benchmarked against, with no
independent ground truth in the loop. Real NNUE bootstrapping success
stories (including Stockfish's own) iterate through many generations,
each net's own self-play becoming the next round's data, with game
outcomes mattering throughout -- not one shot of pure
distillation-from-one-engine's-shallow-search.

**This reframes the earlier-rejected λ-sweep as an open question
again, not a settled one** (see the "λ-sweep methodology was weaker
than later standards" note above) -- a properly-controlled λ=0.5 run
(combined with the material-probe fix, trained to real convergence,
judged with the cross-sectional population check, not a single
hand-picked position) is in progress as of this writing to answer
this properly.

### Next up

1. **In progress**: λ=0.5 + material-probe-loss combined run, trained
   to convergence, judged with `sanity_check_net.py --cross-sectional`
   -- the properly-controlled version of the λ-sweep question above.
2. Once a net shows a real head-to-head result against HCE (win, draw,
   or a meaningfully-less-bad loss than trial 4's 0.133), THEN invest
   in margin recalibration (EFP/LMR/NMP/futility, still 100% HCE-tuned
   today) and a new lazy-probe fit for whichever net is being kept --
   not before, per this session's experience chasing search-side
   explanations that turned out to not be the dominant factor.
3. Data volume scale-up (TWIC + self-play, more self-play generations
   specifically using the matprobe-or-later net to play, per the
   original Phase 2 plan) remains the most clearly-actionable lever,
   but should be paired with an actual outcome-grounding signal in the
   training target, not just more HCE-sd10-labeled positions of the
   same shape already in hand.

## Status (2026-10-18): Stockfish-labeled relabel in progress; feature
## factorization planned as the next architecture-side lever

Labels are being regenerated using Stockfish (not this engine's own
`Eval()`/`sd 10`) to break the single-generation self-distillation
problem described above -- that's a speed/ground-truth fix, not
believed to be the root cause of the five head-to-head losses by
itself. In parallel, researching Stockfish's own NNUE docs surfaced a
second, independent candidate explanation worth trying once there's
enough Stockfish-labeled data to re-run training: **feature
factorization** (Stockfish's term for a training-only "virtual
feature set" that is coalesced into the real weights post-training;
see https://github.com/official-stockfish/nnue-pytorch/blob/master/docs/nnue.md#feature-factorization,
excerpted into this conversation).

### Why this looks like a real lever here, not just a ported idea

The current feature set is HalfKAv2_hm per the canonical spec above:
22528-dim, king-bucketed (32 buckets via `king_bucket()`'s
horizontal-mirror quartet), so every `(piece_square, piece_type,
piece_color)` combination exists as up to 32 separate rows, one per
king bucket, each seen only when the king happens to sit in that
bucket. Checked directly against the current real training cache
(`runs/sd10_twic_2M/tensors_train`, 1,848,411 positions):

- **23.9% of the 22528 HalfKAv2 rows have zero observations** --
  never fire once in training, never receive a gradient update.
- **60.5% have fewer than 50 observations.**
- Collapsing the same data to the king-bucket-independent "P" feature
  set (704 rows: `piece_square x piece_type x piece_color`, no king
  info) gives a median of **8375** observations per row -- roughly
  32x denser, as expected, with none left permanently untrained.

So a substantial fraction of the net's weight space is currently
stuck at random init or barely trained regardless of label quality --
plausibly part of why two different training attempts "worked"
(loss went down, no crash) but neither net could beat HCE in
head-to-head play. This is Stockfish's own "dusty corners" framing:
sparsity from king-conditioning starves rare (king, piece, square)
combinations of signal, which factorization is specifically designed
to fix during early training.

### Planned implementation (not yet done -- next after enough
### Stockfish-labeled data exists to re-run training)

1. **No change to `build_training_tensors.py` / no cache rebuild
   needed.** The HalfKAv2_hm index formula is
   `idx = (sq ^ mask) + piece_plane + king_bucket * PS_NB` where
   `(sq ^ mask) + piece_plane <= 703 < PS_NB (704)`. So the P-factor
   index is exactly `real_idx % 704` -- recoverable from the existing
   `white_idx`/`black_idx` tensors at train time, in `nnue_model.py`.
2. **`nnue_model.py`**: add a second embedding table,
   `self.ft_p = nn.EmbeddingBag(704, l1, mode="sum")`, **zero-initialized**
   (matters: both tables sum into the same accumulator, so a
   default-init P table would double the accumulator's scale at step
   0, confounding the factorization effect with an effective LR
   change), with no bias of its own (`ft_bias` stays as the only
   bias, or coalescing below isn't exact). In `accumulator()`, derive
   `white_idx % 704` / `black_idx % 704` and sum `ft_p`'s output into
   each perspective's accumulator alongside `ft`.
3. **Skip the "K" and "HalfRelativeKP" factors** Stockfish's doc also
   describes. HalfKAv2_hm already encodes the king directly (kings
   are included in the `piece_map()` loop via `piece_plane()`'s
   `PS_KING` case), so the K factor would be redundant here. One
   variable at a time, per the incremental-change rule used
   throughout this project -- P alone first, measure, then decide.
4. **Coalescing after training, in `export_nnue_weights.py`**: this is
   the step most likely to silently fail, because a bad coalesce
   produces a net that is *worse* than not factoring at all (the real
   HalfKAv2_hm rows learn less because the factor absorbed the shared
   signal) and the training loss curve won't show it either way.
   `ft.weight += ft_p.weight[torch.arange(22528) % 704]`, then drop
   both `ft_p` and `material_probe` from the exported weights (two
   training-only heads now, same pattern). **Required check**: assert
   the pre-coalesce factored forward pass and the post-coalesce
   plain-`ft`-only forward pass agree to ~1e-5 on a real batch before
   trusting the export.
5. **Evaluation protocol**: Stockfish's doc is explicit that the
   factorizer's effect is concentrated in *early* training and fades
   as training progresses -- compare early-epoch loss descent against
   a matched-seed, matched-epoch unfactored control, not just final
   validation loss. A final-loss-only comparison could read a real
   early-training effect as zero.

### Sequencing

This factorization work is queued behind the in-progress Stockfish
relabel -- the two are independent, complementary levers (ground-truth
quality vs. training-signal density under sparsity), and per this
project's incremental-change discipline they should be evaluated one
at a time rather than conflated in a single before/after comparison.

## Status (2026-10-18, continued): Stockfish-labeled corpus built, P-factor
## factorization implemented and measured -- why it's load-bearing, not optional

Both levers above are now done, in sequence, against the new
`pgn/stockfish_self_play_training_data.pgn` corpus (35,295 Stockfish-
vs-Stockfish games, 4,008,045 positions, labeled via Stockfish's own
search rather than this engine's `Eval()` -- see `extract_selfplay_
positions.py`'s `MATE_CLAMP_THRESHOLD=90.0`, needed because this
foreign-engine corpus encodes mate as a clamped float, `[99.00,99.99]`
pawns via PolyGlot's UCI-to-xboard bridge, not typhoon's own `+M`/`-M`
sentinel token). Full pipeline run at `lambda=0.75` (`target =
0.75*p_search + 0.25*r_outcome`, chosen over the Phase-A `1.0` default
specifically because pure single-engine-distillation was the leading
suspect for the prior two nets' failure to beat HCE): `split_holdout.py`
-> `fit_k_and_label.py` (K=2.4130, this corpus's own fit, NOT the TWIC-
fit `DEFAULT_K=1.3123` -- the two corpora have different score
distributions and must not share a K) -> `quiet_filter_games.py` ->
`build_training_tensors.py` (now with `--workers`, default 16,
multiprocessing-sharded across positions, verified byte-for-byte
identical output to the old sequential path -- the per-position
feature-index computation has no cross-position dependency, so this
was a clean win on an idle multi-core box).

**Why factorization is necessary here, not a nice-to-have**: measured
directly on this corpus's real training tensors (not a TWIC estimate),
2,237 of HalfKAv2_hm's 22,528 rows (9.9%) never fire once across all
2.84M training positions -- confirmed, after training, to be **bit-
for-bit identical** to their random pre-training init (diffed the
trained checkpoint's `ft.weight` against a freshly re-seeded,
untrained `NnueNet()` at the same seed: max difference on those rows
is exactly 0.0, vs. a clearly nonzero shift on every observed row).
This 9.9% figure is *better* than TWIC's 23.9% (this corpus's
decisive, played-to-conclusion games naturally cover more king
positions than human-resignation-biased TWIC), but still real, and
**not fixable by collecting more data at any practical scale**:
subsampling the existing pool at increasing sizes and fitting the
shrinkage curve (`zero_count ~= 5529 - 226*ln(N)`, log-linear, fit on
6 points from 10% to 100% of the current 2.84M-position pool) says
halving the dead-cell count to ~1,118 needs ~290M positions (~100x
the current pool), and getting under 500 needs ~4.4B (~1,560x). Brute-
force self-play volume cannot close this gap; it needs an
architecture-side fix, which is exactly what factorization is for.

**Implementation** (`nnue_model.py`, `export_nnue_weights.py`), per
the plan above, built essentially as specified: a second, zero-
initialized `ft_p` `EmbeddingBag(PS_NB=704, l1)`, summed into the
accumulator alongside `ft` via `idx % PS_NB` (same bag offsets, no
`build_training_tensors.py` changes needed), coalesced into `ft.weight`
at export time (`ft.weight += ft_p.weight[arange(22528) % 704]`) with
a required pre/post-coalesce forward-pass agreement check (measured
3.58e-07 on a random-weighted smoke test, comfortably inside the
~1e-5 tolerance) before `ft_p` is dropped from the exported file.

**Measured effect, matched-seed/epoch (lambda=0.75, K=2.4130,
material-loss-weight=0.001 -- see the next note on why that changed
from 0.01, 34 epochs, otherwise identical hyperparameters)**:
- Holdout MSE at matched checkpoints: factored beats unfactored at
  every logged step, not just early training (contra the "effect
  fades" caveat in the plan above, which turned out not to dominate
  here) -- e.g. step 4000/epoch 11: 0.01193 (factored) vs. 0.01446
  (unfactored); final epoch 33: 0.01201 (factored) vs. 0.01331
  (unfactored).
- `sanity_check_net.py --cross-sectional` (2000 sampled holdout
  positions, final checkpoints): both nets monotonic in piece value,
  but factored's queen/pawn ratio is closer to nominal (~8.1x own /
  9.8x opponent-removed, vs. the already-decent unfactored run's
  ~8.2x/9.9x -- both well above the old TWIC-trained matprobe net's
  ~5.6x).
- Compared against a REAL Stockfish net's NNUE output (see next
  section) on 1000 shared holdout positions: factored wins on every
  metric -- correlation 0.879 vs 0.861, mean|diff| 0.776 vs 0.852
  pawns raw, 0.718 vs 0.762 pawns after fitting away the overall
  scale difference (both nets run at ~0.79-0.83x Stockfish's scale,
  expected since ours is WDL-sigmoid-calibrated, not raw-centipawn-
  trained).

**`material-loss-weight` lowered from 0.01 to 0.001 for this corpus**,
before any of the above: the original 0.01 (see "material-blindness
fixed" status above) was tuned against TWIC's homogeneous, balanced-
middlegame material distribution, where the raw material-MSE term
was naturally small and its weighted contribution stayed well below
the WDL loss's. This corpus has far more material-imbalanced
positions by construction (decisive self-play games played to real
conclusions, not human-resignation-biased), so the same 0.01 weight
measurably put the material term at parity with the WDL loss rather
than a gentle nudge -- confirmed by briefly launching training at
0.01 before catching it (checkpoints `ckpt_epoch000-001.pt` from that
run were deleted, not used for anything above).

**Known residual gap, not yet fixed**: `ft_p`'s zero-init was
deliberate (avoids confounding the factorization effect with an
init-time accumulator-scale change), but `ft` itself still uses
`nn.EmbeddingBag`'s default `N(0,1)`-per-entry init, which is NOT
zero -- so an untouched row's random contribution is the same
magnitude (`norm~16`) as a fully-trained row's, not suppressed by
scale. Today this is masked by `ft_p`'s well-trained, shared
correction dominating the sum at those positions (confirmed: 0/1000
Stockfish-comparison positions activated any dead cell, so this gap
hasn't tainted any reported result above), but it's a real residual
risk for any future position that activates several dead cells
together. Cheap, not-yet-applied fix: zero-init `ft` the same way
`ft_p` already is, so a dead cell degrades gracefully to "contributes
nothing" rather than "contributes untrained noise."

## Verifying against a real Stockfish net (one-time diagnostic, not a
## pipeline dependency)

To sanity-check whether the above was in the right ballpark at all (as
distinct from reusing Stockfish's own weights, which was explicitly
ruled out -- the goal is a from-scratch net strong enough to self-play
and iterate on, "eating its own tail," not a transplant), pulled a
real, period-appropriate Stockfish net (`nn-fdc1d0fe6455.nnue`, added
to Stockfish 2023-06-12 per `github.com/official-stockfish/networks`'
commit history, rev `38e61663d836e062af0bc002814ad5149c4b7729`) and
parsed its `FeatureTransformer::psqtWeights` table directly, against
that exact commit's header-reading code (fetched and read, not
guessed -- a first attempt at parsing by eye got the header layout
wrong and was caught before trusting any conclusion from it).

- **Confirmed identical feature space**: `HalfKAv2_hm::HashValue =
  0x7f234cb8` and `Dimensions=22528` match our own spec exactly (same
  king buckets, same orientation table, same piece-square planes);
  this net's stored `ft` hash decodes to `L1=1536` (vs. our 256) via
  `ft_hash = HashValue XOR (2*L1)`.
- **`psqtWeights[index][bucket]` for our own 2,237 dead-cell indices
  looks completely ordinary** relative to that plane's overall average
  (e.g. queen plane: 2487cp mean on dead cells vs. 2541cp overall) --
  i.e. our dusty corners aren't inherently weird positions, just a
  sampling gap in this specific corpus, which is exactly the pattern
  factorization is built to exploit (the true per-piece value is
  roughly king-bucket-independent).
- To get a real NNUE eval from this old net for the comparison above,
  built Stockfish from source at the exact matching commit
  (`gmake -j$(nproc) build ARCH=x86-64-avx2 COMP=clang` -- note
  FreeBSD's default `make` is BSD make and cannot parse this
  Makefile's GNU-Make syntax, must use `gmake`), rather than risk
  hand-reimplementing its 8-layer-stack quantized forward pass.
  Compared its `eval` command's "NNUE evaluation" line (pure NNUE
  output, no classical-blend) against our own `forward()`, both
  converted to white-relative, over 1000 shared holdout FENs -- see
  `annotate_pgns/compare_vs_stockfish_net.py` and the factorization
  status note above for the numeric results.

## Iterating on a net: adding a new generation of self-play data

The first net to actually beat (or seriously contest) HCE at matched
`sd 10` is the `lambda=0.75`, P-factored checkpoint from the status
above -- the point where self-play with this net starts producing a
genuinely bigger/more-targeted training pool becomes real ("eating its
own tail", per the conversation that drove this project's current
phase). This section is the checklist for folding a new batch of
self-play games into the next training generation -- written down so
it doesn't need re-deriving from scratch (or re-making the same
mistakes) each time. **None of this requires a new architecture or a
new `nnue_model.py` -- same model class, same feature set, same
`--resume` mechanism.** It's a data/calibration discipline problem,
not a code problem.

### 1. Combine, don't just append

Pool the new self-play positions together with the existing training
corpus (corpora can mix freely at the games.jsonl level -- see
`extract_selfplay_positions.py`'s docstring) and re-run the **full**
pipeline on the combined set:

```
split_holdout.py -> fit_k_and_label.py -> quiet_filter_games.py -> build_training_tensors.py
```

Do NOT train on only the new self-play batch by itself, even via
`--resume`. Self-play games cluster around positions the current net
already favors (that's what self-play means) -- training on only the
new batch narrows the effective training distribution and risks
forgetting what the original, more diverse corpus taught. The combined
pool is also exactly how the dusty-corner coverage problem (see the
factorization status above) actually improves over time: new self-play
games sample different king positions/endgame structures than the
corpus that came before, so the pooled coverage count should be
re-measured after each new generation (same `np.bincount` check used
throughout this doc) to see whether it's actually shrinking the dead-
cell count, not just assumed.

### 2. Refit K on the combined pool -- never carry an old K forward

`fit_k_and_label.py`'s K is fit to one specific corpus's own score
distribution. **"Fitted K = 2.4130 -- record this; applying a different
K later (e.g. after labeling more data) is not the same model"** (the
tool's own exit message) applies in the other direction too: once the
pool changes, K must be refit on the new combined TRAIN split, not
reused from the previous generation. Training against a target
calibrated for a different score distribution than the one actually
being trained on silently miscalibrates every label, the same class of
problem as DNA-tuning's holdout-leakage concerns elsewhere in this
project.

### 3. Watch the label source -- this is the one that actually matters

How were the new self-play games scored?

- **If an independent, stronger reference (Stockfish, or any engine
  not being trained) produced the centipawn labels**, this is safe
  regardless of which engine(s) played the moves -- self-play between
  two candidates is a great way to source diverse, targeted positions
  (specifically ones that stress whatever the current net is weakest
  at), as long as the position doesn't then get "approved" by asking
  the same net being trained to grade itself.
- **If the candidate net's own search produced the score labels for
  its own games**, this reintroduces the single-generation self-
  distillation problem already diagnosed as the likely reason the
  first two (pre-Stockfish-relabel) nets failed to beat HCE at all
  (see the "Revised understanding of why" status note above) -- a net
  trained to reproduce its own judgment can at best match that
  judgment, never exceed it. Mitigate, don't ignore, by keeping
  `lambda < 1.0` in `build_training_tensors.py` so real game outcomes
  (`r_outcome`) stay in the training target regardless of where the
  centipawn half of the blend came from -- outcomes are ground truth
  no self-distillation loop can corrupt, even when the score labels
  themselves are suspect.

### 4. Re-check `material-loss-weight` against the new pool's material
   distribution

`material-loss-weight` was lowered from `0.01` (tuned for TWIC's
balanced-middlegame distribution) to `0.001` specifically because this
project's Stockfish self-play corpus has far more decisive, material-
imbalanced games. A new self-play generation's material distribution
is a new empirical fact, not something to assume stays `0.001` forever
-- if candidate-vs-candidate games turn out far more drawish/balanced
than the original bootstrap corpus (plausible once both sides are
playing well), the same weight could again land at an unintended
fraction of the total loss. Check, don't assume -- same reasoning as
the original 0.01->0.001 change, just potentially in either direction
next time.

### 5. Warm-start, don't restart

`--resume <previous checkpoint>` on the new combined-pool tensors is
the right default -- it's strictly faster than random-init-and-train-
from-scratch, and there's no evidence in this project so far that
warm-starting hurts final quality (nothing here has tested the
opposite, to be clear -- if a future generation's holdout metrics look
worse than a from-scratch control at the same epoch count, that's
worth isolating before trusting warm-starting blindly forever).

### 6. Validate before trusting the new generation

In order, cheapest/fastest first -- stop at the first one that fails
rather than running all of them blind:

1. `sanity_check_net.py --cross-sectional` on the new checkpoint --
   monotonic piece-value deltas, reasonable magnitudes (compare
   against the queen/pawn ratio of the previous generation as a
   baseline, not just "is it monotonic").
2. Re-run the dead-cell coverage check (bincount over the new combined
   training tensors) -- confirm it's trending down, not flat or up.
3. `compare_vs_stockfish_net.py` against the same real Stockfish net
   used above, same held-out FEN sample where practical, to see
   whether correlation/scale-fit-residual improved or regressed vs.
   the previous generation's numbers (0.879 corr / 0.718 pawns resid.
   as of the factored lambda=0.75 checkpoint -- the number to beat).
4. `export_nnue_weights.py` (coalesce check must pass, zero clipped
   values) then a real matched-depth (`sd 10`, not `st` -- see the
   fixed-depth-for-ECM-comparisons convention used throughout this
   project) head-to-head via `eval_tune/test_vs_head.sh` with
   `NNUE_WEIGHTS` set, against classic HCE. This is the only step that
   actually answers "did this generation get better," everything above
   is a cheaper proxy that can catch a regression before burning a
   multi-hour match run on it.

## Adoption criterion: sd-equal parity is not the bar, `st`-equal is --
## and the net is currently ~3.3x slower per node than HCE

Measured directly in the first real sd10-matched match (the factored
`lambda=0.75` checkpoint vs. classic HCE, 2026-10-18, 400 games):
candidate nps=470-475K vs. head nps=1.5-1.6M -- **a ~3.3x gap**, not a
rounding error. At matched *depth* this is invisible (both sides reach
sd10 regardless of nps), which is exactly why the sd-equal match is
the right first filter -- it isolates "is the net smarter per node"
from the speed handicap. But a `st`-equal (real time control) match
would currently be lopsided in HCE's favor for a reason that has
nothing to do with eval quality: at this project's branching factor
(ebf ~3.8-3.9, measured in the same match), a 3.3x nps disadvantage
costs roughly a full ply of search depth at any fixed time budget, on
top of whatever the eval-quality gap already costs. Testing at `st`
before closing that gap would answer "how bad is an engine with no
lazy-eval fast path," not "is this net any good."

**Revised understanding (2026-10-18, same conversation): nps parity is
sufficient, not a blocker -- the real Stockfish binary on this exact
box is the proof.** It searches at ~500K nps -- essentially the same
as our net's measured 470-480K, not the 1.5-1.6M HCE achieves -- and
still beats this project's HCE every single recorded game (not even a
draw). Stockfish doesn't out-node HCE; it out-*prunes* it, searching
dramatically trimmer trees at the same or worse raw nps, because its
eval is trustworthy enough to prune aggressively on. This reframes the
priority between the two levers below: **lazy eval (closing the 3.3x
nps gap) is a nice-to-have, not the thing standing between this net
and real strength.** The actual lever is section 3 of the checklist
below -- null-move R, LMR's reduction table and fail-high prediction,
LMP thresholds, futility margins -- all of which currently encode how
*little* HCE's eval can be trusted. A learned eval earning the right to
prune harder, the way Stockfish's does, is plausibly worth more than
recovering the nps gap ever could be. Lazy eval stays worth doing (free,
no downside), just not gated-on-first the way the staged list below
originally implied.

**Staged gate, in order -- do not skip ahead:**

1. **Sd-equal match (done, 2026-10-18)**: necessary-but-not-sufficient
   filter. If the net can't hold its own at matched depth, it has no
   chance at matched time, since real time controls only make the
   speed handicap worse for it, never better.
2. **Recalibrate the pruning-aggressiveness constants (checklist
   section 3) -- this is the primary lever, per the Stockfish
   comparison above, not a secondary cleanup pass.** Not a full
   DNA-tuning sweep, just enough that search isn't making prune/
   reduce/extend decisions calibrated for a different eval's scale and
   trustworthiness. Plausibly worth more than nps recovery on its own.
3. **Close the free speed gap too** -- lazy eval (checklist section 1),
   still worth doing since it's pure engineering with no downside (it
   doesn't change what the net *thinks*, only how often it's asked to
   think at nodes that get pruned anyway) -- just not the bottleneck
   the Stockfish comparison suggested it might be.
4. **Only then, a real `st`-equal SPRT against HCE.** This is the step
   that actually answers the adoption question -- everything before it
   answers a necessary precondition, not the question itself.

**The adoption threshold itself shouldn't be pure parity-or-better,
though.** HCE is a mature, hand-tuned eval already deep into
diminishing returns on its own DNA-tuning pipeline. NNUE has an
open-ended improvement path still mostly unexploited (more self-play
generations per the iteration section above, factorization already
landed, a wider L1 if a Phase B GPU run ever happens -- see the real
Stockfish net's L1=1536 vs. our 256 as a sense of the room available).
A net that's close-but-not-quite-parity at a genuinely fair (`st`-
equal, post-calibration) time control may still be worth adopting if
the plan is to keep training it -- the alternative is maintaining two
eval paths indefinitely while one's ceiling keeps rising and the
other's doesn't. Close-and-improving beats even-and-stagnant.

## Engine calibration checklist: what needs re-tuning for a new eval

This engine's search has several subsystems whose margins/tables were
fit against classic `Eval()`'s specific output scale and noise
profile. None of this is required for the net to *run* (it already
does, correctly, per the wiring in `search.c`/`nnueeval.c`) -- it's
required for the net to run *well*, since an uncalibrated search is
fighting the new eval with pruning/reduction decisions tuned for a
different one. Confirmed against this engine's actual current search
(2026-10-18 conversation; EFP and ProbCut were previously assumed
present and are not -- corrected below rather than left as dead
checklist items):

**1. Free speed, no behavior change (do this first, before any of the
below -- see the adoption-criterion note above for why ordering
matters here):**
- Lazy eval fast path -- designed (architecture decisions above: reuse
  the net's own cheap linear/PSQT-ish term -- here `ft_p`/
  `material_probe`-shaped -- as a first-pass filter, falling through
  to the full forward pass only when inconclusive vs. alpha/beta), not
  yet built. This is the single biggest lever on the measured 3.3x nps
  gap and touches zero tuning -- it's the right first move specifically
  because it can't make the net play worse, only faster.
- Deferred/lazy accumulator maintenance (flagged in the accumulator
  design section above as a "performance follow-up, not required for
  correctness") -- avoid eager accumulator updates at nodes pruned
  before any eval is ever consulted.

**2. The raw-score-to-cp mapping -- verify this before touching any
margin below, since it's a multiplier on all of them:**
Every margin in section 3 is a flat cp (or near-cp) threshold tuned
against HCE's output scale. The trained net runs at ~0.8x a real
Stockfish net's scale (measured via `compare_vs_stockfish_net.py`) and
is WDL-sigmoid-calibrated (K=2.4), not raw-centipawn-trained. If
search currently treats `forward()`'s raw output as if it were
already in HCE's cp convention, every margin below is off by that
scale factor before even considering noise-profile differences. This
needs the inverse-sigmoid-to-cp conversion the original architecture
decision already called for ("Requires a calibrated inverse-sigmoid
conversion back to centipawns... that conversion constant needs its
own calibration pass") -- confirm it exists and is right once, rather
than chasing a scale error through a dozen separately-miscalibrated
margins.

**3. Score-scale/noise-dependent pruning and reduction constants --
this engine's actual current set (corrected 2026-10-18: no EFP in main
search, only LMP; no separate razoring step -- LMR fills that role
here; no ProbCut; QSearch futility and "delta pruning" are the same
mechanism, not two):**
- QSearch futility margins
- Static/reverse null-move pruning margin
- Null-move reduction depth R, and any eval-based skip/verification
  gating on the null-move try itself
- LMP move-count table, `data.c`
- LMR reduction table, `data.c`, **and** the joint fail-high
  prediction table feeding it (see the LMR sd11/sn10M protocol note
  elsewhere in this project) -- this table is a direct statement about
  how trustworthy the eval is at a reduced-depth node, fit entirely
  against HCE's noise profile
- Root aspiration window initial width and re-widening schedule on
  fail-high/fail-low (tuned against HCE's iteration-to-iteration score
  noise -- a good catch, easy to overlook since it's at the root, not
  buried in the main search loop)

**4. Eval-component-dependent heuristics (lower priority -- already
confirmed to degrade gracefully under a lazy/missing full eval per the
"Breaking external dependencies" section above; re-measure how often
they actually fire once NNUE lazy eval ships, don't re-derive from
scratch):**
- `EstimateHangingMaterialCp`'s `cTrapped`-fallback "dumb move" axis
- `ValueOfMaterialInTroubleDespiteMove`'s QSearch stand-pat gate

**5. Currently dormant, no action needed yet:** draw/contempt scoring
is built but always returns 0 right now -- no live miscalibration risk
today, but if it's ever turned on, the same cp-scale concern in
section 2 applies to whatever constant drives it.

## Status (2026-10-18, continued): pipeline-scaling audit, three gaps fixed, end-to-end driver added

Prompted by generating a much larger volume of annotated PGN than
before and wanting to confirm the post-annotation Python pipeline
(`annotate_pgns/`) actually uses all available parallelism rather than
silently bottlenecking on a single-threaded stage. `annotate.py`
itself (the sd-N search stage) was already confirmed fine -- sharded
across N single-threaded engine subprocesses -- and
`build_training_tensors.py` already had a `--workers 16`
`multiprocessing.Pool`. Three real gaps found and fixed; one
unrelated question (an end-to-end PGN-to-`.nnue` driver) answered by
adding a new orchestrator script.

- **Gap: `quiet_filter.py` had no parallelism at all.** It's a
  single-threaded Python loop doing `chess.Board()` construction + SEE
  per row over the entire corpus (2.5M+ rows for the Stage A run) --
  the one CPU-bound step in the non-annotation part of the pipeline
  with zero parallelism, unlike `build_training_tensors.py`'s `Pool`
  right next to it. **Fixed via a new wrapper, `shard_quiet_filter.py`**,
  rather than rewriting `quiet_filter.py` itself: round-robin-splits
  the input across `--workers` (default 16) temp shard files, runs
  that many `quiet_filter.py` subprocesses concurrently, concatenates
  their outputs. Deliberately reuses `quiet_filter.py` unmodified --
  one definition of the filter, reused as a subprocess, the wrapper
  only owns sharding/recombining. Row order in the combined output
  does not match the input (shards are concatenated shard-by-shard,
  not interleaved back to original order) -- harmless here since
  `positions.quiet.jsonl` is a flat, order-independent training-set
  input. Verified end-to-end on a synthetic 40-row fixture: 40 in, 40
  out, across 4 shards.
- **Gap: `fit_k_and_label.py`'s ternary-search K-fit was unvectorized
  Python.** `fit_k()`'s 40 ternary-search iterations each call `mse()`
  twice, and the old `mse()` was a plain Python `for` loop over every
  (score, outcome) pair -- roughly 80 full passes over ~2M+ pairs in
  pure Python for a step that does very little actual compute.
  **Rewritten to numpy** (`sigmoid`/`mse` as vectorized array ops over
  the full pair set) **and sharded** across `--workers` (default 16)
  via `multiprocessing.Pool`, for *both* halves of the file: the
  calibration-pair collection (`collect_calibration_pairs`, now
  `collect_chunk` workers each vectorizing one chunk of games with
  numpy, combined via `np.concatenate`) and the labeling pass
  (`label_file`, now `label_chunk` workers, each vectorizing one
  game's non-exact positions' sigmoid computation per chunk) --
  `Pool.imap`-based, order-preserving, mirroring
  `build_training_tensors.py`'s existing chunking pattern. Verified
  end-to-end on a synthetic 50-game fixture (4 workers, chunk-size 7):
  correct game/position counts, K fit, both splits labeled.
- **Gap: `nnue_train.py`'s CPU thread default was lower than the rest
  of the pipeline's convention.** `--threads` default bumped 8 -> 16,
  matching the `--workers 16` convention used everywhere else in this
  pipeline (kept below this box's 24 cores -- see below -- rather than
  maxed out, so other jobs on the shared box keep some breathing
  room).
- **Core-count note, not acted on:** this box has 24 cores
  (`nproc`), so every one of these `--workers`/`--threads`/
  `annotate.py --shards` defaults (16) intentionally leaves some
  headroom rather than using all 24 -- a deliberate choice to leave
  the box's other jobs breathing room, not an oversight. `annotate.py`
  Stage A's own measured throughput (NNUE.md's "Status (2026-10-14)"
  section: ~75 pos/sec aggregate at 16 shards vs. ~112/sec
  extrapolated from one shard x16, attributed to contention) is a
  reminder that this box's parallelism doesn't scale linearly past a
  point anyway -- blindly raising shard/worker counts further is not
  assumed to help without measuring first.
- **New: `run_nnue_pipeline.py`**, an end-to-end orchestrator from a
  glob of input PGN to a `.nnue` weights file, requested directly as
  "can one script run the whole training process end to end." Targets
  **already-scored PGN** specifically (match_play.py's
  `"{+-}NN.NN/DEPTH PV..."` per-move comment format -- e.g.
  `pgn/stockfish_self_play_training_data.pgn`, `pgn/self_play_games.pgn`),
  not raw human-game PGN needing a fresh sd-N search -- that's
  `annotate.py`'s job and is a different, not-yet-automated entry
  point. Chains, in order: `extract_selfplay_positions.py` (once per
  input file, since that script takes a single PGN; outputs
  concatenated) -> `split_holdout.py` -> `fit_k_and_label.py` ->
  `quiet_filter_games.py` (both splits) -> `build_training_tensors.py`
  (both splits) -> `nnue_train.py` -> `export_nnue_weights.py` (against
  the latest `ckpt_epochNNN.pt` written -- `nnue_train.py` has no
  separate "best checkpoint" selection yet, so "latest" is the only
  option today). Every stage checks whether its expected output
  already exists and skips it unless `--force` is passed -- the later
  stages here can run for hours, so a crash/interrupt partway through
  should not mean restarting from stage 1. Passes `--workers`/
  `--threads` through at the same default of 16 used everywhere else.
  A thin orchestrator by design: shells out to the existing,
  individually-tested scripts via `subprocess` rather than
  reimplementing any of their logic. Argument parsing, `--help`, and
  the glob-miss error path were smoke-tested; the full multi-stage run
  (which needs the engine binary, real PGNs, and hours of wall clock)
  was not run end-to-end as part of this change.

## TODO (not yet implemented)

- **Horizontal-flip data augmentation, in the position loader
  (`build_training_tensors.py`).** HalfKAv2_hm's king-bucket mirroring
  does NOT tie piece-square weights across the a/h files -- the
  `OrientTBL` XOR step re-orients the board onto the king's own half
  but a piece's *file within that half* still maps to a distinct
  feature row, so a↔h-mirrored positions are not already folded
  together by the architecture. Randomly mirroring positions
  left-right at load time (flip every square's file, recompute both
  perspectives' indices, swap castling rights symmetrically where
  applicable) is a legitimate, standard way to roughly double the
  effective sample count per feature row without changing dataset
  size on disk. Directly relevant given the measured sparsity
  (23.9% of HalfKAv2 rows with zero observations, per the
  2026-10-18 factorization status above) -- should be evaluated
  alongside P-factorization, not instead of it, since the two
  address the same sparsity symptom through different mechanisms
  (more effective samples per row vs. a denser auxiliary feature
  set) and their effects may not be additive.
- **L2 weight decay in `nnue_train.py`'s optimizer (AdamW).** Pulls
  rarely-updated weights (the same sparse/underexposed rows the
  factorization work above targets) toward zero instead of leaving
  them to drift on whatever noisy gradient they happened to last
  receive. Cheap -- a single optimizer hyperparameter, no
  architecture or export-format change -- so there's no reason to
  gate it behind the factorization/augmentation experiments above;
  can be added and swept independently.