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
|
# GetLMRReduction overhaul -- session handoff (2026-10-02)
**Note on filename**: this supersedes `lmr_testing/GET_LMR_REDUCION.md`
(missing a T -- an earlier typo in this same session). That file's
content is reproduced/updated below; don't treat the two as
independent histories, the old one is just a mid-session snapshot.
This session started by making `GetMoveFailHighPercentage`'s dynamic
fail-high table honest, discovered that honesty alone broke LMR's
node-savings, spent a long stretch diagnosing and redesigning the
gate through several dead ends, and ended with a real, working
redesign: self-play validation is pending as of this handoff (the
user is launching it separately). This file is the full history.
**Current code state** (verified by grep immediately before writing
this): `searchsup.c`'s `GetLMRReduction` uses
- `LMR_STATIC_ADMIT_THOUSANDTHS = 500` -- wide-open admission ("do we
reduce at all").
- `JOINT_EXTRA_TIER1/2/3_THOUSANDTHS = 72/5/1` -- now vestigial for
the live magnitude decision (see section 8); TIER1 only appears in
comments, TIER3 is reused by `LMP_STATIC_MAX_THOUSANDTHS` for the
(currently disabled) LMP experiment.
- `LMR_FH_BASELINE_THOUSANDTHS = 4` -- dynamic-danger baseline,
unchanged all session.
- Magnitude ("how much extra") is now computed by
`_FlatTableExtraPlies(uRemainingDepth, uLegalMoves)`, which reads
`g_iLMRQuietReduction` (dynamic.c) -- a Crafty/Ethereal-shaped flat
`log(depth)*log(moves)` table that existed, fully built, completely
unread, since before this session started. This is the live,
working mechanism as of this handoff -- see section 8.
- `JOINT_EXTRA_MAX_PLIES = 5` (cap on extra plies stacked on the base
`-ONE_PLY`).
- LMP (overt late-move pruning, section 7) is implemented in
`ShouldLatePrune` (searchsup.c) but **disabled** at its call site
(`search.c:1228`, `if (FALSE && ...)`) -- measured net-negative, kept
for reference, not deleted.
Build is clean (`gmake -j5 GENETIC=1 PERF_COUNTERS=1 MP=1 SIXTYFOUR=1`,
no warnings) at every checkpoint described below.
## 1. The honesty fix (where this started)
`g_FailHighs` (dynamic.c) used to seed every move-identity to a fake
`1 FH / 1 attempt` (100%) on `ClearDynamicMoveOrdering`, so
`GetMoveFailHighPercentage` never returned a real 0% -- a move needed
~9-10 *clean* observations before its decaying fake prior dropped
below the old `<= 10%` gate. Fixed:
- `ClearDynamicMoveOrdering` now zeroes the table for real (0/0).
- Renamed to `GetMoveFailHighThousandths` (dynamic.c) -- returns the
rate in thousandths (0-1000), not whole percent, since the real
population baseline (data.c's comment: **0.400%** tries-weighted,
across the 500B-node joint-table collection run) needs sub-1%
resolution to mean anything.
- Added `GetMoveFailHighRawCounts` (dynamic.c) -- raw FH/attempts, used
by diagnostics below.
Also while in there: killed the stale doc-comment in `_NewCounterMove`
(claimed depth-gated overwrites; code actually always overwrites on
recency, confirmed by its own inline A/B-test comment -- just the
routine description never got updated). Removed `uCounterDepth`
(write-only, zero readers, the "read-time bonus" it was meant to feed
was never built) and the dead `FIRST_COUNTER_MOVE`/`SECOND_COUNTER_MOVE`
flag constants (chess.h).
Added a countermove-table veto to `GetLMRReduction` itself: a bare
match against `ctx->sPlyInfo[ctx->uPly-2].mv` (the move played
immediately before `mv` -- **two** plies back from here, since `mv`
has already been made by the time `GetLMRReduction` runs) withholds
reduction, independent of generate.c's evidence-gated tier promotion.
## 2. Consolidation: one function, not two files
The joint-table-driven "extra reduction" logic used to live in
search.c as post-processing on top of `GetLMRReduction`'s own baseline
decision -- split across two files/two inconsistent dynamic checks
purely to minimize the original diff against HEAD. Moved the whole
decision into `GetLMRReduction` (searchsup.c):
- `EstimateHangingMaterialCp`, `HangCpBucket`, `GapCpBucket`,
`MoveNumBucket`, `DepthBucket` are no longer file-static -- they're
declared in chess.h now because search.c's own joint-table
*collection* instrumentation (gated on `fPruneReduceEligible` alone,
independent of the LMR decision) still needs them.
- `GetLMRReduction` gained three params: `bbMinorAttacksSnapshot[2]`,
`bbRookAttacksSnapshot[2]` (for `EstimateHangingMaterialCp`;
split.c's HelpSearch has no such snapshot, passes all-zero arrays,
which degrade safely), and `OUT ULONG *puExtraTier` (optional, feeds
search.c's re-search-rate calibration instrumentation).
- search.c's move loop is now just one call + two counter increments.
## 3. The surprise: depth went DOWN, not up
First redesign attempt kept the *old* two-check shape (dynamic rate
must be low AND bucket-specific, both gates, a bare 0.4%-baseline
veto) but made it honest. Measured against `head_reference` (committed
baseline, no joint-table mechanism at all -- its `GetLMRReduction`
only ever applies a flat `-ONE_PLY`, old fake-prior-decay gate):
- **sd10 node count went UP 2-6%** across all three curated suites --
the honest gate was *more* conservative than HEAD's old loose one.
- A single position (ECM.233) pushed to sd20 showed candidate using
3x HEAD's node count at the same depth (18 ply) -- undeniable, not
a suite-aggregate artifact.
- A diagnostic (`u64HeadStyleEligible`/`u64HeadStyleWouldReduce`,
chess.h) replays HEAD's exact old gate formula --
`(rawFH+1)*100/(rawAttempts+1) <= 10` -- against the SAME live
trajectory as the real decision. Result: **HEAD-style reduces
~76-77% of eligible decisions, always by exactly 1 ply.** The honest
design (0.4% baseline) only reduced 62-66%, but with some decisions
getting 2-4 plies. Average ply-saved-per-eligible-call was actually
*slightly higher* for the new design (0.80-0.88 vs HEAD's 0.73-0.76)
-- so the problem wasn't magnitude, it was **coverage**: breadth
(how many moves get touched at all) matters more to total tree size
than depth-of-cut-per-touch, and extra-tier magnitude carries a
re-search tax (2-3%/tier) that eats part of its own benefit. This
theme (coverage beats magnitude, magnitude has a tax) recurs
throughout the rest of the session -- see sections 8/9.
- A pure threshold sweep on the EXTRA-tier ceiling alone (holding the
honest 0.4% baseline fixed) never recovered HEAD's node count, not
even at the old deliberate-extreme-experiment setting
(9000/8300/1000 thousandths) -- confirming magnitude-tuning on an
already-small qualified population is the wrong lever.
- A sweep of the 0.4% *baseline* itself told a different story:
loosening it to 100 thousandths (10%, matching HEAD's old literal
ceiling) recovered coverage (75.6%) and **beat HEAD's node count by
35%**. Disabling the countermove-match veto or the bucket-prediction
veto *individually* moved node count by only 1-10% -- noise,
basically. **The flat 0.4% baseline was almost the entire story**,
not the "smart" vetoes. This is the first sighting of the pattern
that eventually won: a simple, broad admission lever beats precise
per-decision judgment.
## 4. Why the population shape matters: the FH histogram
A joint histogram of `(uAttempts bucket) x (uFHThousandths bucket)` at
the live decision point (`u64FHJointHist`, chess.h;
`AttemptsBucket`/`RateBucket`, searchsup.c -- still live
`PERF_COUNTERS`-only diagnostic code, not yet removed) showed, on
confident_quick (18.7M eligible decisions):
- **~71% sit at exactly 0% observed rate**, including a large,
well-evidenced chunk (3.29M with 100+ attempts, genuinely zero FH
across real volume, not a fluke).
- **A real, separate, well-evidenced population sits durably at
1-10%** -- most of that mass is in the `attempts=100+` row (713K at
1-1.9%, 1.1M at 2-4.9%, 657K at 5-9.9%), i.e. **not** small-sample
noise. (A real, narrower small-n artifact *does* exist separately:
at `attempts <= 9`, rates between ~1% and ~10% are structurally
unreachable -- `1/9 ≈ 11.1%` is the smallest nonzero rate at 9
attempts -- but this is a distinct, smaller phenomenon from the
large well-evidenced 1-10% population above.)
- **~7% are pure-cold** (`uAttempts == 0`) and, under the original
two-population design, got flatly refused any reduction regardless
of what the static joint table said. The static table needs NO game
history (keyed on depth/hang/gap/late, not move identity) -- gating
it behind a dynamic-history requirement threw away its whole point
for this slice.
So the dynamic rate is **bimodal**, not unimodal around 0.4% -- the
population average is a real summary statistic of a mixture, not
representative of either mode.
## 5. Static-first and three-population redesigns (SUPERSEDED)
Two intermediate redesigns, both now superseded by section 8's simpler
mechanism, but worth recording for what they established:
- **Static-first**: flipped priority so the static bucket prediction
became primary ("should we reduce at all"), dynamic signals became
vetoes on top rather than a precondition. Established the
qualitative lesson (cold moves should defer to the static table, not
be refused outright) that survived into every later design.
- **Three-population**: cold / warm+safe / warm+dangerous, with
warm+dangerous capped to base-only rather than fully vetoed. This
*also* established a real, lasting bug: `JOINT_EXTRA_TIER1_
THOUSANDTHS` was doing double duty as both the admission threshold
("reduce at all") and the magnitude threshold ("+1 extra ply") --
decoupling these (section 6) is what unblocked real progress.
**CONTAMINATION WARNING, still relevant if this file's old numbers
ever resurface**: partway through this session,
`JOINT_EXTRA_TIER1/2/3_THOUSANDTHS` got silently stuck at
**9000/8300/1000** (an old deliberate-extreme-experiment setting) for
a long stretch, because a `cp searchsup.c searchsup.c.orig_full`
snapshot captured a sweep script's leftover state without checking it
first. Every measurement taken during that window (the HeadStyle
diagnostic, the FH histogram, the first static-first pass) was
measured under that contamination. **Lesson for next time**: after ANY
sed-based sweep script, explicitly grep the tier/baseline constants
before trusting a `cp`'d file as a clean base for anything else.
Despite the contamination, one result survived it, independently
corroborated later: **depth gain from LMR changes is heavily damped**.
Even at the extreme (accidentally-active) ceiling, sn5M average search
depth only moved 0.1-0.3 ply vs HEAD. This matches a documented
finding already in search.c's history (2026-10-01 comment): the same
9000/8300/1000 setting, in an *overnight self-play run*, predicted
~1.0 extra ply/decision but measured only **+0.29 actual root-depth
ply** -- a ~0.3x damping ratio, attributed to EBF: iterative
deepening's cost scales by the effective branching factor per ply, so
affording one more full iteration needs saving roughly `(EBF-1)/EBF`
(~70-75% for EBF 3.5-4) of the previous iteration's total cost.
**Crucial later correction to this section's pessimism** (from the
user, mid-session): a *different* overnight self-play run, using the
old p5/p25/p75-derived "dodgy" thresholds, did NOT just match HEAD --
it ran **0.9-1.0 ply deeper and was even-or-better in actual game
strength**. This is the one piece of ground-truth (real game outcome,
not an ECM proxy) evidence the whole session has, and it directly
contradicts "tune LMR for modest percentage savings is the wrong order
of magnitude lever" -- it says aggressive-even-if-locally-unsound
reduction, tolerated because iterative deepening self-corrects
individual mistakes on the next pass, is exactly the right lever. This
reframing drove everything in sections 6-9.
## 6. Decoupling admission from magnitude
Advisor-reviewed fix, directly motivated by the three-population bug
above and the "0.9-1.0 ply overnight" evidence: **introduced a
separate constant, `LMR_STATIC_ADMIT_THOUSANDTHS`, used only for "do
we reduce at all"**, independent of `JOINT_EXTRA_TIER1_THOUSANDTHS`
("does this earn +1 extra ply"). Also fixed the sentinel (no static
data) branch: previously required `uAttempts > 0` even when static had
no opinion either, needlessly refusing cold+sentinel moves; changed to
admit whenever not dynamically dangerous, since absence of data is not
evidence of danger.
Swept `LMR_STATIC_ADMIT_THOUSANDTHS` on sn5M (all three suites, vs
`head_reference`):
| setting | ringers depth/solve | confident_quick depth/solve | hard_quick depth/solve |
|---|---|---|---|
| HEAD | 11.6 / 6 | 12.4 / 78 | 11.4 / 19 |
| 500 | 11.7 / 6 | 12.8 / 77 | 11.9 / 19 |
| 1000 (no-op gate) | 11.9 / 4 | 12.8 / 76 | 12.1 / 18 |
500 was flat-to-noise on solves (0/-1/0) while gaining real depth
(+0.1/+0.4/+0.5 ply). 1000 was deeper still but **lost solves on every
single suite simultaneously** (-2/-2/-1) -- a consistent, same-direction
signal, not noise, meaning the extra depth from over-wide admission
wasn't paying for itself. **500 was adopted as the working admission
value**; it remains the current setting (section "Current code state"
above).
**Methodology note the user corrected mid-session**: sd10 (fixed
depth) solve-count drops are expected and uninteresting (searching
fewer nodes before the ceiling naturally solves fewer problems) --
solve counts only matter as a signal at **sn5M (fixed node budget)**,
where a drop means the extra depth isn't translating to correctness.
Don't conflate the two contexts when reading solve deltas.
## 7. Late move pruning (LMP) -- tried, net-negative, disabled not deleted
Prompted by comparing against Crafty's actual source
(`crafty-25.2/search.c:542,580`): Crafty's LMP is a hard `break` (skip
ALL remaining moves at a node, not just one) gated purely on
`depth < LMP_depth(16)` and `order > LMP[depth]` (a precomputed
`3 + (i+.5)^1.9` table) -- no static table, no dynamic history, no
per-move judgment at all. Implemented a conservative analog,
`ShouldLatePrune` (searchsup.c): overt skip (not reduction) for the
narrowest, most-certain tail only -- `uRemainingDepth <=
LMP_MAX_REMAINING_DEPTH(6 plies)`, move count past a per-depth floor,
AND both static (`JOINT_EXTRA_TIER3_THOUSANDTHS`, the strictest tier)
and dynamic (well-evidenced, near-zero rate) signals agreeing it's
ultra-safe, with killer/countermove exemptions carried over unchanged.
Wired into search.c ahead of LMR/EFP so a pruned move skips both;
reused `fThisMoveEFPPruned`/`fAnyMoveEFPPruned`'s existing hash-
correctness plumbing rather than duplicating it.
Measured on top of the admission=500 baseline, sn5M:
| suite | admit=500 alone | +LMP |
|---|---|---|
| ringers | 11.7 / 6 | 11.7 / 6 (unchanged) |
| confident_quick | 12.8 / 77 | 12.8 / **76** |
| hard_quick | 11.9 / 19 | 11.9 / **18** |
Depth/EBF barely moved (same as admit=500 alone), but solve count
dropped one further on two of three suites at sn5M (the metric that
matters per section 6's note) -- a real cost with no offsetting
benefit. **Disabled at the call site (`search.c:1228`, `if (FALSE &&
...)`), not deleted** -- the mechanism may be worth revisiting with
different thresholds (the tested band was very narrow -- only
~250K-moves-pruned out of ~23M total nodes on sd10 ringers, i.e. this
barely touched anything, so "net-negative" here means "net-negative
at this specific narrow threshold," not "pruning can't work." Crafty's
own version is dramatically more aggressive (breaks on ALL remaining
moves, not a per-move judgment call) -- this was flagged mid-session
as "clearly stronger but way dodgier," and was deliberately not
pursued further given the explicit instruction not to get crazy with
pruning (irreversible, no re-search safety net, unlike LMR).
**Known, unfixed issue in `ShouldLatePrune`'s depth gate** (found
reviewing the Crafty comparison, documented in-code at
`searchsup.c`'s `LMP_MAX_REMAINING_DEPTH` comment but not patched,
since LMP is disabled anyway): the function only permits pruning near
the leaf (`uRemainingDepth <= 6*ONE_PLY`), mirroring Crafty's
`FP_depth`/`LMP_depth` near-leaf restriction. But our own joint table
shows predicted FH% **declining** as remaining depth grows (checked
directly, dumb=0/gap=0/late=0 slice: depth-bucket 0 → 17119
thousandths, bucket 5 → 4033, bucket 11 → 617, a ~25x drop) -- so the
ceiling permits pruning exactly where our own data says risk is
*highest*, and forbids it where risk is *lowest*. Crafty isn't
self-contradicting the same way: it has no static table at all, so
the near-leaf restriction *is* its entire risk model (a mistake near
the leaf is cheap to re-discover). We inherited the structural gate
without inheriting its justification, stacked on a mechanism that
already has a better, contradicting signal.
The user's correct reframing: the *cost* of a wrong prune does
legitimately grow with remaining depth, but that should tighten the
**confidence bar**, not forbid the decision outright past a cutoff. The
principled fix is in `lmr_testing/SPEC.md`'s "how much statistical
confidence is actually needed" section: gate on the **static table's
own per-cell sample count** via a rule-of-three bound (`N ≈ 3 *
applications_per_game / target_misses_per_game`) -- not on remaining
depth, not on the dynamic per-move-identity table. SPEC.md derives
`N ≈ 14.4M` for a 1/1000-games miss target (no deep-zone zero-FH cell
clears this) and, after Scott's pushback that 1/1000 was picked, not
derived, a looser 1/100-games target gives `N ≈ 1.44M` (largest real
cell: 1.34M, close) or 1/20-games gives `N ≈ 288K` (16 deep-zone
zero-FH cells clear this today). **Blocker**: `g_uJointFHThousandths`
(data.c) ships only the rate per cell, not the sample count behind it
(cells need ≥500 tries to ship at all, but a 500-try cell and a
1.34M-try cell are indistinguishable once shipped) -- applying this
bound correctly needs a re-bake (`bake_joint.py` + `data.c`) adding a
companion per-cell count array. Not done this session; a stand-in
(depth-scaled dynamic-sample floor) was drafted and then deliberately
reverted rather than leaving an uncalibrated heuristic in place --
current code still has the original (acknowledged-wrong) depth
ceiling, now with this critique documented alongside it.
**Concrete lead for whoever re-bakes this**: querying the raw
collection counts directly (not the shipped table) found **43 cells
with a genuine 0.0% fail-high prediction (0 fail-highs across all
tries) backed by ≥288,000 tries** -- clearing the 1/20-games bound
above. These are heavily concentrated at **hang/dumb bucket 9**
(`HangCpBucket`'s near-top bucket, `EstimateHangingMaterialCp`'s
largest "this move hangs a lot of material for nothing" reading),
essentially regardless of depth/gap/late bucket -- the single largest
qualifying cell has **5.1M tries, zero fail-highs**. This isn't a
sparse-sampling coincidence: it's the "dumb" axis doing exactly the
job it was built for at its most extreme setting -- a move that
obviously just hangs material for nothing is essentially never
actually the best move at a node, so "it raises alpha" can't happen
independent of depth/gap/lateness context. **This is the real
population LMP should target once sample counts are shippable**: not
"near the leaf" (the current, backwards ceiling), but "hang bucket 9,
any depth" -- a structural, not positional, criterion.
## 8. Magnitude-loosening attempts: the real dead end, and the actual fix
With admission settled at 500, the next lever tried was MAGNITUDE --
how many extra plies a reduced move gets. Two attempts:
**Attempt A -- widen the bucket-tier thresholds.** Changed
`72/5/1/cap-3` to `150/20/5/cap-4`. Rationale: Crafty's own LMR table,
at the remaining-depth range our suites actually exercise (5-10
plies), applies larger cuts (2-6 plies) than our tiers were allowing.
**Result: sd10 ringers node count went UP, 23.0M -> 27.7M -- WORSE
than even HEAD's 26.9M.** The re-search-rate diagnostic showed why:
tier 1's research rate (4.13%) became *higher* than tier 0's (2.73%)
-- exactly the "distribution-shift failure" the code's own calibration
comment warns about (low-predicted-FH tiers should show a RISING
research rate with tier, never an inversion). Widening tier 1's
*admission* boundary let in a bucket of moves genuinely more dangerous
than baseline, and the resulting re-search tax outweighed the savings.
Reverted immediately.
**Attempt B -- raise only the tier 2/3 *payout*, leave all admission
boundaries (72/5/1) untouched.** Rationale: tiers 2/3 already showed
LOWER research rates than tier 0 even before any change -- i.e.
headroom, not a crack, unlike tier 1. Bumped tier2 payout 2->3, tier3
3->5, cap to 5. **Result: sd10 node count barely moved (23.04M ->
22.97M, 0.3%)** -- essentially null. Diagnosed (advisor-assisted)
after the fact: **89% of all reduced moves land in tier 0 (no extra
ply at all)**, 10.3% in tier 1, and only ~1.1% combined in tiers 2/3 --
so tuning tiers 2/3's payout only ever touches the smallest sliver of
the population; it mathematically cannot move the aggregate much
either way. Separately, the new `cap=5` needed `remaining_plies >= 8`
to even engage (`iMaxExtra` floor logic), which these suites' typical
5-10-ply remaining depth rarely reaches -- a second reason this came
back null.
**The actual fix -- stop grading magnitude with the joint table at
all.** The diagnosis above reframed the real question: 89% of the
population (tier 0) was getting a flat, ungraded `-ONE_PLY` with no
magnitude mechanism touching it whatsoever -- every experiment so far
had been tuning the payout of an 11% minority. Separately, a grep
turned up `g_iLMRQuietReduction[MAX_PLY_PER_SEARCH+1][LMR_TABLE_MAX_MOVES+1]`
(dynamic.c, built by `InitLMRTable()`, called from `main.c`) -- a
**Crafty/Ethereal-shaped flat `log(depth)*log(moves)` table, fully
built at startup, read by nothing, anywhere in the codebase.** A prior
session had built the simple mechanism and then gone and built the
whole joint-table/dynamic-FH system instead, without ever wiring the
simple one in. Sanity-checked its magnitudes against Crafty's actual
table at the depth range that matters (5-10 plies remaining): d=5,m=8
gives ~2.1 plies here vs. Crafty's 2.3; d=10,m=8 gives ~2.7 vs. 3.0 --
same ballpark, same shape, and structurally immune to the "one bucket
silently miscalibrated" failure mode that broke Attempt A, because
it's not a lookup into learned per-situation fail-high predictions at
all -- just depth and move order.
Wired `_FlatTableExtraPlies(uRemainingDepth, uLegalMoves)` in as the
magnitude source, **replacing** the old `_JointTableExtraPlies`
tier-lookup (which is now dead code, deleted) -- admission logic
(static/dynamic agreement, killer/countermove vetoes,
`LMR_STATIC_ADMIT_THOUSANDTHS=500`) is completely unchanged; only "how
much extra" changed. The table returns the FULL reduction in
ONE_PLY-scaled ticks; subtracting the existing `ONE_PLY` base and
flooring at 0 gives the extra-ply count, clamped by the existing
`JOINT_EXTRA_MIN_REMAINING_DEPTH` floor logic as before.
**Result -- the best of the whole session, every metric improved or
flat simultaneously:**
sd10 ringers node count: **19.56M**, vs. HEAD's 26.94M (-27.4%) and
vs. the admit=500-alone baseline's 23.04M (-15.1% further). Solve
count unchanged (3/11).
sn5M, all three suites, vs. HEAD and vs. the previous best
(admit=500 alone, no LMP, no magnitude change):
| suite | HEAD | admit=500 alone | **flat-table magnitude** |
|---|---|---|---|
| ringers | 11.6 ply / 6 solved | 11.7 / 6 | **11.8 / 6** |
| confident_quick | 12.4 / 78 | 12.8 / 77 | **12.8 / 77** |
| hard_quick | 11.4 / 19 | 11.9 / 19 | **12.0 / 19** |
Depth improved on every suite vs. both HEAD and the previous best
(+0.2/+0.4/+0.6 ply vs. HEAD); hard_quick's solve count is perfectly
flat (19/19) despite the largest depth gain of any candidate this
session; no suite lost a solve relative to the admit=500 baseline.
This is the first candidate all session where no metric had to be
traded off against another.
## 9. The pattern across the whole session
Every lever that worked was a **simple, broad, structural** one
(admission width, Crafty's flat depth x order table); every lever that
failed or plateaued was a **precise, learned, per-situation** one
(bucket-tier magnitude grading, narrow LMP thresholds). The common
failure mode of the precise levers was a correlated miscalibration in
one slice of the population (tier 1's admission boundary) that a flat
mechanism structurally cannot have, because it has no slices to
miscalibrate -- only a single smooth surface in (depth, move-order).
Crafty's own source confirms this isn't a coincidence: neither its LMP
nor its LMR table consults any static or dynamic per-move signal at
all, and it reaches ply 19 at node counts where this engine was
reaching ply 11-12 before this session. The dynamic/static per-move
tables are still useful -- they're what's deciding *whether* to
reduce at all (admission), where they've demonstrably worked -- the
mistake was asking the same machinery to also grade *how much*.
## Where we left off / next steps
1. **Self-play validation is the next step, launched by the user
separately from this handoff.** Every metric used all session (sd10
node count, sn5M depth/EBF, sn5M solve count) is a proxy; the ECM
suites are small and documented-noisy (`../tests/ECM.md`); the only
ground-truth evidence all session is the overnight self-play run
referenced in section 5. **Use a genuine time-per-move or fixed-
node self-play gate, never `sd` (fixed depth defeats the entire
mechanism being tested -- confirmed in `typhoon_lmr_sd11_sn10m_
protocol` memory).** Check which existing script
(`eval_tune/match_play.py`, `.dna`-file-oriented, built for eval
tuning -- or `eval_tune/test_vs_head.sh`, currently modified in the
working tree) actually supports two *binaries* head-to-head, since
`match_play.py` as built takes two DNA files against one binary,
not two search-behavior variants.
2. **Cross-check any solve-count deltas against `../tests/ECM.md`**
before trusting them -- gained/lost positions skew heavily
flaky/noise; the project's own doc tracks which ECM numbers are
known-flaky vs. known-legit.
3. The current working config (admission=500, flat-table magnitude,
LMP disabled) has NOT been tried with LMP re-enabled using a
structurally different criterion than the narrow one tested in
section 7. Section 7's concrete lead (43 real cells, hang/dumb
bucket 9, up to 5.1M tries, genuine 0% FH -- a "this move obviously
hangs material" criterion, not a depth-near-leaf one) is the
specific next experiment: ship per-cell sample counts
(`bake_joint.py`/`data.c` re-bake), then gate `ShouldLatePrune` on
hang-bucket-9-or-similar + a rule-of-three-cleared sample count,
dropping the backwards depth ceiling entirely. Not attempted this
session (the re-bake is real work, and the explicit instruction was
to not get crazy with pruning without a principled basis -- this
gives one).
4. Before running any further sed-based sweep, confirm the baseline
file is actually clean (section 5's contamination warning) --
explicitly grep the tier/baseline/admission constants, don't just
trust a `cp` from a few steps ago.
5. The `u64FHJointHist`/`AttemptsBucket`/`RateBucket`/
`u64HeadStyleEligible`/`u64HeadStyleWouldReduce`/`u64LMPPruned`
diagnostics (chess.h, searchsup.c, root.c, script.c) are explicitly
one-off and still wired in under `#ifdef PERF_COUNTERS`. Harmless to
leave (no behavior change, diagnostic-only), but strip them out once
this investigation concludes.
6. `JOINT_EXTRA_TIER1_THOUSANDTHS`/`JOINT_EXTRA_TIER2_THOUSANDTHS` are
now unused outside of comments/the disabled LMP path respectively --
candidates for removal once the flat-table magnitude approach is
confirmed by self-play, along with the now-dead `_JointTableExtraPlies`
function (already deleted) and its tier-sweep history in this doc,
which is kept here only as a record of why the simpler mechanism won.
7. If self-play confirms this is a real strength gain, update
`head_reference/` (binary + logs + README) to make this the new
comparison baseline, per this codebase's own stated protocol
(`CLAUDE.md`'s "head_reference protocol" section).
---
# Update, 2026-10-03: admission-rate measurement, a counter-bounds bug, and
# why this session's solve-count evidence does not support what it was used for
Written while the self-play validation from "Where we left off" item 1 was
running (`--st 4`, SPRT elo0=0/elo1=5, 12+ workers). At 1710 games:
`-426 =831 +453`, score 0.508, llr +0.41, avg depth candidate 13.55 vs head
12.90, ebf 3.464 vs 3.647, nps essentially tied (1.360M vs 1.365M).
All measurements below were taken with the live candidate binary
(`./typhoon`, confirmed `-DPERF_COUNTERS` and newer than every source file),
`--cpus 1`, `--hash 256m`, three curated suites run serially.
`eval_tune/match_play.py` confirmed to spawn engines with `--cpus 1`, so
`split.c`'s degraded all-zero-snapshot `GetLMRReduction` path is not involved
in the self-play numbers either.
## 10.1. The match result is underpowered, not null
Score 0.5079 over 1710 games with that draw rate gives an SD of the mean of
0.0087, i.e. **+5.5 Elo with a 95% CI of roughly [-6, +17]**. The llr is
drifting toward H1, not away. The honest statement is that this match spans
everything from a small loss to a solid gain and cannot yet distinguish them
-- not "the depth didn't translate," and not "it's fine."
The extra depth is real rather than nominal: `ln(3.647)/ln(3.464) = 1.0414`,
and `1.0414 x 12.90 = 13.43` against 13.55 measured. The ebf reduction fully
accounts for the depth gained, with no residual.
**That "no residual" is itself the interesting part.** A well-targeted
reduction should beat the arithmetic, because the saved nodes go
preferentially to lines that matter, so accuracy-at-fixed-nodes improves
beyond what pure depth predicts. Getting exactly the arithmetic and nothing
more is the signature of a near-uniform tax on the leftover pool rather than
a reallocation. See section 10.7b.
## 10.2. Admission rate: the candidate reduces far MORE than HEAD, not less
Ran the still-wired `u64HeadStyleEligible`/`u64HeadStyleWouldReduce`
diagnostic (section 3 (original)) at `sd 10`. Last cumulative checkpoint per suite:
| suite | eligible calls | candidate: SOME reduction | HEAD-style: flat -1 | delta |
|---|---|---|---|---|
| ecm_ringers | 2,897,448 | 92.88% | 71.60% | +21.3 |
| ecm_confident_quick | 15,345,704 | 94.47% | 70.37% | +24.1 |
| ecm_hard_quick | 28,632,443 | 93.26% | 74.99% | +18.3 |
The section 3 (original) under-coverage problem is comprehensively gone. Coverage is now
~19-24 points WIDER than HEAD-style's.
**Cause, checked rather than assumed.** The first hypothesis was that the
smoothed re-bake widened admission, since `data.c`'s current header reads
"Every cell is populated -- no 0xffff sentinels remain" with 5,241/21,780
cells shrunk toward a coarser marginal. That hypothesis is wrong, and
backwards: the sentinel branch in `GetLMRReduction` was the PERMISSIVE one
(`fBaseEligible = (FALSE == fDynamicDanger)`, admit unless dynamically
dangerous) while the value branch is CONDITIONAL
(`uPredictedFH < LMR_STATIC_ADMIT_THOUSANDTHS`). Moving cells from sentinel
to value can only TIGHTEN.
Parsing `g_uJointFHThousandths` out of data.c directly: only **733 of 21,780
cells (3.4%) read >= 500**, and 224 of those sit in dumb bucket 1 where
`_JointTablePredictedFHThousandths` returns the sentinel regardless -- so only
~509 cells (2.3% of the space) can actually veto. They concentrate in gap
bucket 0 (480 of 733, i.e. near-alpha) and depth buckets 0-1. So
`LMR_STATIC_ADMIT_THOUSANDTHS = 500` has ALWAYS been close to wide open;
section 6 (original)'s 500-vs-1000 sweep was distinguishing a ~6% slice of calls, not a
broad gate.
What actually widened coverage from section 3 (original)'s 62-66% to today's 93-94% was
**section 6 (original)'s decoupling**: `fDynamicDanger` was demoted from vetoing the base
reduction to merely forcing `uExtraPlies = 0`. A code change, not a data
change.
**Corollary: section 1 (original)'s `g_FailHighs` honesty fix is now nearly inert.**
`fDynamicDanger` no longer vetoes the base reduction (except for dumb-bucket-1
moves, which take the sentinel branch), and it only zeroes the extra plies --
which ~85% of reduced moves never receive anyway (section 3 (original) below). The lever
that started that whole session has almost no remaining effect on shipped
behavior. Worth knowing before investing further in that table's accuracy.
## 10.3. Magnitude is still overwhelmingly base-only
Tier share of all REDUCED moves, across the three suites: tier 0 ~85-86%,
tier 1 ~11-13%, tier 2 ~2.2-2.4%, tier 3 ~0.0-0.01%. Section 8 (original)'s "89% of all
reduced moves land in tier 0" survived the switch from `_JointTableExtraPlies`
to `_FlatTableExtraPlies` essentially unchanged.
So the +0.65 ply in self-play is bought almost entirely by BREADTH (93-94%
admission at a flat -1 ply), not by depth-of-cut. The flat table replaced the
fragile tier payouts without actually paying out much more than they did.
## 10.4. BUG: `JOINT_EXTRA_MAX_PLIES` (5) overruns `JOINT_EXTRA_TIER_COUNT` (4)
```
chess.h:999 #define JOINT_EXTRA_TIER_COUNT (4) // valid indices 0..3
chess.h:1143 UINT64 u64JointExtraTries[JOINT_EXTRA_TIER_COUNT];
chess.h:1144 UINT64 u64JointExtraResearch[JOINT_EXTRA_TIER_COUNT];
chess.h:1149 UINT64 u64LMPPruned;
searchsup.c:514 #define JOINT_EXTRA_MAX_PLIES (5)
searchsup.c:934-6 if (uExtraPlies > JOINT_EXTRA_MAX_PLIES) uExtraPlies = JOINT_EXTRA_MAX_PLIES;
searchsup.c:942 *puExtraTier = uExtraPlies; // 0..5 escapes to the caller
search.c:1279 INC(ctx->sCounters.tree.u64JointExtraTries[uExtraTier]);
search.c:1489 INC(ctx->sCounters.tree.u64JointExtraResearch[uThisMoveJointExtraTier]);
```
With `uExtraTier == 4`: `search.c:1279` writes `u64JointExtraTries[4]`, which
lands on `u64JointExtraResearch[0]`; `search.c:1489` writes
`u64JointExtraResearch[4]`, which is past BOTH arrays and lands on
`u64LMPPruned`. The only other clamp, `iMaxExtra` (searchsup.c:922), bounds
against remaining depth, not against the array -- at remaining depth 14 it
permits 11.
**Why no suite run ever caught it.** `_FlatTableExtraPlies` returns
`(total_ticks - ONE_PLY)/ONE_PLY` with
`total = 0.7844 + log(d)*log(m)/2.4696`. Tier 4 needs `total >= 5.0`:
| remaining depth | moves tried | total | tier |
|---|---|---|---|
| 10 | 63 (LMR_TABLE_MAX_MOVES) | 4.65 | 3 |
| 14 | 31 | 4.45 | 3 |
| 14 | 63 | 5.21 | **4** |
| 20 | 63 | 5.81 | **4** |
At `sd 10` remaining depth never exceeds 10, so tier 3 is the ceiling and
every suite measurement in this document (section 8 (original)'s included) is clean. The
live match averages 13.55 ply, so tier 4 fires there routinely.
**Blast radius**: counters only. Nothing reads these to make a search
decision, so the running match's RESULT IS VALID, and `root.c:1558-1561`'s
memcpys use `sizeof` the 4-element destinations so it does not propagate into
`g_Options`. What it corrupts is (a) tier 0's apparent re-search rate, i.e.
precisely the calibration diagnostic the next candidate would be judged on,
and (b) `u64LMPPruned`, which can read nonzero while LMP is disabled at
`search.c:1228` -- a thoroughly confusing symptom for whoever hits it next.
**Fix**: raise `JOINT_EXTRA_TIER_COUNT` to 6 (`script.c`'s report loop is
already bounded by the constant, so reporting stays faithful). Clamping
`JOINT_EXTRA_MAX_PLIES` to 3 instead would change search behavior and is
therefore not a pure bug fix.
## 10.5. The by-tier re-search-rate diagnostic no longer means what section 8 (original) used it for
```
tier0 tier1 tier2 tier3
ringers 2.63% 3.84% 3.55% 3.85% (77 tries)
confident_quick 2.69% 2.94% 2.87% 3.98% (1,204 tries)
hard_quick 2.60% 3.74% 3.30% 2.88% (1,768 tries)
```
Section 8 (original) rejected Attempt A because tier 1's research rate (4.13%) came in
HIGHER than tier 0's (2.73%), calling it the "distribution-shift failure."
That was a valid test WHEN tier was a predicted-FH% band
(`_JointTableExtraPlies`): a rising rate meant the risk ordering had broken.
Under `_FlatTableExtraPlies`, tier is pure MAGNITUDE. A bigger cut
mechanically needs more re-searches regardless of how well-calibrated
anything is, so the inversion carries no calibration signal at all. Note
tier 2 < tier 1 on all three suites, which no FH-ordering story predicts but
a "bigger cut costs more, noisily" story accommodates fine.
To be useful again this diagnostic must be re-based on PREDICTED FH%
independent of applied magnitude. As currently reported it should not be used
to accept or reject a candidate. Aggregate re-search tax is ~2.6-2.8% of
reduced moves, which is modest.
## 10.6. The solve-count evidence in this document does not support what it was used for
Ran `sn 5000000` on all three suites (the metric section 6 (original) established as the
one that matters) against `head_reference/logs/sn5m_*.out`, then diffed
PER POSITION instead of reading the aggregate:
| suite | HEAD solved/depth | candidate solved/depth | positions FLIPPED |
|---|---|---|---|
| ecm_ringers | 6/11 @ 11.6 ply | 6/11 @ 11.8 ply | 4 of 11 (36%) |
| ecm_confident_quick | 78/90 @ 12.4 ply | 77/90 @ 12.8 ply | 7 of 90 ( 8%) |
| ecm_hard_quick | 19/90 @ 11.4 ply | 20/90 @ 12.0 ply | 15 of 90 (17%) |
Depth reproduces section 6 (original)/8's admit=500 row closely (+0.2/+0.4/+0.6 ply) and
is the trustworthy axis. The solve counts are not, and cross-referencing every
flip against `../tests/ECM.md` shows the near-flat aggregates are
CANCELLATION, not stability:
- **ringers (net 0)**: gains `ECM.213` (catalogued flaky, +0.37 unconfirmed
repetition line) and `ECM.270` (catalogued BAD SOLUTION -- score exactly
0.00, Crafty's own sd20 prefers Rxc5); losses `ECM.721` and `ECM.746`, both
catalogued CLEAN. **Net 0 is two real losses masked by two junk gains.**
- **confident_quick (net -1)**: real gains `ECM.519` (clean, +7.33) and
`ECM.736` (Crafty confirms the same move across depths 24-28 while HEAD
wobbles); real loss `ECM.348` (NOT CATALOGUED); `ECM.357` gain and
`ECM.715`/`716`/`826` losses all catalogued flaky or explicit horizon
artifacts. **De-noised this suite is roughly +2/-1 -- the OPPOSITE sign
from its aggregate.**
- **hard_quick (net +1)**: real losses `ECM.128` (clean, +2.66, no
oscillation) and `ECM.846` (explicitly RECLASSIFIED NON-flaky on 2026-09-11
from two-engine deep stable agreement); real gains `ECM.476`
(Crafty-confirmed legit) and `ECM.803` (Crafty finds mate in 10 from the
solution move). `ECM.866` is catalogued verbatim as "a genuinely coin-flip
position." `ECM.018`/`084`/`516`/`640`/`774` NOT CATALOGUED.
**Uncatalogued positions that flipped and should be triaged into ECM.md**:
`ECM.348`, `ECM.018`, `ECM.084`, `ECM.516`, `ECM.640`, `ECM.774`.
### Implication: section 6 (original)'s admission decision may rest on noise
`LMR_STATIC_ADMIT_THOUSANDTHS = 500` was chosen over 1000 because 1000 "lost
solves on every single suite simultaneously (-2/-2/-1) -- a consistent,
same-direction signal, not noise." But a single configuration churns 4/11,
7/90 and 15/90 positions here, with each net produced by catalogued-junk
cancelling catalogued-clean in BOTH directions. A -2/-2/-1 aggregate sits
well inside that churn.
Direction-consistency across three small aggregates was treated as evidence
against noise. That inference does not hold when each aggregate is itself a
near-cancellation of 8-36% churn.
The same caveat applies to **section 7 (original)'s LMP rejection** (-1 on two suites)
and **section 8 (original)'s "no suite lost a solve relative to the admit=500
baseline."** None of those conclusions are refuted -- they are unsupported,
which is different. 500 is not shown to be wrong; it is shown to rest on
nothing.
**Protocol change this implies**: never report a curated-suite solve delta as
an aggregate again. Diff per position, classify each flip against ECM.md, and
report "N real gains / M real losses / K junk / J uncatalogued." The script
used here is three lines of `grep`/`sed`/`join` over the two `.out` files and
should be checked in rather than re-derived.
## 10.7. Proposed directions, in priority order
**Superseded in part by 10.9** -- written before the magnitude question was
reconciled (10.8). 10.7a and 10.7c still stand as written; 10.7b is absorbed
into 10.9c; 10.7d is DONE; 10.7e still stands. Read 10.9 for the current
direction list.
### 10.7a. HEAD vs HEAD at 2:1 time odds -- the decisive unrun experiment
Grepped `lmr_testing/*.md`, `../src/CLAUDE.md` and `../tests/ECM.md`: this
has never been run or recorded. It is the highest-value measurement available
and it depends on neither ECM nor any proxy.
**CORRECTED 2026-10-03** -- an earlier version of this section had the
ebf/time conversion INVERTED (it claimed +0.65 ply was "a 0.19x doubling").
The correct arithmetic, at ebf 3.6:
- One ply costs a factor of ebf in nodes, so one DOUBLING of time buys
`ln(2)/ln(3.6)` = **0.54 ply**, not ~1.6.
- Conversely this candidate's +0.65 ply costs `3.6^0.65` = 2.30x the nodes,
i.e. it is worth about **1.20 doublings** of time-equivalent.
That inversion mattered, because it made one of the two branches below look
plausible when it is not. With the correct conversion, if the measured
Elo-per-doubling is D, then a sound +0.65 ply "should" be worth
`1.20 x D` Elo:
| measured D (Elo/doubling) | expected Elo for +0.65 ply | vs. the ~+2 to +5 measured |
|---|---|---|
| 60 | ~72 | 15-30x shortfall |
| 40 | ~48 | 10-20x shortfall |
| 20 | ~24 | 5-12x shortfall |
| ~3 | ~4 | matches |
So for the candidate's measured result to be "exactly what depth is worth
here", a doubling of time would have to be worth only ~2-4 Elo -- i.e.
doubling the thinking time barely helps this engine at all. That is a strong,
falsifiable claim, and the measurement settles it:
- **D is small (< ~10 Elo/doubling)**: typhoon is severely eval-limited.
Depth is nearly worthless to it, no search-efficiency work can pay, and the
lever is eval/speed. Consistent with the known 1.5M-vs-7.5M nps gap vs
Crafty and with eval being the separately-identified bottleneck.
- **D is normal (~20-70 Elo/doubling)**: then reduction-bought plies are
worth drastically less than time-bought plies of the same nominal size, and
the question becomes WHY. That is a search-QUALITY investigation (what is
being lost in the reduced tree), not a tuning exercise -- and it would
explain the whole project's "always slightly positive, never significant"
pattern in one stroke.
Note which way the remaining bias runs: Elo-per-doubling generally SHRINKS
with depth (diminishing returns), so a D measured at a short time control is
an upper bound on D at a longer one. Using a short-TC D therefore
over-estimates the expected Elo and over-states the shortfall. Record the
time control the calibration was actually run at.
### 10.7b. Direct the cuts rather than widening them further
At 93-94% admission and 85% flat -1 ply, the mechanism is close to "reduce
every late quiet move by one ply" -- a uniform tax. A uniform tax lowers ebf
and buys depth, and it structurally CANNOT protect critical lines, because
nothing in it distinguishes them. Section 1 (original)'s dynamic signal was demoted to
extras-only, section 8 (original) removed the joint table from magnitude, and the
admission gate it still owns vetoes 2.3% of the cell space: step by step, each
individually justified, the mechanism converged on undirected.
Three inputs for directing it are already in scope and unused:
1. **`iImprovement`** is a declared parameter of `GetLMRReduction`
(searchsup.c:638) and is never read in the body. Not-improving nodes
conventionally reduce ~1 ply more.
2. **`pi->eNodeType`** is computed for every node and is exact
(`NODE_PV` iff wide window, searchsup.c:2203). CUT nodes tolerate more
reduction than ALL nodes, which must search everything to prove a fail-low.
LMR ignores the distinction.
3. **`g_HistoryCounters`/`g_ContinuationHistory`** feed move ordering but
reach reduction only as a binary FH-rate veto, never as a scale on R.
Frame these as flat structural +/-1 adjustments on a single smooth surface --
explicitly NOT the "precise learned per-situation" grading that failed in
section 8 (original). They have no buckets to miscalibrate, which is why section 9 (original)'s own
pattern predicts they behave differently from the tier payouts.
### 10.7c. Re-examine the original section 9's conclusion
Section 9 (original) concluded that simple broad structural levers work and precise
learned per-situation levers fail. That is an accurate description of what
moved NODE COUNTS. It may be the wrong inference about Elo: the broad lever is
what produced the no-residual, flat-accuracy depth in section 1 (original), and all three
of section 9 (original)'s supporting measurements (sd10 node count, sn5M solve count,
by-tier research rate) are now either noise-level (section 6 (original)) or no longer
meaningful (section 10.5). Node count is not Elo. This is the inference in this
document I would trust least.
### 10.7d. Fix the counter bug before the next calibration read -- DONE 2026-10-03
Section 10.4. Fixed: `JOINT_EXTRA_TIER_COUNT` 4 -> 6 (chess.h:999), with the
broken lockstep against `JOINT_EXTRA_MAX_PLIES` documented in place. Every
user of the constant was already bounded by it (both counter structs, the
`JOINT_EXTRA_VERIFY` arrays, all six `script.c` loops), so nothing else
needed touching. Raised the count rather than clamping `MAX_PLIES` back to 3,
which would have been a search-behavior change rather than a fix. Clean
rebuild, no warnings, batch self-test passes.
Note for anyone reading counters from a binary built BEFORE this fix --
including the 1710-game match in 10.1 -- tier 0's re-search rate and
`u64LMPPruned` are corrupted there. The game results are unaffected.
### 10.7e. Re-derive the admission threshold, if 10.7a says depth is worth buying
Nothing currently justifies 500 over any other value (section 10.6). If 10.7a shows
depth converts, sweep it against a metric that survives scrutiny -- move
agreement against a deep oracle over a few hundred positions gives far more
samples per position than a binary solved/unsolved on 90, and measures
"better decisions per node" directly.
## 10.8. Why magnitude is shallow: fully reconciled, plus a unit landmine fixed
Follow-up to 10.3 ("magnitude is still overwhelmingly base-only"), which
reported the symptom without explaining it. Driven by a direct question:
if we have a 4-axis table that judges P(fail-high), why fall back on a
"more primitive" `log(depth)*log(moves)` formula to size the bet? We are
at a blackjack table and should bet more when we have an edge.
The answer turns out to be that the magnitude *function* was never the
binding constraint, so swapping it changed almost nothing -- and the
reason it isn't binding is a deliberate invariant, not an oversight.
### The table is not signal-starved
Reassembled the `FOURAXISRAW` collection cells from the three 10.2 `sd10`
runs (87.8M eligible tries, 25,159 distinct raw cells) and looked up every
cell's shipped prediction. Tries-weighted, over the admitted population:
| predicted FH | share |
|---|---|
| < 0.001% | 3.49% |
| 0.001-0.005% | 18.97% |
| 0.005-0.072% | 48.07% |
| 0.072-0.5% | 18.84% |
| >= 0.5% (admission veto) | 10.36% |
**70.5% of admitted moves read below 0.072%** (the old TIER1 boundary) and
22.5% read below 0.005%. So "we rarely encounter moves the table thinks are
safe" is false -- it thinks most of the population is safe.
### The two magnitude sources are a wash
Over LMR's actual population (see the correction below), mean extra plies:
**4-axis table 0.224, flat log(d)*log(m) 0.244** -- the flat formula is
marginally MORE generous, and they put the same ~62% of traffic in tier 0.
Within the depth>=4 slice where extras are legal at all, 0.919 vs 1.002.
They disagree per-cell on 36% of depth>=4 tries (21% where the table wants
less, 15% where it wants more) at essentially identical means.
So section 8 (original)'s swap did not meaningfully change how much we
reduce. It changed WHICH moves get the extras, at the same aggregate. That
also means section 8 (original)'s headline result -- ringers 23.04M ->
19.56M nodes, attributed to this swap -- **is not explained by the change
it was attributed to.** Still unexplained; flagging rather than guessing.
### What actually binds: the one-full-width-ply invariant
`Search()` drops into `QSearch` at `uDepth < THREE_QUARTERS_PLY`
(search.c:462). A reduced move consumes TWO plies before any extra (the
normal `- ONE_PLY` decrement plus the base `-ONE_PLY` carried in
`iExtend`), so `JOINT_EXTRA_MIN_REMAINING_DEPTH = ONE_PLY` encodes exactly
"a reduced move still gets at least one genuine full-width ply before the
horizon." `searchsup.c`'s `iMaxExtra` then works out to
**`remaining_plies - 3`**, and `uDepth > TWO_PLY` (search.c:1254) enforces
the same invariant for the base ply (at remaining depth 3, base-only leaves
exactly `ONE_PLY`). One invariant, two gates -- the design is coherent, and
this floor is load-bearing rather than conservative padding.
Consequence: **no extra ply is permissible below remaining depth-bucket 4,
for any signal, from any source.** Depth mix of the LMR population:
| depth bucket | share of LMR pop | iMaxExtra |
|---|---|---|
| 2 (uDepth 129-191) | 45.7% | 0 |
| 3 | 30.5% | 0 |
| 4 | 14.7% | 1 |
| 5 | 5.8% | 2 |
| 6 | 2.3% | 3 |
| 7+ | 1.1% | 4+ |
Only ~23.9% of the population can receive an extra ply at all. This is just
the shape of an alpha-beta tree: node count grows with ply-from-root, so the
mass is near the leaves, where there is no depth left to cut.
### Reconciliation of the model against the engine
10.3 left a 2.7x discrepancy between a first (wrong) model and the measured
mean. Both causes found:
1. **Wrong population floor (my error).** The first pass filtered at depth
bucket >= 3, but `uDepth > TWO_PLY` admits bucket 2 (`uDepth` 129-191),
where `iMaxExtra` is 0. Bucket 2 is 21.25% of the eligible population and
was excluded entirely. Note also that `FOURAXISRAW` collection is gated on
`fPruneReduceEligible` ALONE -- it does not apply LMR's own
`uDepth > TWO_PLY`, so the collection population is ~4x LMR's and must be
filtered before any magnitude projection. Easy trap; it is a different
population than the one `u64FHJointHist` counts.
2. **The dynamic component**, as predicted. Read straight off
`u64FHJointHist` with no rebuild: `fDynamicDanger` (own rate > the 0.400%
baseline, with any samples at all) zeroes the extras on **27.4%** of LMR
decisions (27.0 / 25.1 / 28.6% across the three suites, 46.9M calls).
62.1% are warm-with-rate-exactly-0 and 7.1% pure cold; both pass.
| model | mean extra | x (1 - 0.274) | measured |
|---|---|---|---|
| bucket >= 3 (wrong floor) | 0.440 | 0.320 | 0.164 |
| **bucket >= 2 (correct)** | **0.244** | **0.177** | **0.164** |
Within 8%, and the residual has a known sign: `FOURAXISRAW` increments
happen AFTER the EFP decision, so EFP-pruned moves get a
`GetLMRReduction` call (counted in the 46.9M histogram) but no collection
increment. EFP fires near the horizon, so the proxy is biased away from the
shallowest depths, which inflates the model exactly as observed.
**So the magnitude story is: the clamp is the dominant factor, the dynamic
veto is the second, and the choice of magnitude function is third and
nearly irrelevant.**
### Unit landmine fixed (2026-10-03)
`GetMoveFailHighThousandths` returned PER-MILLE (0-1000) while
`g_uJointFHThousandths` is in thousandths of a PERCENT -- 100x finer. Every
caller was individually correct, but the two families were indistinguishable
by name, and `ShouldLatePrune` held both four lines apart:
`LMP_DYNAMIC_MAX_THOUSANDTHS (1)` meaning 0.1% beside
`LMP_STATIC_MAX_THOUSANDTHS (1)` meaning 0.001%. The function's own doc
comment asserted it shared the static table's unit "so a caller can compare
this move's own dynamic rate directly against the static table's
prediction" -- which is how the trap got planted.
Fixed by converting the whole dynamic family to the table's unit (not just
the two constants originally flagged -- leaving EFP and LMP in per-mille
would have relocated the trap):
| constant | was | now |
|---|---|---|
| `GetMoveFailHighPctThousandths` (renamed) | 0-1000 | 0-100000 |
| `LMR_FH_BASELINE_PCT_THOUSANDTHS` | 4 | 400 (0.400%) |
| `LMR_STATIC_ADMIT_PCT_THOUSANDTHS` | 500 | 500 (unchanged) |
| `EFP_FH_PRUNE_THRESHOLD_PCT_THOUSANDTHS` | 100 | 10000 (10%) |
| `LMP_DYNAMIC_MAX_PCT_THOUSANDTHS` | 1 | 100 (0.100%) |
| `LMP_STATIC_MAX_PCT_THOUSANDTHS` | 1 | unchanged |
Documented in-code: the old `> 4` per-mille test could only fire once
`fh/attempts >= 0.005`, so the SHIPPED gate behaved like 0.5%, not the
0.400% it documented. 400 is faithful to the intent and a hair stricter than
what shipped; the affected band needs >= 200 observations of a single move
identity to express and is essentially unpopulated per `u64FHJointHist`.
Intended behavior preserved; shipped behavior changed immeasurably.
## 10.9. Future directions from 10.8
Premise, per Scott: when the table says a move is safe we should be more
willing to (1) reduce beyond the clamp, letting the move drop straight into
qsearch, or (2) prune it outright. Both are right in kind; they differ by
more than an order of magnitude in expected payoff, and the measurement
says which is which.
### 10.9a. Reduce into qsearch when the table certifies safety -- a SAFETY probe, not a depth lever
Make `JOINT_EXTRA_MIN_REMAINING_DEPTH` table-conditional: `ONE_PLY`
normally, 0 when predicted FH clears a strict bar. This is the FIRST place
in this project where the table would do something the flat
`log(d)*log(moves)` formula structurally cannot express -- a conditional
floor. It is also strictly safer than pruning: the move still receives a
qsearch score and still trips the existing fail-high re-search
(search.c:1484), so it stays recoverable in the one direction we can detect.
Measured payoff ceiling (mean extra plies, before/after the 27.4% dynamic
veto):
| variant | mean extra | after dyn veto |
|---|---|---|
| today (`MIN_REMAINING = ONE_PLY`) | 0.224 | 0.162 |
| unconditional `MIN_REMAINING = 0` | 0.506 | 0.368 |
| conditional, pred < 0.005% | 0.302 | 0.219 |
| conditional, pred < 0.001% | 0.227 | 0.165 |
**Then apply section 5 (original)'s own damping measurement** (1.0 extra
ply/decision -> +0.29 measured root ply): the conditional-at-0.005% variant
buys ~+0.057 extra ply/decision, i.e. roughly **+0.017 root ply.** The
unconditional version reaches ~+0.06 root ply. Both are an order of
magnitude below the +0.65 ply this candidate ALREADY achieves, and well
below measurement noise.
**So this cannot be justified as a strength experiment -- the arithmetic
will not support it.** Its real value is as the cheapest available probe of
whether the table's "safe" verdict survives a much harsher consequence: if
pred<0.005% cells can lose their last full-width ply with no measurable
damage, that is direct evidence the table is trustworthy at the extreme,
which is the precondition 10.9b needs anyway. Run it at the strict
threshold, judge it on silent-miss rate (reusing `JOINT_EXTRA_VERIFY` plus
the `NEUTRALIZE_NULL_BAILOUT` fix, per section 8 (original)'s lesson), and
do NOT expect or claim a depth gain.
Risk class to watch specifically: the refutation that is itself a quiet move
one ply deep. That is precisely the documented genuine-miss class 2 (the
`Bb6` deflection, invisible to qsearch by construction), and it is the exact
thing the discarded full-width ply existed to catch.
### 10.9b. Prune outright -- where the order of magnitude actually is
Reduction saves plies off a subtree; pruning removes the subtree. The
savings scale completely differently, which is why this dominates 10.9a:
- 1.88% of eligible tries sit at depth >= 4 with predicted FH < 0.005%
(0.87% at depth >= 5, 0.38% at depth >= 6).
- By SPEC.md's own order-of-magnitude check a depth-6 subtree is ~20,000
nodes against 30-100 for a near-leaf node.
- A 1.88% population of 100-1000x larger subtrees dominates the node budget
in a way no magnitude change can approach.
The machinery already exists (`ShouldLatePrune`, searchsup.c, disabled at
search.c:1228) and section 7 (original)'s concrete lead is still unused:
**43 cells with a genuine 0.0% observed fail-high backed by >= 288,000
tries, concentrated at hang/dumb bucket 9 essentially regardless of
depth/gap/late, the largest with 5.1M tries and zero fail-highs.** The
criterion is structural ("this move obviously hangs material for nothing"),
not positional, and it replaces the current backwards near-leaf depth
ceiling -- which section 7 (original) already documents as permitting
pruning exactly where our own table says risk is HIGHEST.
Prerequisites, unchanged from section 7 (original) and SPEC.md's agreed
design requirements: ship per-cell sample counts (`bake_joint.py` +
`data.c`) so a rule-of-three bound can gate on evidence rather than on
remaining depth, and build the prune-specific shadow-verification harness
BEFORE pruning for real.
### 10.9c. Coverage into the populations currently excluded outright
Neither 10.9a nor 10.9b touches anything outside the already-admitted
leftover pool. Still untouched: the 10.4% that admission vetoes, every
`NODE_PV` node, and every non-leftover (sub-`GOOD_MOVE`-failing) move --
`fPruneReduceEligible` excludes the latter two structurally. Crafty and
Ethereal both reduce all quiet moves past a move-count threshold, and
"coverage beats magnitude" is the most replicated finding in this document
(section 3 (original), section 6 (original), 10.2). This is the largest
remaining lever by population size, and 10.8 is the explanation for WHY it
is larger than magnitude: magnitude is capped by the depth distribution,
coverage is not.
Also in this family, and nearly free: the three unused inputs from 10.7b
(`iImprovement`, a declared-but-unread parameter of `GetLMRReduction`;
`pi->eNodeType`; history as a scale on R rather than a binary veto).
### 10.9d. Standing caveat over all of the above
10.7a (HEAD vs HEAD at 2:1 time odds) still gates every one of these. If a
ply is worth ~25 Elo in this engine rather than the ~60-70 a healthy engine
shows, then even 10.9b's node savings convert poorly and the real lever is
eval/speed, not search. That measurement is cheaper than any direction here
and decides whether to pursue them at all.
### Two errors of mine recorded, because the reasoning is the useful part
1. **"Relax `JOINT_EXTRA_MIN_REMAINING_DEPTH` to 0 as a cheap way to buy
magnitude"** -- withdrawn. It is not a magnitude tweak; it converts
reduction into a qsearch-only evaluation, i.e. stealth LMP, and the
invariant being relaxed is load-bearing (see 10.8). It survives only in
the much narrower, table-gated, safety-probe form of 10.9a.
2. **Filtering the collection population at depth bucket >= 3** instead of
>= 2, which over-predicted mean magnitude by ~1.8x and briefly made the
clamp look like it hard-capped 56% of decisions rather than the correct
~76% (buckets 2 and 3 combined). Corrected in 10.8; the lesson is that
`FOURAXISRAW` and `u64FHJointHist` count DIFFERENT populations and
neither is LMR's without explicit filtering.
## 10.10. The time-odds calibration, and the Crafty gap decomposed (2026-10-03)
Two measurements that between them reframe this whole project. Neither
depends on ECM solve counts or any proxy.
### 10.10a. A ply is worth ~95 Elo to this engine
HEAD vs HEAD at 2:1 time odds (`--st 2 --odds 2`, same binary both sides,
no SPRT -- measuring a quantity, not testing a hypothesis). Stopped by hand
at 170 games once the interval was decisive:
| games | score | Elo | depth (cand vs head) |
|---|---|---|---|
| 20 | 0.700 | +147 +/-109 | 11.93 vs 11.08 |
| 70 | 0.650 | +108 +/- 58 | 12.37 vs 11.33 |
| 120 | 0.646 | +105 +/- 45 | 12.33 vs 11.47 |
| 170 | 0.621 | **+86 +/- 37** | 12.37 vs 11.47 |
**Result: a doubling of time buys ~0.90 ply and ~+86 Elo, i.e. ~95 Elo per
ply** at this time control on this box. The 95% interval [48, 123] excludes
by a wide margin the "a ply is worth almost nothing here" hypothesis that
10.7a entertained.
**This is the yardstick the project never had.** Any future depth-buying
change can be pre-registered against it: predicted ceiling = measured ply
gain x ~95 Elo. A change that buys +0.3 ply has a ~28 Elo ceiling (so
`elo0=0 elo1=15` will resolve it in hours); a change that buys +0.05 ply
cannot clear any bar worth caring about and should not get a match at all.
That single number retires the entire class of 2,000-game inconclusive runs
this document is otherwise a history of.
### 10.10b. Reduction-bought plies are worth ~1/11th of time-bought plies
Side by side, same engine, same box:
| source of depth | ply gain | Elo | Elo/ply |
|---|---|---|---|
| 2x time | +0.90 | +86 | **~95** |
| LMR candidate (10.1) | +0.65 | ~+5 | **~8.5** |
| the section 5 (original) overnight "dodgy thresholds" run | +0.9-1.0 | even-or-better | ~0-10 |
Two independent LMR configurations convert at under 10 Elo/ply while time
converts at ~95. **The conversion loss is structural to reduction, not a
function of how hard it is pushed** -- the more aggressive config did not
convert better. So "squeeze LMR until it reaches 1.0 ply" predicts roughly
+10 Elo, not +95, and at r~2.0 (below) reaching 1.0 ply means HALVING node
count, against the ~27% savings achieved today.
Leading explanation (Scott's, and it fits): nominal root depth advances
honestly (PV nodes are never reduced), but the REFUTATION lines -- the late
quiet moves at ALL nodes whose fail-low proofs justify the move choice --
are searched shallower than before. Alpha-beta's answer is only as good as
its cutoff proofs, and reduction weakens exactly those. Time-bought depth
deepens everything proportionally; reduction-bought depth deepens the PV
while thinning its own verification.
### 10.10c. `local_ebf` was dead on arrival; the real branching factor is ~2.0, not ~3.9
Three stacked bugs hid this, found while trying to answer "is our branching
factor really 3.9?" (Scott's suspicion: it is ~2.0, and the harness's number
is inflated by aspiration/root-PV-change re-search cost rather than real tree
growth):
1. `match_play.py`'s `ebf` is `nodes ** (1.0/depth)` on the CUMULATIVE node
count -- not a per-ply ratio. Its own docstring (2026-09-18) documents
this and says `local_ebf` was added to fix it.
2. `local_ebf` -- the correct estimator (geometric mean of
`nodes_now/nodes_prev` across consecutive completed iterations) -- read
`0.000` in every match since it was added. `_ITER_RE` was applied only in
the trailing stats parser, but the per-iteration PV lines arrive DURING
the search, in the move-scan loop, which read and discarded them.
3. Even after fixing (2), still 0.000: `Engine.__init__`
(`tune_eval_dna.py:120`) sends **`nopost`**, and `root.c:248` gates
per-iteration PV output on `g_Options.fShouldPost`, so the lines were
never emitted at all. Now opt-in via `match_play.py --post`.
Measured once working, `sd 9`, two engines: **local_ebf 2.02 and 2.12**
against the same runs' cumulative `ebf` of 4.12 and 4.14. Scott's estimate
was right and the published ebf has been wrong in every match report in this
project. Independently corroborated by the time-odds depth response: 2x time
-> +0.90 ply implies a per-ply ratio of `2^(1/0.90)` ~= 2.1.
Also found: **`--st` takes integer seconds** -- `--st 0.5` yields "Soft time
limit 00:00.000", i.e. an effectively instant search. Any sub-second `--st`
value in a past experiment produced garbage games.
### 10.10d. The Crafty gap: 4.7 plies of branching + 2.7 plies of speed
Direct measurement, 12 real-game positions from `tests/cross_sectional_1.ep_`,
both engines at fixed depths 6-12, 64M hash, single-threaded, book off
(`/tmp/typhoon/cr_measure/`). Node accounting verified comparable
beforehand by code reading: Crafty increments per `MakeMove`
(`search.c:452`, `quiesce.c:185/238/352`, `root.c:101` -- qsearch nodes ARE
counted), typhoon once per `Search`/`QSearch` entry (`searchsup.c:2019`).
Fitting `nodes(d) = C * r^d`, with `r` taken from consecutive-ratio
geometric means (NOT from the regression -- `C` and `r` trade off badly over
a 7-point depth range, visible as absurd `C` of 1,362-4,102 on Crafty's
low-`r` positions):
| | typhoon | Crafty |
|---|---|---|
| **r** (median) | **2.17** | **1.74** |
| r range across 12 positions | 1.75-2.50 | 1.44-1.99 |
Decomposition of the ~7-ply gap Scott observes in live games (Crafty at ~19
when typhoon is at 12):
| term | contribution |
|---|---|
| branching (r 2.17 vs 1.74 -> depth x 1.391) | **+4.7 ply** |
| speed (1.43M vs 6.6M nps = 4.6x nodes) | **+2.7 ply** |
| predicted Crafty depth at typhoon's 12 | **19.4** (observed ~19) |
Two independently measured quantities reproduce the observation with no
residual.
**A hypothesis this KILLS, recorded because it was mine and it was wrong:**
that the gap lives in `C` (unit-subtree cost) because typhoon's qsearch is
too wide. typhoon's qsearch is indeed ~84% of its nodes against Crafty's
~15% (same position, `sd 8`: typhoon 482,156 nodes / 406,205 qnodes; Crafty
65,688 nodes / ~10K evals) -- but `C` fits LOWER for typhoon (~287) than
Crafty (~448), and `C` sits inside the logarithm, so even a 20% `C`
reduction is worth ~0.3 ply. The qsearch width difference is real and
costs almost nothing in depth. The earlier arithmetic that made `C` look
decisive assumed equal `r` and failed a plausibility check (it implied
Crafty's depth-1 search costs 11 nodes, when it must visit ~35 children).
So: **the branching term is the bigger half of the gap and the one that can
be attacked.** Scott's instinct to work on search efficiency was right.
### 10.10e. Crafty's lazy-eval rates, measured
Settling a disagreement about whether Crafty's eval is cruder. Crafty has
TWO lazy exits and the printed fields distinguish them (`iterate.c:624-630`,
both percentages over denominator `evaluations + lazy_evaluations`):
`lazy=` is the FIRST (material-only, `evaluate.c:60/64`), `full=` went
through piece scoring, and the residual is the SECOND lazy exit
(`evaluate.c:243`, skips piece scoring).
12 positions, `sd 10`: material-only **28.8% mean** (median 30, range 0-45);
second lazy exit **24.9%**; full eval **46.3%**. So **~54% of Crafty's eval
calls skip the expensive piece scoring**, which supports Scott's 40-50%
recollection; the material-only tier alone is ~29%, not the 20% claimed
earlier in this session from a single position at a single depth (the same
position reads 30% at depth 10 -- that number was a bad sample, and
depth-dependent).
typhoon has the matching three-way split (`u64SuperLazyEvals`/
`u64LazyEvals`/`u64FullEvals` plus cycle counts, `root.c:508-542`) but
behind `#ifdef LAZY_EVAL`, not in the standard profile -- one rebuild gets a
like-for-like comparison.
**Caveat on what this can support**: lazy exits fire when the score is
already far outside the window, i.e. in lopsided positions where detail does
not matter. These counters measure eval COST, not eval QUALITY where it
counts. Neither side of the eval-quality argument is settled by them.
### 10.10f. Exchange truncation: the mechanism, and three fixes with very different costs
Scott's symptom: losing a pawn or knight "at the end of an exchange" against
Crafty, prompting an idea to extend all captures fractionally.
NOT the futility margin -- `iFutility = iAlpha -
(FUTILITY_BASE_MARGIN_BY_SOURCE[pi->uLastPositionalSource] +
rgiPositional[...]) - iEval` is indexed by eval's own positional source,
i.e. genuinely eval-calibrated rather than hand-picked.
The mechanism is the SEE-ordered early exit at the top of `QSearch`'s move
loop: moves are SEE-sorted, and the first `iValue <= 0` does `goto end`,
abandoning every remaining move at that node. So qsearch only ever explores
captures SEE scores as non-losing, and **any exchange whose resolution
requires passing through a SEE-negative intermediate capture is outside the
horizon entirely.**
That cutoff is sound only to the extent SEE is right, and SPEC.md already
documents that it is not, specifically: `_WhoAttacksSquareBB` (see.c) has no
pin or legality awareness -- the finding that made the queen-vs-queen dumb
bucket read backwards and got that signal deleted. A pinned defender still
counts as a recapturer, so SEE calls a winning capture losing and qsearch
never tries it. Same root cause, different symptom.
| fix | touches | cost |
|---|---|---|
| make SEE pin-aware (see.c) | `C` | ~0.3 ply for a 20% `C` rise; also improves every other SEE consumer (`fPruneReduceEligible`'s capture rule, EFP, `MVF_SEE_*`) |
| admit SEE<0 captures at shallow qsearch depth | `C` | bounded, tunable |
| extend all captures fractionally | **`r`** | exponential; at ~95 Elo/ply, expensive |
Recommendation: fix the cause on the `C` side before spending `r`.
### 10.10g. Next steps implied
1. **Measure Delta-r directly for extension changes, do not infer it from
the counters.** `root.c:568-577` prints every extension source count, but
those count GRANTS, before `MINU(ONE_PLY, ...)` caps and before
`g_uExtensionReduction[]` decays -- so they cannot give the applied ply
inflation that actually moves `r`. The 12-position C/r sweep
(`/tmp/typhoon/cr_measure/run.sh`) is now the right instrument: build with
extensions trimmed toward Crafty's single SEE-gated 1-ply check and
re-run it. Crafty has exactly ONE extension (`search.c:476/483`, checks
only, `check_depth=1`, SEE-gated, and it disables reductions for that
move); typhoon has ten fractional per-move sources plus five node-level
ones. Whether typhoon's 3/4-ply-but-decayed check extension "washes out"
against Crafty's 1-ply-but-SEE-gated one is exactly the question a Delta-r
measurement answers and counter arithmetic does not.
2. **Route matters more than destination.** Closing `r` from 2.17 to 1.74 is
worth 4.7 plies ~= 450 Elo at the 10.10a yardstick. But reduction-bought
plies convert at ~8.5 Elo/ply (10.10b), so the question is not "how do we
lower r" but "how does Crafty lower r SOUNDLY". Extension trimming is the
first candidate identified that is not "reduce harder": it declines to
spend effort rather than suppressing information.
3. **Clamp removal is off the table.** Stockfish floors the reduced depth at
1 ply (`search.cpp:1370`, `std::max(1, ...)`) -- the same invariant
typhoon has -- and achieves its leanness through separate explicit
pruning (Step 15, `search.cpp:1166`). Crafty has no such floor and does
not even verify a move reduced into qsearch (the full-depth re-search at
`search.c:957` is inside the `depth > 0` branch only). The strongest
engine keeps typhoon's invariant. Combined with 10.9a's arithmetic
(+0.017 root ply), two independent lines say drop this idea.
4. The 10.9b hang-bucket-9 pruning lead is unaffected by any of the above
and remains the best-evidenced pruning target (3.3% of eligible tries,
observed FH 0.0112%, 44x safer than hang bucket 0).
## 10.11. Where the branching gap actually comes from, and the prioritized backlog (2026-10-03)
### 10.11a. Crafty's r advantage is move-list TRUNCATION, not reduction grading
Settling the "should we grade reduction magnitude harder" question for good.
Crafty's entire LMR adjustment, `search.c:579-581`:
```c
reduce = LMR[Min(depth, 31)][Min(order, 63)];
if (reduce && (pv_node || extend))
reduce--;
```
One decrement. No history, no improving, no node-type, no per-move signal --
and it reaches r = 1.74 against typhoon's 2.17. **So adjustments cannot be
where their leanness comes from.** (Stockfish does have a dozen such
adjustments; Crafty does not, and Crafty is still far leaner than typhoon.
This retires the "typhoon's candidate is SF's base with no adjustments"
theory floated earlier in this session, and matches Scott's report of five
prior LMR attempts with assorted factor-mucking, none of which paid off.)
What sits immediately ABOVE those lines in Crafty's move loop does the work:
```c
if (depth < FP_depth && !check && MaterialSTM(wtm) + FP_margin[depth] <= alpha && !pv_node) {
tree->moves_fpruned++; break; // abandons ALL remaining moves
}
if (order > LMP[depth] && depth < LMP_depth && !pv_node && !check && ...) {
tree->moves_mpruned++; break; // abandons ALL remaining moves
}
```
Measured in one `sd 8` sample: `fp=10.0K mcp=10.3K` against `nodes=65688` --
**~20,000 move-list truncations in a 66,000-node search.** typhoon has EFP (a
per-move `continue`) and a disabled LMP, and **no truncation mechanism at
all**. That is the structural difference behind the 4.7-ply branching term
in 10.10d.
### 10.11b. "We are holding the 4D table wrong" -- the specific sense
The table is currently consulted for reduction ADMISSION at a threshold that
vetoes **2.3% of the cell space** (733 of 21,780 cells read >= 500, and 224 of
those are in the sentinel'd dumb bucket 1). Section 8 (original) removed it
from magnitude; section 6 (original) demoted the dynamic half to extras-only.
Step by step a surface with a ~28,000x dynamic range in predicted FH% was
reduced to making a near-binary decision that fires on ~10% of traffic.
Its dynamic range only earns anything where the decision's stakes are
correspondingly asymmetric:
- A **reduction** is recoverable and its payoff is a ply or two off one
subtree. Fine-grained risk estimates are wasted there -- and 10.8 measured
exactly that: table-driven magnitude 0.224 mean extra plies vs the flat
`log(d)*log(m)` table's 0.244. A wash.
- A **prune** is irreversible and its payoff scales with SUBTREE SIZE
(~20,000 nodes at depth 6 vs 30-100 near the leaf). That is where
"0.0112% observed FH across 2.9M tries, 44x safer than baseline" is worth
having, and where Crafty operates blind on depth-and-movecount alone.
**So: Crafty gets r = 1.74 by truncating move lists on a crude rule, and we
have a measured FH surface they do not. Holding the table right means using
it to decide WHERE TO TRUNCATE.**
### 10.11c. Scott's dimension argument: a sound derivation of Crafty's `break`
Of the four axes, only **dumbness** is genuinely move-specific:
| axis | scope |
|---|---|
| gap to alpha | node-level constant |
| remaining depth | node-level constant |
| lateness (move number) | move-specific, but MONOTONE -- every later move is at least as late |
| dumbness (hang cp) | move-specific, varies arbitrarily |
Therefore: if the table **marginalized over dumbness** reads "never fails
high" for this (depth, gap, late) cell, that read applies at least as
strongly to every remaining move at the node, because two axes are fixed and
the third only worsens. That justifies abandoning the rest of the move list
-- Crafty's `break`, derived from our own data instead of assumed.
**This does NOT contradict the 2026-09-27 dumb-axis-pooling bug**, and the
distinction is subtle enough to be worth stating: that bug was
`ConfirmedZeroFailHigh` using the dumb-SUMMED marginal to vouch for a
*specific* move sitting in a rare, dangerous dumb bucket -- unsound, because
the cell's zero was produced by ordinary moves dominating its volume while
the rare queen-hanging move had never been tested on its own. The argument
here runs the opposite direction: it uses the pooled read to vouch for the
**dominant** population. Since dumb=0 is 82.3% of all tries, a pooled rate
of ~0 mathematically requires the dumb=0 subpopulation to be ~0 too, so
pooling is CONSERVATIVE for this purpose.
Preferred implementation (Scott's): per-move `continue` rather than one
`break`, so the move-specific dumbness veto still applies to the move in
hand. Nearly the same node savings, none of the blanket assumption.
### 10.11d. The near-leaf depth ceiling is backwards and should be deleted
`ShouldLatePrune`'s `LMP_MAX_REMAINING_DEPTH <= 6*ONE_PLY` was inherited
from Crafty, where a near-leaf restriction IS the whole risk model because
they have no table. Our own table shows predicted FH% FALLING ~25x from
depth bucket 0 to bucket 11, so the ceiling permits pruning where risk is
highest and forbids it where risk is lowest (already documented in
section 7 (original), never patched).
The cost of a wrong prune does rise with remaining depth -- which is what a
rule-of-three CONFIDENCE BAR is for, not a depth cutoff. Per Scott: pruning
a move with depth 11 under it is fine *if* the static read says "no way" and
the dynamic vetoes are silent; benefit rises exponentially with remaining
depth while measured risk falls.
### 10.11e. Per-cell sample counts are available NOW -- no re-collection
`joint_table_collection/pickles/raw_2026-10-02.pkl` survives and holds
per-cell `(fh, tries)` for all 20,075 populated cells; `grand_tries` =
690,062,068,603 over 56,530 positions. Running SPEC.md's rule-of-three bars
(`N ~= 3 * applications_per_game / target_misses_per_game`, ~4,800
applications/game):
| miss budget | required N | zero-FH cells clearing it |
|---|---|---|
| 1 game in 20 | 288,000 | **43** |
| 1 game in 100 | 1,440,000 | **9** |
| 1 game in 1000 | 14,400,000 | 0 |
This reproduces section 7 (original)'s "43 cells" lead exactly and adds the
1/100 tier. **The blocker named in section 7 -- that `g_uJointFHThousandths`
ships only the rate, not the sample count behind it -- is a
`splice_joint.py` + `data.c` change against an existing pickle, not a new
collection run.**
### 10.11f. Prioritized backlog
**1. HIGHEST IMPACT -- deep pruning via the table (10.11b/c/d/e).**
Rationale: Crafty's 4.7-ply branching advantage is demonstrably truncation
(10.11a); typhoon has no truncation; the payoff is exponential in remaining
depth; the data exists; 43 cells already clear a 1/20-games bar. Closing r
from 2.17 toward ~1.9 is worth ~1.6 ply ~= ~150 Elo at the 10.10a yardstick
-- an order of magnitude more than anything else on this list.
Steps: (a) ship per-cell tries via `splice_joint.py`/`data.c`; (b) build the
prune-specific shadow-verification harness FIRST, with the
`NEUTRALIZE_NULL_BAILOUT` fix applied from day one (SPEC.md's agreed design
requirement #4); (c) re-point `ShouldLatePrune` at a rule-of-three-cleared
sample bar + dumb-marginalized monotonicity criterion, deleting the depth
ceiling; (d) keep the dynamic per-move-identity veto (requirement #5).
**2. CHEAP AND UNATTENDED -- HEAD vs HEAD with LMR disabled.**
The control condition 25 years of this work has lacked. The 10.10a yardstick
measures what TIME-bought depth is worth; nothing has ever measured whether
HEAD's own flat -1 ply LMR converts. If removing it costs 0.5 ply and ~45
Elo, reduction-bought depth does convert for HEAD and the candidate broke
something specific. If it costs 0.5 ply and ~5 Elo, reduction-bought depth
has never been worth anything in this engine and the answer is LESS
reduction, not better reduction. One build, one match, runs while other work
proceeds. Use `SPRT_ARGS="--sprt --elo0 0 --elo1 15"`.
**3. DURABLE INFRASTRUCTURE -- the move-agreement test.**
Replaces curated-suite solve counts (shown to be noise at these sample sizes
in 10.6) with a metric that has a usable signal-to-noise ratio:
- take ~300 real-game positions (`tests/cross_sectional_1.ep_`);
- build an ORACLE best move per position -- ideally Crafty at high depth,
independently implemented and cheap for them;
- run each candidate at a fixed `sn` budget; record % agreement with oracle.
Every position contributes a signal (not just the ~20% that are tactical
puzzles with a known answer), there is no dependence on EPD solution
correctness, and 300 positions gives a binomial SE of ~2.9%.
Immediate use: at EQUAL node budget, does the LMR candidate pick the oracle's
move less often than HEAD? Yes => its extra nominal depth costs real decision
quality. No => the depth was simply never worth anything (a different problem
with a different fix). Refinement: score both moves with the oracle and only
count a disagreement when it prefers its own by a real margin.
**4. SMALL BUT CLEAN -- drop the six negligible extension sources.**
From a real `sd 15` dump: `zug` fired **9** times, `endg` 352, `mult+` 1,410,
`recap` 10,339, `sing` 10,296, `q+` 35,427 -- against the check extension's
1,483,277. These cannot affect anything at those volumes and need no gate;
just confirm grant counts go to zero and node counts do not move. Weighted
by ply amount the check extension is **~78% of all extension spend**; every
other source combined is ~0.35 ply ~= a ~33 Elo ceiling, and individually
3-9 Elo each -- unmeasurable by self-play (~25,000 games to separate 5 Elo
from 0). So test the mid-tier (threat + bm + 1mv + !kmvs + pawn) as ONE
bundle, not five matches.
**5. SCREEN-ONLY -- check-extension magnitude variants.**
Non-negotiable as a mechanism (Scott: prior attempts at SEE-gating caused
sacrifice-blindness; "king moves out" variants exploded the branching
factor). But the AMOUNT and gate are open: 3/4 -> 1/2 ply; material gate ->
Crafty's SEE gate; 3/4 -> 1 full ply (Crafty's amount -- extensions are not
automatically bad). ~4 minutes each on the C/r sweep
(`/tmp/typhoon/cr_measure/run.sh`). Note the 3/4-ply SEE>=0 branch is
700,479 grants = 2.1M of ~2.9M check quarter-plies, i.e. 57% of ALL
extension spend -- that is the branch worth screening, not the 1/4-ply one.
**6. DEAD -- do not revisit.**
- *LMR magnitude grading / per-factor adjustment*: five prior attempts,
plus 10.8 (table vs flat is a wash) plus 10.11a (Crafty has one
decrement and is leaner anyway).
- *Clamp removal / reducing into qsearch*: 10.9a arithmetic (+0.017 root
ply) plus Stockfish flooring reduced depth at 1 ply
(`search.cpp:1370`) while getting its leanness from separate explicit
pruning.
- *SEE<0 check extension removal*: measured Delta-r = -0.004 (median over 12
positions), a ~3 Elo ceiling. Also independently reported to cause
sacrifice-blindness in earlier work. The counter-based estimate that
sized this at 21% of extension spend was wrong by ~20x because
`uCheckByQuarterPly[1]` pools the SEE<0 and low-enemy-material branches
-- **screen with Delta-r, never size an extension from grant counts.**
- *Attacking qsearch width to close the Crafty gap*: 10.10d -- `C` fits
LOWER for typhoon (~287) than Crafty (~448), and `C` sits inside the
logarithm, so a 20% change is worth ~0.3 ply.
## 10.12. The control experiment, the conversion-rate model, and why flat LMR is hard to beat (2026-10-03, later)
### 10.12a. LMR vs no LMR -- the control condition 25 years of this work never had
`DISABLE_LMR` (GNUmakefile) makes `GetLMRReduction` return 0 before consulting
any signal. Two binaries from the SAME tree, differing only in that flag, run
head-to-head with NO SPRT (measuring a quantity, not testing a hypothesis --
same reasoning as the time-odds calibration in 10.10a). `noLMR` is the
baseline, so a positive score means LMR helps.
| games | score | Elo | depth (withLMR vs noLMR) |
|---|---|---|---|
| 50 | 0.550 | +35 +/- 69 | 13.75 vs 12.59 (+1.16) |
| 90 | 0.550 | +35 +/- 51 | 13.96 vs 12.79 (+1.17) |
| 110 | 0.559 | +41 +/- 46 | 13.72 vs 12.57 (+1.15) |
| 150 | 0.553 | +37 +/- 40 | 13.59 vs 12.43 (+1.16) |
| 180 | 0.575 | +53 +/- 36 | 13.51 vs 12.36 (+1.15) |
| 210 | 0.569 | **+48 +/- 34** | 13.54 vs 12.35 (+1.19) |
**LMR is worth roughly +40-50 Elo and buys ~1.17 ply.** Remarkably stable
across six checkpoints. The depth delta is the largest and steadiest of any
candidate in this document's history.
This settles a question nobody had ever asked: LMR *does* earn its keep here.
Scott's prior ("LMR has never earned its keep in this engine") was wrong, and
so was the stronger form of the author's "reduction-bought depth is debased"
thesis.
### 10.12b. The conversion-rate model: diminishing returns, not a flat rate
Pulling every Elo-per-ply measurement together:
| mechanism | what it buys | measured rate |
|---|---|---|
| **LMR vs NO LMR** (the FIRST reduction) | +1.17 ply | **~40 Elo/ply** |
| graded LMR *on top of* flat LMR | +0.65 ply | ~8 Elo/ply |
| pruning (`PRUNE_LIVE=10`) *on top of* graded LMR | +0.88 ply | ~8 Elo/ply |
| 2x time (NEW resource, not a transfer) | +0.90 ply | ~95 Elo/ply |
That is a decreasing-marginal-returns curve, and it is a better model than the
flat "~8 Elo/ply for all tree-shaping" claim made earlier in this session.
**Why the earlier claim was wrong**: the graded-LMR and prune candidates were
both measured against `head_reference`, WHICH ALREADY HAS LMR. Their +0.65 and
+0.88 ply were MARGINAL plies stacked on an already-reduced tree. 10.12a
measures the FIRST ply of reduction, which is where the value is.
**Practical calibration (Scott's framing, and it is the right one)**: there is
not another 40 Elo available from LMR/LMP/pruning work. There is maybe 40-45
Elo TOTAL in the whole reduction program, of which the dumb flat -1 ply
already captures ~85%. Five failed attempts to improve on it do not mean it
cannot be improved -- they mean expectations should be calibrated to single
digits, and that single digits cannot be measured in affordable self-play
(resolving +5 vs 0 needs ~10,000 games).
**ALSO NOTE a yardstick error this corrects.** 10.10a divided the time-odds
result (+86 Elo for +0.90 ply) and called it "~95 Elo per ply," then used that
to compute ceilings all session. That division assumes depth is the ONLY
channel through which extra nodes help. It is not: at fixed nodes, a ply bought
by shrinking the tree is worth far less. So "95 Elo/ply" is the rate for
TIME-bought plies specifically and must never be applied to tree-shaping
changes. Every ceiling computed from it earlier in this document was inflated
(e.g. `PRUNE_LIVE=10`'s ceiling was quoted as 33 Elo; corrected to ~7, and it
measured +7 -- it hit its real ceiling exactly rather than underperforming).
### 10.12c. Why a flat -1 ply is so hard to beat -- the actual mechanism
The curve is already present (`_FlatTableExtraPlies` reads
`g_iLMRQuietReduction`, dynamic.c's `log(depth)*log(moves)` table). The problem
is WHERE it can act. Evaluating `0.7844 + log(d)*log(m)/2.4696`:
| remaining depth | moves tried | formula | extra plies |
|---|---|---|---|
| 2 | 16 | 1.56 | **0** |
| 3 | 16 | 2.02 | 1 |
| 4 | 16 | 2.34 | 1 |
| 10 | 20 | 3.58 | 2 |
**At remaining depth <= 2 the curve prescribes ~1 ply -- which IS flat -1.**
And 76% of the LMR population sits at depth buckets 2-3 (10.8). So flat is not
a crude approximation of the curve; at the depths where the mass lives, the two
AGREE.
Measured consequence: the curve contributes mean **0.164 extra plies** over the
LMR population, which through section 5 (original)'s damping (~1.0 extra
ply/decision -> +0.29 root ply) is about **+0.05 root ply**. That is the
curve's entire current contribution, and it explains the null results directly.
The curve's remaining headroom is the ~11% of moves at remaining depth >= 4,
further limited by `iMaxExtra = depth - 3` (the one-full-width-ply invariant).
**Open and never cleanly tested**: curve-vs-flat in self-play. The 2212-game
run (section "Self-play status", 2026-09-29) tested the joint-table TIER
magnitude under the pre-unification `uLegalMoves > 5` gate, NOT section 8's
`log(d)*log(m)` version, which was only ever validated on sd10 node counts and
sn5M suites. An earlier draft of this section wrongly treated that null as
having answered the question.
### 10.12d. Retraction: eNodeType is too unreliable to modulate reduction
10.7b and 10.9c recommended using `pi->eNodeType` to reduce more at CUT nodes
than ALL nodes. **Withdrawn.** After fixing the node-type surprise counters
(10.12f) the full-width rates read: ALL nodes fail HIGH 69-71% of the time,
CUT nodes fail low 12-14%. An "ALL node" that fails high two-thirds of the time
is not behaving like an ALL node -- the structural label (assigned purely from
the parent's type) is close to uninformative here. Modulating reduction off a
signal that unreliable adds complexity for noise.
### 10.12e. QSearch / QSearchFromCheckNoStandPat review
Reviewed for logic errors in the custom stand-pat and check-generation rules.
**SHIPPED: option B -- waive the opponent-stand-pat requirement at PV nodes.**
`fIncludeChecks`' first clause suppressed check generation whenever the
opponent could stand pat at an ancestor. That suppression rests on a sound
argument *for null-window nodes*: once their stand-pat has raised alpha, no
line below that score can change what they return, so resolving mate-in-10 vs
"drops a queen" is wasted work -- and even when their stand-pat did NOT raise
alpha, the node is heading for a fail-low, which IS the parent's beta cutoff,
so any score at or below alpha answers the question identically. It does NOT
hold at a WIDE-WINDOW node, where the exact score propagates to the root and
those two outcomes are a different PV and a different reported score -- which
is the forced-mate-from-qsearch case this machinery exists for. Now:
`(fCouldStandPat[FLIP(uToMove)] == FALSE) || (NODE_PV == pi->eNodeType)`,
keeping the material requirement (an independent sanity check, not a
suppression). Reads eNodeType, never writes it. Uses eNodeType rather than
recomputing `(iBeta != iAlpha+1)` because iAlpha may have just been raised by
the stand-pat, which would make a recomputed width test read narrow for a node
that is genuinely PV.
**SHIPPED: `iExtensionAmount` unit fix.** `QSearchFromCheckNoStandPat` wrote
`uQsearchCheckExtension` (0 or 2, counting qsearch CHECK PLIES) into a field
that is ONE_PLY-scaled everywhere else (root.c writes HALF_PLY, split.c writes
iLMR, search.c writes iExtend) -- a latent 32x error. Benign as it stood (both
readers only test sign) but the field is already a documented stale-state
hazard. Now scaled; sign and all reader behavior unchanged.
**SHIPPED: `pi->iEval` published** when `QSearchFromCheckNoStandPat`'s lazy
`Eval()` actually runs. It used to stay purely local, so that ply slot kept a
stale value. Still stale on the common in-check path where Eval is never called
at all (no stand-pat is offered, so there is nothing to compute it for) --
making that safe needs invalidation on entry, a separate change.
**TRIED AND REVERTED THE SAME DAY: `iBestScore = MAX(iAlpha, iEval -
iInTrouble)`.** The observation behind it was real -- the beta test lives only
in the else-branch, so a node with `iEval >> iBeta` and hanging material
returns a fail-low instead of cutting off. The fix was wrong twice over, and
self-play caught it in 10 games (candidate depth collapsed to +0.05 ply vs
head, from the +0.65 the same tree showed without it):
Work the signs. A parent searching this node passed window
`(-iBeta_p, -iAlpha_p)`, so `iAlpha` here == `-iBeta_p`. The parent fails high
iff `-V >= iBeta_p`, i.e. iff `V <= iAlpha`. So returning exactly `iAlpha` is
not a crude placeholder -- **it is precisely the value that hands the parent
its cutoff.** Returning MAX(...) instead reports a better-than-alpha value and
denies that cutoff (~0.45 ply of lost depth), AND asserts the node is worth at
least `iEval - iInTrouble` without searching anything -- a stand-pat credit at
a discount, exactly what the branch exists to refuse.
`MIN(iAlpha, iEval - iInTrouble)` was then proposed and also rejected: it is
depth-neutral (V <= iAlpha always, so the cutoff always fires) but it cannot
produce the cutoff the original observation was about (MIN never exceeds
iAlpha), and it asserts an unearned TIGHTER upper bound, which propagates as an
inflated lower bound into the parent's `StoreLowerBound`. The original
observation is still unfixed; a sound fix needs a bound the node has EARNED.
Probably moot: a node with material in trouble almost always has a capture
available, and captures sort above the `iValue <= 0` early exit, so the move
loop usually recovers the cutoff by search.
**Confirmed correct** (checked, no change needed): the qsearch-entry reset
invariant (`search.c:573` and `command.c:739` clear `fCouldStandPat[]`,
`uQsearchNodes`, `uQsearchDepth` and reset `uQsearchCheckDepth` before every
drop into qsearch; the only other QSearch call site is
`CALIBRATE_QSEARCH_FUTILITY`-only and already inside a qsearch state);
`fCouldStandPat` scoping (each node saves/restores only its own side's flag and
never writes the opponent's, so `fCouldStandPat[FLIP(uToMove)]` genuinely
reflects the parent); paired `uQsearchDepth`/`uQsearchCheckDepth` increments
with no `goto end` between any pair; and the `uLegalMoves > 0` guard on escape
futility pruning, which correctly prevents a bogus mate score even when leading
escapes are illegal.
**The exchange-truncation mechanism**, for the record: it is NOT the futility
margin (`iFutility = iAlpha - (FUTILITY_BASE_MARGIN_BY_SOURCE[
pi->uLastPositionalSource] + rgiPositional[...]) - iEval` is genuinely
eval-calibrated). It is the SEE-ordered early exit at the top of QSearch's move
loop: moves are SEE-sorted and the first `iValue <= 0` does `goto end`,
abandoning every remaining move. So qsearch only explores captures SEE scores
as non-losing, and any exchange whose resolution needs a SEE-negative
intermediate capture is outside the horizon. That cutoff is sound only to the
extent SEE is right, and SPEC.md already documents `_WhoAttacksSquareBB`
(see.c) having no pin/legality awareness. Three fixes with very different
costs: make SEE pin-aware (`C`-side, ~0.3 ply for a 20% C rise, and improves
every other SEE consumer); admit SEE<0 captures at shallow qsearch depth
(`C`-side, bounded); extend all captures fractionally (`r`-side, exponential).
Fix the cause on the C side before spending r.
### 10.12f. Node-type surprise line: four defects
Scott's question ("how can 47% of ALL nodes fail high?") was right, and chasing
it found four separate defects in one Trace line:
1. **Denominator was total nodes**, not nodes of that type.
2. **Qsearch was included** (~84% of the tree). A qsearch node whose stand-pat
beats beta returns `iBestScore >= iBeta` immediately; typed ALL, that reads
as "ALL failed high" while being the most ordinary event in qsearch. Now
excluded from numerator (QSearch's copy MOVED to its own slots) and
denominator (`u64NodeTypeCount`, incremented in `Search()` only).
3. **Stale node types.** Every early exit in `CommonSearchInit` returns TRUE
*before* `pi->eNodeType` is assigned, and the caller's `goto end` lands on
the counters -- which scored the node against whatever type the PREVIOUS
node at that ply left behind. That produced an impossible "106.2% of PV
nodes". Now guarded by `fNodeTypedThisVisit`.
4. **The fail-low test used the in-place-raised `iAlpha`.** `search.c:2048`
does `iAlpha = iScore`, so a node that SUCCESSFULLY raised alpha ends with
`iAlpha == iBestScore` and `(iBestScore <= iAlpha)` reads true -- every
success counted as a fail-low. The PV row read 99.1%. Now uses
`iInitialAlpha`, which already existed for exactly this distinction
(`search.c:2105`).
Also added, per Scott's QNODE suggestion applied to the counters rather than
the enum: `u64QNodeTypeCount`/`u64QNodeSurprise`, plus a **PVS identity
cross-check**. The identity: a CUT node failing low returns `score <= alpha_C`,
so its parent (an ALL node) sees `-score_C >= beta_P` and fails HIGH, and that
parent cannot have had an earlier child fail low (it would have cut off first)
-- so #(CUT fails low) and #(ALL fails high) should run ~1:1. Counting qsearch
separately brought the measured ratio from 3.6 to **1.86**. The residual means
~46% of ALL-node fail-highs come from nodes resolved with NO child fail-low at
all: qsearch stand-pat cutoffs, interior-node recognizer/EGTB fail-highs, and
null-move cutoffs (including the Tord Romstad bailout, which fabricates a score
and searches nothing). The NODE_TYPE enum and every behavioral consumer (all of
which only test against NODE_PV) are untouched.
### 10.12g. PERF_COUNTERS cleanup -- the standard profile was carrying investigation debris
An audit for real WORK (not counter increments) inside `#ifdef PERF_COUNTERS`
found five sites in three categories. All three were fixed.
**Category 1 -- three `rdtsc` calls leaking into the standard build.**
`eval.c`'s king-safety hash probe bracketed itself with
`SystemReadTimeStampCounter()` on entry and exit, in one of the hottest paths
in the engine; `rdtsc` is ~20-30 cycles and partially serializing. An
`EVAL_TIME=1` flag already exists for exactly this ("make a version that counts
cycles spent in eval") and already guards the other **32** rdtsc sites in that
file -- these three were the only leaks. Moved to EVAL_TIME. (The report side
in root.c was already EVAL_TIME-guarded, so nothing dangles.)
**Category 2 -- two one-off diagnostics, deleted outright.** Both did per-
decision lookups plus integer division at the hottest decision point in the
search, and both had comments saying to remove them once their investigations
concluded:
- `u64HeadStyleEligible`/`u64HeadStyleWouldReduce` (called
`GetMoveFailHighRawCounts`). Its investigation concluded -- it produced
10.2's 92-94%-vs-70-75% admission finding.
- `u64FHJointHist` (called `GetMoveFailHighPctThousandths`). Also concluded --
it produced section 4 (original)'s bimodal-dynamic-rate finding.
Removed the counters, the `g_Options` mirrors, the suite accumulation and BOTH
report blocks -- a diagnostic that prints zeros forever is worse than none.
`AttemptsBucket`/`RateBucket` went with them. Binary shrank 7,760 bytes.
**Category 3 -- the 4D collection block, re-gated to FH_TABLE_COLLECTION.**
`search.c`'s `u64JointTries`/`u64JointFH` collection calls
`EstimateHangingMaterialCp` (real attack-bitboard work) per eligible move. It
is COLLECTION instrumentation, and CLAUDE.md already says FH_TABLE_COLLECTION
is deliberately kept out of the standard profile *because this measurably costs
nps* -- this block had escaped that gating. Verified by guard inspection:
`search.c:1637` is now `PERF_COUNTERS > FH_TABLE_COLLECTION`; the only other
`EstimateHangingMaterialCp` call site is the prune predictor, correctly behind
`PRUNE_VERIFY`/`PRUNE_LIVE`.
**Bonus:** it was computing the hang bucket TWICE per move in any build with a
prune gate (the gate needs the same value), which inflated the `PRUNE_LIVE`
candidate's nps deficit (-6.0% vs head) and made that match mildly unfair to
the candidate.
**nps impact: NOT MEASURED.** Two attempts were made and both are worthless
because the box was running a 32-engine self-play match. The second attempt
produced `perf_clean` FASTER than `noperf`, which is impossible (the cleaned
build still has every counter increment `noperf` lacks) -- proof that noise
exceeded the effect. Also a methodology error worth not repeating: the script
ran all of arm A, then all of B, then all of C, so a load drift masquerades as
an effect. **Re-measure on an idle box with ROUND-ROBIN interleaved arms**,
fixed node budget (`sn 8000000`), same position, 3+ runs each.
### 10.12h. The deep-prune audit and miss verification
`PRUNE_VERIFY` (GNUmakefile) -- audit-only shadow verification for the prune
gate. It evaluates the table's prediction for every `fPruneReduceEligible` move,
records it banded by predicted FH%, then **declines to prune** and watches
whether the move fails high in its own ordinary search. No bespoke shadow
`Search()` call is needed, which sidesteps the null-move-bailout fabrication
that made `JOINT_EXTRA_VERIFY`'s first numbers 80-99.8% artifact. Banding by the
decision variable means one run answers "is `prune if pred <= K` sound" for
every candidate K at once.
Pooled over the three curated suites (65.6M flagged decisions), measured FH%
ran **3-19x BELOW predicted** in every prunable band -- the Wilson re-bake
(10.10/10.11e) doing what it was for. But `cross_sectional` positions (250
samples, the real-game distribution) came back **5-11x WORSE than the curated
suites** in the low bands, with `measured > predicted`. The explanation is
distributional and inverts SPEC.md's stated worry: in a tactical puzzle the
action is in forcing moves, which gate-1 excludes by construction, so leftover
quiet moves there really are the dregs; in an ordinary middlegame the best move
is frequently a quiet one, landing squarely in the leftover pool. **Validating
a prune gate on puzzles understates its risk for real games.**
Verification of the 71 low-band (`pred <= 2`) cross_sectional misses against
clean deeper searches: **27 confirmed real, 44 artifacts.** The margin column
discriminates cleanly -- `margin ~= 0` means artifact (a score that only just
clears a tight inherited PVS window), huge margin means a genuine forced mate.
That inverts the author's initial guess, which had it backwards.
One confirmed band-0 miss is worth keeping as a counter-example to the dumb
axis: `6k1/p4p1p/3p2q1/2p1rP2/2P3p1/P7/1P1Qn1PK/3R1R2 w`, after `Qg6`. The
queen only LOOKS hung -- White is losing too badly to take it (best line grabs
the e2 knight; White is -14 at depth 19), so a move the table rates
"obviously hangs material for nothing, never fails high" is actually winning.
A structural mispredict that no amount of sampling conservatism catches, which
is precisely why this harness had to exist before pruning went live.
**Prune threshold measurements** (12-position C/r sweep, paired per-position
deltas -- note the author initially reported difference-of-medians instead of
median-of-differences, which flipped the sign on two of three arms):
| threshold | share of eligible | median dr | ply @d12 | verified miss rate |
|---|---|---|---|---|
| `pred <= 2` | 4.3% | -0.0040 | +0.03 | ~1 in 481,000 |
| `pred <= 5` | 16.6% | -0.0281 | +0.21 | ~1 in 360,000 |
| `pred <= 10` | 30.6% | -0.0465 | +0.35 | ~1 in 121,000 |
Band 6-10 alone carries 79% of the misses for 29% of the flagged volume, so
`pred <= 5` keeps ~60% of the depth for ~16% of the risk. `PRUNE_LIVE=10`
self-play: +0.88 ply, +7 Elo at 150 games (llr flat) -- which is exactly its
corrected ~7 Elo ceiling (10.12b), so it performed as well as it possibly could
and that is still not interesting.
Also measured and worth recording: **a dynamic veto cannot save these prunes.**
Cross-tabulating prunable-band candidates by (own-FH attempts x own-FH rate),
11 of 23 low-band misses sit at **rate = 0** -- the per-move history agrees with
the static table and both are wrong. A rule catching the 12 high-rate misses
would refuse ~390,000 sound prunes to do it (~49,000 per miss prevented), the
same failure mode that retired the `GetMoveFailHighPercentage` veto before.
## 10.13. Open action items as of 2026-10-03 EOD
Ordered by expected value. Items 1-2 are the only ones with a plausible
double-digit Elo payoff.
### 1. Give EFP the `dumb` axis -- the best remaining idea
EFP is keyed on gap-to-alpha and blind to the other three. Measured over EFP's
actual operating envelope (10.2): the **dumb axis swings FH% 15-30x** within an
otherwise-fixed cell while **gap -- EFP's only lever -- swings ~4x** across its
entire knight-to-rook range. EFP is reading the weakest signal and ignoring the
strongest. And unlike every LMR/LMP experiment, this is an **accuracy** play
rather than a depth purchase: EFP prunes the same volume either way, so it is
not buying marginal plies at ~8 Elo/ply and nothing measured so far bounds its
upside. It also finally gives the 4D table a consumer that uses its dynamic
range. Caveat from section "the tactical-position gap": the first table-driven
EFP attempt lost ECM.272 and four others and threshold-tightening rescued only
one -- diagnosed as a training-population problem. The table has been
re-collected and Wilson-re-baked since, so retry rather than assume.
### 2. The nps gap
Crafty measured at **5.5M nps** on a real 10-second search (an earlier 7.5M
figure came from a 0.01s search where the timer is meaningless -- do not quote
it) against typhoon's ~1.4M. Even discounting for Crafty counting illegal moves
as nodes (`search.c:452` increments before the legality test), that is roughly
4x. Time-bought depth converts at ~95 Elo/ply, so the speed term in the Crafty
gap (~2.7 ply) is worth ~250 Elo -- the only lever on the table with that
magnitude. Scott's profiling says abandoning the 0x88/bitboard hybrid buys
maybe 500knps, which does not close it, and the mechanism for their remaining
advantage is not understood.
### 3. Re-measure the PERF_COUNTERS cleanup on an idle box
Round-robin interleaved arms, `sn 8000000`, same position, 3+ runs each. Two
prior attempts are worthless (see 10.12g). Arms are built:
`/tmp/typhoon/candidates/typhoon_{perf,perf_clean,noperf}`.
### 4. Curve-vs-flat LMR, cleanly
Never tested in self-play (10.12c). One flag: current tree vs current tree with
`_FlatTableExtraPlies` forced to 0. Expected ~1-3 Elo from the arithmetic, so
self-play cannot resolve it -- measure **node count at fixed depth** instead,
where 0.164 mean extra plies should be visible.
### 5. Decide the fate of the 4D table
Its only live shipping consumer is `LMR_STATIC_ADMIT_PCT_THOUSANDTHS`, which
vetoes 509 of 21,780 cells (2.3% of cell space, 7.4% of traffic) -- and that
threshold was chosen in section 6 (original) on solve-count deltas that 10.6
showed were noise. **Cheap decisive test**: build with admission effectively
disabled (`= 100000`) and run against current. If neutral, the table buys
nothing in a shipping build. NOTE item 1 may make this moot by giving the table
a real consumer -- do item 1 first.
### 6. Triage six uncatalogued ECM positions into ../tests/ECM.md
`ECM.348`, `ECM.018`, `ECM.084`, `ECM.516`, `ECM.640`, `ECM.774` all flipped in
the 10.6 per-position diff and are not in the flaky/legit catalogue.
### 7. Smaller / hygiene
- **Strip the remaining one-off diagnostics**: `u64LMPPruned` and the
`JOINT_EXTRA_VERIFY` family are still wired under PERF_COUNTERS.
- **Share the hang bucket** between the collection instrumentation and any
prune gate instead of computing `EstimateHangingMaterialCp` twice (10.12g).
- **`pi->iEval` is still stale** on the common in-check qsearch path (Eval is
never called there). Only reader is `search.c:760`'s `iImprovement`, which
should never see a qsearch ply given the layering -- add an ASSERT recording
that invariant.
- **`JOINT_EXTRA_TIER1/2/3_PCT_THOUSANDTHS`** are vestigial (TIER1 appears only
in comments, TIER3 only in the disabled LMP path).
- **A sound fix for the `iEval >> iBeta` lost cutoff** in QSearch's
material-in-trouble branch is still open; both MAX and MIN forms were tried
and rejected (10.12e). Needs a bound the node has EARNED. Probably low value
-- the move loop usually recovers the cutoff by search.
- **`ShouldLatePrune`'s depth ceiling is still backwards** (`<= 6*ONE_PLY`
permits pruning where the table says risk is highest). Moot while LMP is
disabled.
### 8. Shelved deliberately
- **`PRUNE_LIVE`/LMP**: ~7 Elo, hit its ceiling exactly, needs the table most.
Code stays behind its flag; do not ship.
- **`DISABLE_LMR`**: diagnostic only. 10.12a says removing LMR costs ~40-50
Elo, so this is NOT a shipping candidate.
- **Clamp removal / reducing into qsearch**: twice falsified (10.9a arithmetic;
Stockfish floors reduced depth at 1 ply while getting its leanness from
separate explicit pruning).
- **LMR magnitude grading via the joint table**: 10.8 (table vs flat is a wash)
plus 10.11a (Crafty has ONE `reduce--` and is leaner anyway).
- **`eNodeType` as an LMR signal**: 10.12d.
- **SEE<0 check-extension removal**: measured dr = -0.004, ~3 Elo ceiling, and
independently reported to cause sacrifice-blindness.
|