summaryrefslogtreecommitdiff
path: root/src/lmr_testing/SPEC.md
blob: 55e6c5c4cda6209560e299b1ad93f63072d411dc (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
# EXECUTIVE SUMMARY:

We are implementing late move reduction / late move pruning in a chess
engine based on a "simple idea": measure the propensity of a given
move at a given node in the search tree (search.c/split.c) to fail
high using (at least) four signals:

- The move's "lateness", i.e. the order it was generated by the move
  generator code (generate.c).  Because this code generates moves in
  order with the best moves first, the only moves left at the end are
  either quiet moves or losing captures, usually.  Not very
  interesting ones, usually.  To be extra certain that the late moves
  we are looking to reduce/prune are uninteresting, we also prohibit
  checking moves, escapes from check, moves on PV nodes, moves with
  extensions, killer moves, and, in general, do not prune when any of
  several signals indicate that this "uninteresting quiet move" might
  be more than meets the eye.
- The move's "dumbness", i.e. does it seem to hang material?  A move
  of our queen next to an enemy pawn might be a brilliant sacrifice
  but most of the time it's a blunder.
- The situation at the node (position) at which the move was
  generated, i.e. is the side to move "winning" or are things "going
  badly"?  Since our search is an alpha..beta search, the gap between
  a static evaluation of the current node (iEval) and alpha (iAlpha)
  indicates "how far the side to move would have to improve the score
  at the current position in order for our move to matter (reach the
  PV and/or fail high).  The bigger this gap, the less likely a move
  is to fail high because to do so requires a stronger move.
- The move's remaining depth.  This one is counter-intuitive but if
  you do the work to compute the probability of a move to fail high
  then you find that the deeper we search the move tree under that
  move, the *less* likely the move is to fail high.  This is a really
  interesting phenomenon -- it indicates that scores (and therefore
  PVs) returned by shallow searches are very noisy but once you search
  3-4-5-8-12 ply under the move, the score usually stabilizes and
  actually diminishes the deeper you look.

We call these four signals "axes" and have computed a probability table
where, given a score for each of these signals, you can look up the
computed probability that the move (and node) exhibiting these signals
will fail high.  This is the core of our approach.

# GOAL:

The goal is to reduce the size of the chess search tree that we search
(and therefore to reduce our effective branching factor) by reducing
the depth we search the subset of moves we believe will not fail high
or outright pruning them (i.e. not searching under them at all).  Each
node saved (via reduction or pruning) is re-allocated to cause the
engine to be able to search deeper on (hopefully) more interesting and
meaningful nodes/moves elsewhere in the tree.

# DANGER:

When we reduce the search under a move we introduce error in the score
returned.  It can bite us in two ways:
- The move can fail high even though we did not expect it to.  If a
  move fails high with a reduced depth search, search.c re-searches
  it at full depth to confirm the fail high so this is not fatal; it
  just wastes some time and double searches the tree under the move
  in question.
- The move can fail low even though if we would have searched it with
  full depth it would have failed high.  This is a fatal miss; we have
  lost information here.  To help defend against this, we have built a
  version of the engine that, when it selects a move to reduce, it
  then proceeds to run the search at full depth anyway and pays
  attention to how many times a reduced move would have ended up
  failing high.  This is very rare but not impossible.  It is, by far
  the most dangerous thing about reducing moves.

All in all, though, reducing the search tree under a weak,
uninteresting move is less dangerous than the alternative: pruning the
move completely.  Pruning means never searching under the move at all
-- skipping it completely.  Therefore, the threshold to reduce a move
is much less than the threshold needed to prune a move outright.
When we are making irrecoverable decisions, we must be conservative!

# Four-axis joint FH% table -- next steps, 2026-09-28

See `lmr_testing/2026-09-28_four_axis_joint_table_plan.md` for the full
plan (axis definitions, table shape/encoding, collection/bake recipe,
consumption design). This section used to duplicate that entire file
verbatim; removed 2026-09-30 (alongside the late-bucket fix below) so
future edits only need to touch one copy.

**Correction, 2026-09-30, carried over from the plan doc**: the table's
`late` axis shipped-bucket mapping had an off-by-one. Raw `late`
(`uLegalMoves` at the point it's sampled) can never be less than 2 -- the
collection/consultation gate (`fPruneReduceEligible`, `search.c`) requires
`uLegalMoves > 1`, and `_MoveNumBucket`'s own 1-based-index comment
already ruled out 0. The old bucketing (`raw - 1`, real floor assumed to
be 1) left shipped bucket 0 permanently unreachable -- a full
`depth x dumb x gap` slice (1,452 of 21,780 shipped cells) that read the
`0xFFFF` sentinel forever, not from insufficient collection but because
no sample could ever land there. Fixed by shifting `raw - 2` instead (real
floor is 2): raw 2-13 -> shipped 0-11, 14-21 -> 12, 22-31 -> 13, 32+ -> 14.
Applied in `search.c`'s `_ShipLateBucket`, `dashboard.py`'s
`ship_late_bucket`, and `joint_table_collection/bake_joint.py` (newly
written -- imports `dashboard.py`'s bucket functions rather than
redefining them). `data.c`'s `g_uJointFHThousandths` was rebaked from the
2026-09-30 collection run with the fix in place: real coverage is
18,775/21,780 (86.2%) populated, not the 17,444/21,780 (80.1%) the old
bucketing would have reported on the same data. No data was discarded --
this was a bucketing/reporting bug, not a population/gate mismatch (a
separate concern raised and ruled out the same day, see below).

# FH%-by-(depth, move, gap) table, EFP==LMP unification, and LMR as a
# fallback -- brain dump, 2026-09-26

This is a different, unrelated project from the `README.md`/
`searchsup_GetLMRReduction.c`/`generate_counter_move_block.c` files
already in this directory (that's the 2026-08-27 graded-LMR-curve +
counter-move sweep, reverted to a clean baseline and stashed for
possible later reuse). This write-up is about a same-week but
independent effort: building a real, measured `P(fail-high)` surface
and using it to drive EFP, and eventually LMR, instead of hand-picked
constants or a hand-picked reduction curve.

## The core idea

EFP is a pruning method currently in use in the engine; we are
considering amending it or replacing it.

Historically, EFP (extended futility pruning) was Ernst Heinz's
two-tier design: only active within ~2.75 ply of the qsearch horizon,
gated by a hand-picked material margin (`VALUE_KNIGHT` at <=1.75 ply,
`VALUE_ROOK` at <=2.75 ply), with a per-move `ComputeMoveScore(mv) <
uFutilityMargin` test on top. LMP (late move pruning) didn't exist in
this codebase at all; Crafty-style engines that have it usually key it
on move number/depth alone, blind to how far below alpha the position
actually looks.

The thesis this session worked from: **EFP and LMP are answering the
same underlying question** -- "is it safe to stop searching from here"
-- just entering from different angles (EFP: "this move's static score
already looks bad, gap-driven"; LMP: "we've tried enough moves,
move-count-driven"). Once you have real data on `P(fail-high | depth,
move, gap-to-alpha)`, there's no reason to maintain them as two
separate hand-tuned mechanisms with two separate blind spots. One
table, consulted from either direction, replaces both. See the
conversation this file's SessionId corresponds to for the full
back-and-forth that arrived at this; the short version is EFP was found
to be nearly move-number-blind (a move at position 3 and a move at
position 25 with the *same* gap get treated identically by the old
margin test, despite real data showing a 6-12x difference in FH% between
them at the same depth/gap), and that's what motivated building the
joint table instead of patching EFP's old formula.

## The data: what it is, exactly

`f(remaining_depth, move_number, gap_to_alpha) -> P(fail-high)`,
where:

- **remaining_depth**: `uDepth / ONE_PLY`, whole plies, NOT capped at
  the old EFP's 2-3 ply window -- deliberately extended, because the
  data shows depth 1-2 is the *riskiest* part of the curve, not the
  safest (FH% falls ~5x from depth 0-1 to depth 9-12, confirmed
  independently holding move number fixed, so it's a real depth effect
  not a move-mix artifact). EFP's old activation window covered exactly
  the highest-risk region and left the safer, deeper region entirely
  unpruned.
- **move_number**: 1-based ordinal position in the move list. Also
  measured as move-*fraction* (fraction of the move list exhausted,
  20 buckets of 5%) as a parallel table, since "move 15 of 18" and
  "move 15 of 54" are different signals and raw move count doesn't
  capture that. The fraction table exists but the raw-move-number one
  is what's actually wired into the live lookup right now.
- **gap_to_alpha**: `iAlpha - iEval`, the NODE's static eval deficit
  below alpha, in centipawns, bucketed 50cp wide (`FH_GAP_BUCKET_WIDTH`),
  from -200cp up through +1400cp+ (`FH_GAP_BUCKETS = 32`, chess.h).
  Deliberately the cheap, already-computed node-level gap (`iEval` is
  computed for every node regardless of EFP) rather than a per-move gap
  built on `ComputeMoveScore` -- the costlier per-move version was
  considered and explicitly deferred, since computing it unconditionally
  for every leftover move (to avoid the exact censoring bias described
  below) would add real per-move cost everywhere, not just within EFP's
  old narrow activation window.

Of the three, **gap dominates by far** -- a ~28,000x swing in FH% from
the worst to best gap bucket (measured on real data: 28.3% at -200..
-150cp down to 0.001% at +1300..+1350cp), versus roughly 5-12x from
depth or move number alone. But move number and depth both carry real,
independent signal on top of gap -- confirmed by slicing FH% at a FIXED
gap bucket and watching it still vary 4-12x across move number, and
still vary ~5x across depth. All three matter; none alone is sufficient.

## The population the data is measured over: "gate-1"

This is the single most important methodological point, and got fixed
late after an expensive lesson (see "the first real attempt" below).
**The table must be trained on exactly the population that will
actually reach it in real search, not on "all leftover moves."**

Note: a "leftover move" is not a capture, killer or good countermove.
They are the quiet moves and losing captures.  They are the tier of
moves produced by generator.c with a score < GOOD_MOVE.

If you train an FH-rate table on a broader population that includes
checks, killer-adjacent moves, threat-node members, and normal
captures -- categories that a real EFP/LMR gate would ALREADY exempt
via cheap, separately-validated exemptions -- the resulting table is a
systematic OVERESTIMATE of risk for the population that actually
reaches it, because those excluded categories are (by design, since
they earned their exemption from evidence) higher-FH-rate populations
diluting the measurement.

The "gate-1" eligibility checklist, applied both when COLLECTING the
data and when CONSULTING the table live, is:

- `fIsLeftoverMove` (sub-`GOOD_MOVE` -- not a winning/even capture,
  killer, killer-mate, or sufficiently-evidenced countermove)
- `!IS_CHECKING_MOVE(mv)`
- `!IS_ESCAPING_CHECK(mv)`
- `iExtend <= 0` (no extension already applied)
- `!fThreat` (this node's own null-move probe didn't raise a threat
  signal)
- `(!IS_CAPTURE_OR_PROMOTION(mv)) || (iMoveSee < 0)` -- captures/
  promotions excluded UNLESS losing (SEE < 0), which are admitted. This
  was a deliberate widening from the old EFP's blanket capture
  exemption: a losing capture is character-wise much closer to a bad
  leftover move than a good one, and `iMoveSee` is already computed for
  exactly this class of move at shallow depth (`uDepth < THREE_PLY`),
  so admitting it costs nothing extra. Caveat: `iMoveSee` defaults to 0
  (not a real SEE value, treated as "non-losing") for captures outside
  that depth range where `GetMoveSee` never actually ran -- a
  non-issue for the current depth-1-9 beachhead, but worth remembering
  if the table's depth range is ever pushed further out without also
  extending where SEE gets computed.

The first data-collection pass (before this fix) trained on the
unrestricted leftover population and showed real cells with FH% up to
20-30% at aggressive-looking gap/move combos -- looked alarming, until
the `fThisMoveEFPCandidate` audit mechanism (below) showed the ACTUAL
population reaching a table lookup in real search had real FH% around
0.1-0.15% at a "5.0% predicted" cutoff, a 30-50x gap entirely explained
by the population mismatch, not a flaw in the gap/depth/move signal
itself. Re-collecting on the gate-1-restricted population removed this
bias; the retrained table's own audit numbers then matched its
predictions almost exactly (within ~1-2%), confirmed by comparing real
outcomes against the volume-weighted average PREDICTED value for
exactly the flagged population -- NOT against the raw threshold used to
flag it, which is a different, much more permissive number and an easy
place to fool yourself (a threshold is a ceiling on what gets flagged,
not the typical value of what actually does).

## How the data is collected

Three parallel counter tables live in `chess.h`'s `COUNTERS.tree`
(mirrored into `g_Options` for suite-level reporting), all gated behind
`#ifdef FH_TABLE_COLLECTION` (its own opt-in `gmake` flag, NOT part of
the standard `PERF_COUNTERS` profile -- this instrumentation measurably
costs NPS when active on every gate-1-eligible move, and there's no
reason to pay that cost outside a dedicated collection run):

- `u64LeftoverTriesByDepthMove` / `u64LeftoverFHByDepthMove` -- raw
  move-number x depth, `[65][64]`.
- `u64LeftoverTriesByDepthMoveFrac` / `...FHByDepthMoveFrac` -- move
  FRACTION x depth, `[65][20]`. Watch out: `MOVE_COUNT(ctx, ctx->uPly)`
  can be 0 when the hash move is tried before this ply's moves are
  generated (a distinct search stage) -- skip the frac increment
  entirely in that case, don't clamp/floor it. An earlier version used
  a `MAXU(1, ...)` floor instead of skipping, which silently dumped
  every such move into the "95-100% exhausted" bucket regardless of its
  real position, corrupting it with a huge, spuriously-low-FH%
  population -- caught by a smoke test where that one bucket had
  20-100x the sample count of its neighbors. **Always smoke-test a new
  counter design on a tiny run before trusting a big collection pass.**
- `u64LeftoverTriesByDepthMoveGap` / `...FHByDepthMoveGap` -- the full
  3-way table, `[65][64][32]`. This one's cell count is ~65x the other
  two combined, and printing it in full (even just the compact
  `FHGAPRAW` reassembly line, not a human-readable duplicate) produces
  tens of thousands of lines per checkpoint on a real collection run --
  budget log volume accordingly (this box's `/tmp` is a 30GB tmpfs;
  don't let it fill during an unattended run).

Reassembly across shards: each shard's raw output is greppable for a
`FHRAW`/`FHFRACRAW`/`FHGAPRAW` line format
(`TAG depth move [gap_bucket] fh_count tries`), one line per populated
cell, printed at the end of a `script` run via `_PrintSuiteReport`
(`script.c`). **Periodic checkpointing matters for anything that runs
long enough to risk a timeout**: `ScriptCommand`'s position loop
re-prints the full (cumulative, not delta) report every
`CHECKPOINT_INTERVAL` (currently 5) positions, not just once at the
true end -- found the hard way, the first version of this only printed
once at the end and a shard that missed its `timeout` budget by 0.05
seconds lost 100% of its data. **When reassembling from checkpointed
output, take the LAST checkpoint block in a shard's log, never sum
every occurrence** -- each checkpoint is a full cumulative snapshot,
not a delta; summing multiple checkpoints double- (or 18x-) counts.

A second, unrelated gotcha in the checkpoint counter itself: it used to
key off `g_SuiteCounters.uTotal`, which is gated by `WeAreRunningASuite()`
-- true only when the current position has a recorded `solution`/`avoid`
move. Plain `setboard`/`go` position pools (like
`cross_sectional_200k.ep_`, no EPD solution annotations) never
increment `uTotal` at all, so the checkpoint silently never fired on
that file format despite the code looking correct. Fixed with a
separate, unconditionally-incrementing `uPositionsProcessed` counter.
**If a checkpoint/report mechanism silently does nothing on a specific
file format, suspect a hidden dependency on EPD solution metadata.**

Position source used so far: `tests/cross_sectional_200k.ep_` -- per
`TESTING_PLAN.md`, this pool's actual documented purpose is
`fuzz_test.sh`'s crash-fuzzing corpus, not curated for FH-rate
calibration specifically. No generation script for it exists anywhere
in the repo (checked); its contents look like ordinary, plausible
real-game positions on inspection, but that's "looks fine," not
"verified representative" -- worth keeping in mind, and see "the
tactical-position gap" below for why this specific pool's composition
turned out to matter a lot.

## The upcoming large sharded run

Plan: run this collection sharded across many single-threaded
(`--cpus 1` -- `split.c`'s parallel helpers are NOT wired into these
counters, deliberately, so multi-threaded runs would silently under-
count) processes, at real depth (the whole point of collecting deep-
remaining-depth cells is that they're thin unless the root search goes
substantially PAST the depth being measured -- a node with remaining
depth R sits at ply ~(root_depth - R) from the root, and node count
grows with ply-from-root, so `sd 6` can never produce a single
remaining-depth-10 sample; you need `sd 16+` to make that shell
non-trivially populated), for on the order of a week, against a large
position sample.

**Composition of that sample matters and was the subject of a real,
expensive lesson this session (see "the tactical-position gap" below):
mostly real-game cross-sectional positions, with a MODEST tactical
fraction mixed in -- not a puzzle-heavy pool.** Scott's explicit framing:
"we want an engine that plays chess, not solves tricky puzzles." A
pool dominated by tactical puzzles would teach the table that leftover
moves in general are riskier than they really are (puzzles are
adversarially selected to have a non-obvious best move), producing a
table that under-prunes in the ordinary quiet middlegame positions
that make up most of a real game. Something like a 80/20 or 90/10
real-game/tactical blend is a reasonable starting point to test, not a
locked-in ratio.

Practical constraints learned from the smaller collection runs already
done:
- Per-position cost at real depth varies enormously (34s to 300+s
  observed at `sd 14-16` on different positions, single-threaded) --
  don't budget wall-clock by assuming uniform per-position cost.
  Feeding each shard a generous position POOL (more than it's expected
  to finish) and bounding by wall-clock `timeout` rather than position
  count means the run naturally uses whatever time is available without
  needing to guess position count in advance.
- The `/tmp` tmpfs on this box is shared and has been observed at 99%
  full from OTHER processes mid-session (not our own files) -- don't
  assume a `du`-based capacity check at the start of a run is still
  valid hours later.
- A stray, unrelated process (an interactive `xboard -fcp ./typhoon
  -scp crafty` game, apparently left running from earlier work) has
  shown up sharing the box's cores during a collection run. Worth a
  `ps aux | grep typhoon` sanity check before assuming all available
  cores are actually free.

## What the data is FOR: EFP == LMP, LMR as a lower-risk fallback

Once gate-1 is passed, the joint table gives a real `P(fail-high)`
estimate. Two consumers, deliberately sequenced -- prune first (since
it was the smaller, more contained change), reduction later:

**EFP/LMP (unified)**: skip the move outright if predicted FH% is below
some `epsilon_prune`. Irrecoverable, so `epsilon_prune` needs to be very
conservative and, critically, should be picked from a real measured
precision/aggressiveness curve (sweep several candidate thresholds
through the SAME audit mechanism used to validate the table, plot real
FH% and candidate volume against each), not guessed. Runtime-tunable
via `set EFPCandidateThresholdTenths <tenths-of-a-percent>` specifically
so this sweep doesn't require a rebuild per value.

**LMR (future work, not yet touched this session beyond discussion)**:
the SAME table, but as a reduction-amount driver instead of a binary
skip -- `reduction_plies ~ f(-ln(predicted FH%))`, monotonically more
aggressive the safer the cell looks. The key asymmetry that justifies a
MORE permissive threshold for reduction than for pruning: a reduction
is recoverable in (at least) the direction we can detect -- the
existing "research deeper if history pruning failed" path already
re-verifies a reduced move whose shallow search unexpectedly raises
alpha. What it does NOT catch is the other direction: a reduced search
that fails low, silently accepted, with nobody ever checking whether
the FULL-depth search would have found a raise there instead. Built an
audit for exactly this (`LMR_AUDIT` build flag, its own opt-in profile
since it costs a real extra `Search()` call per audited candidate, not
just a counter increment) -- for a move that got reduced and then
failed low at the reduced depth, re-run the SAME scout window at full
(reduction-undone) depth and check if THAT raises alpha. First real
numbers (small sample): miss rate 4-10% at remaining depth 2, dropping
under 1% at depth 3-4, then creeping back up to 3-7% at depth 5-6 -- a
non-monotonic, "high-low-high" shape that wasn't the expected smooth
decay and hasn't been investigated further yet.

Both consumers, plus per-move veto signals (below), sit on top of the
same gate-1-gated table lookup -- this is the concrete sense in which
EFP and LMP "are the same thing now."

## Per-move veto signals: two tried, both hit cliffs, no smooth middle ground

The idea: once the table flags a move as a pruning candidate, consult a
cheaper-or-not-much-more-expensive per-move signal as a final veto,
since the table only knows the aggregate risk for this (depth, move,
gap) CELL, not anything about this specific move's own tactical merit.
Both signals tried this session are audit-only-first, real-pruning-
second, and both turned out to be effectively binary rather than a
smooth dial:

1. **`ComputeMoveScore` veto, Heinz-style depth-scaled margin.**
   Generalized the old two-tier `VALUE_KNIGHT`(1.75ply)/`VALUE_ROOK`
   (2.75ply) activation margin to a linear scale across the whole
   depth-1-9 range (`uEFPVetoMarginBase + (depth-1) *
   uEFPVetoMarginPerPly`, both runtime-tunable). Swept the margin from
   0 up to 300/200: below ~(200-300, 100-150) nothing gets rescued at
   all; at (300, 200) every one of 5 known-loss positions gets rescued,
   but node count on `ecm_ringers` comes back to within 0.6% of the
   ORIGINAL unpruned baseline -- i.e. that setting is functionally "never
   prune," not a real middle ground. **Retired this veto** in favor of
   (2) below; not worth resurrecting without a fundamentally different
   margin shape (this was linear-in-depth; something with real curvature
   might behave differently, untested).

2. **`GetMoveFailHighPercentage` veto** (per-move-IDENTITY history,
   `dynamic.c`'s `g_FailHighs` table, keyed by `mv.uMove & 0xFFFFF` --
   near move-exact, not just piece+destination). Exempt a flagged
   candidate from pruning if it has real, evidenced history of failing
   high: `(uFHAttempts >= MinSamples) && (uFHPct > Threshold)`.
   **Critical, easy-to-miss fact about this table's lifecycle**: every
   cell resets to `0x00010001` (1 attempt, 1 fail-high = a 100% FH-rate
   PRIOR), not zero, on `ClearDynamicMoveOrdering()` -- which fires on
   `setboard`/`new` (`PreGameReset`'s callers), i.e. **once per position
   in a suite run, but only once per GAME in real play, never between
   moves.** At the most permissive setting tested (`MinSamples=1,
   Threshold=0`), the veto protects EVERY move by default until real
   accumulated failures (only `_RecordMoveFailure` ever touches these
   cells during a normal search; it increments the attempts denominator
   without touching the fail-high numerator, diluting the percentage
   toward 0 -- reaching an integer-truncated 0% takes on the order of
   100+ observed failures of that exact move-identity within the
   current search) prove a specific move safe. This is NOT "smart
   triage using real evidence" so much as "protect nearly everything
   by default, until proven otherwise" -- confirmed by measuring the
   actual prune rate among flagged candidates: **70-80% of everything
   the table flags gets vetoed and fully searched anyway**, at every
   depth checked. That's a real, quantified NPS cost for zero node
   savings on the majority of "candidate" evaluations.
   Swept `(MinSamples, Threshold)` away from the extreme (1,0) setting
   -- same cliff pattern as (1): every tested combo away from (1,0)
   lost 4-5 of 5 known-loss rescues while only partially reducing the
   waste rate; none beat "just tighten the table threshold with no veto
   at all" (which alone recovers exactly 1 of 5, `ECM.272`, the one
   case that was ever a pure threshold-calibration problem rather than
   a population/data problem).

**Open question, not yet resolved**: does this veto's real-game behavior
degrade over the COURSE of a single game, given the table never resets
mid-game? Live self-play match data shows the candidate's average-
search-depth advantage over the unmodified baseline shrinking as the
match's game count grows (within one fixed-configuration match:
+1.09 ply at 30 games, +0.57 at 60, +0.49 at 300) -- consistent with,
though not yet proven to be caused by, staleness accumulating within
individual games as they get longer. Needs a direct test: instrument
depth-gain (or the candidate/actually-pruned ratio) as a function of
MOVE NUMBER WITHIN a game, not just averaged across whole games, using
actual game logs from a running match rather than inferring it from the
aggregate trend.

## `sd` vs `sn` vs real games -- and getting this backwards cost real time

**`sd N` (fixed depth) is structurally the WRONG test for anything that
trades tree size for depth.** At equal depth, a leaner-tree change can
only ever cost solves relative to baseline -- it never gets credited
for the depth it's supposed to buy, because the test held depth fixed
instead of letting the efficiency convert into extra plies. This
session ran an expensive round of `sd10` suite comparisons that showed
what looked like a severe regression (14 net lost solutions across two
curated suites) before catching that the test itself was the problem,
not (only) the mechanism: re-run at `sn 5000000` (fixed node budget,
the fair comparison, since it lets the efficiency actually convert into
depth the way it's supposed to), the SAME positions and SAME build
showed net +6 solved across all three curated suites with deeper
average search depth everywhere. **Any test of a pruning/reduction
change needs to specify which of these three it's using and why**:
`sd` for "does this change the tree SHAPE at fixed effort" (the right
question for judging LMR/EFP's aggressiveness in isolation), `sn` for
"does this deliver more accuracy at fixed COST" (the right question
for judging whether a leaner-tree change is worth it), real self-play
games for "does this actually win more, accounting for everything
neither synthetic test can capture" (time-control-driven node budgets
that vary naturally by position, and the compounding effect of
thousands of small decisions across a whole game rather than a handful
of curated positions).

Related, and worth its own callout: **curated ECM suites have real,
documented noise** (see `tests/ECM.md`'s own "flaky/garbage" catalog --
oscillating PVs, near-0.00 scores, single-line coin-flips). A ±1-2
solve swing on a 90-position suite, or any result on the 11-position
`ecm_ringers`, is not strong evidence on its own. Chasing a specific
named position's recovery (the 5-position known-loss set used
throughout this session's veto sweeps) can actively mislead: the
0.5%-threshold configuration that achieved a PERFECT 5/5 recovery on
that named set actually performed WORSE in full-suite aggregate (net
+2) than the 1.0%-threshold configuration it was being compared against
(net +6) -- optimizing against a small, hand-picked set pulled toward a
setting that was worse on the broader, still-imperfect-but-larger
sample. The named positions are useful as a fast smoke test for "did
this obviously break something we already know about," not as an
optimization target.

## The tactical-position gap: why this matters more than it first looked

The first live-pruning attempt (table threshold 1.0%, no veto) showed a
real, `ECM.md`-confirmed regression: legitimate tactics lost, including
`ECM.272` -- a position independently, manually re-verified from scratch
this same session (traced the exact sacrifice line, confirmed our own
engine solves it cleanly and stably from depth 7, confirmed by an
independent Crafty search converging to the same move) as a genuine,
non-flaky tactic. Root cause, confirmed by a threshold sweep specific to
the 5 known-loss positions: tightening the table threshold all the way
to 0.1% (10x tighter than the default that failed) rescued exactly 1 of
5 (`ECM.272`); the other 4 stayed wrong regardless. **That's the tell
that it's a population/training-data problem, not a threshold-
calibration problem** -- if tightening 10x doesn't move 4 of 5 cases at
all, the table's own predicted value for whatever cell is involved is
wrong, not just permissively thresholded. `cross_sectional_200k.ep_`
(real games only) apparently underrepresents the "quiet-looking but
critical" move shape tactical positions are specifically curated to
contain, enough that the table confidently mispredicts it. This is the
direct motivation for mixing a modest tactical fraction into the next,
larger collection pass (see above) -- not to make the engine solve
puzzles better, but because the training population needs to actually
contain examples of the shape it's being asked to judge.

## Status as of this write-up

- Real pruning is OFF pending resolution of the open questions above
  (in-game staleness, and the not-yet-attempted ECM-augmented
  retraining). What's live right now (via `EFPCandidateThresholdTenths`/
  `EFPFHVetoMinSamples`/`EFPFHVetoThreshold`, all runtime-settable) is
  the most conservative configuration tested (0.1% table threshold,
  `(1, 0)` veto), currently running as `candidate` in an ongoing SPRT-
  style self-play match against unmodified `head` -- last checkpoint:
  300 games, score 0.473, llr -0.50 (inconclusive, hasn't crossed either
  bound; trending toward parity after an unfavorable dip at 60 games
  that looks in hindsight like small-sample noise, consistent with the
  "ECM suites are noisy, don't over-read small samples" lesson above).
- `FH_TABLE_COLLECTION` build flag exists and works; a real, week-long,
  larger, tactically-augmented collection run is the planned next major
  step, informed by everything above.
- The `ComputeMoveScore` veto is retired (dead end, see above); the
  `GetMoveFailHighPercentage` veto's mid-game staleness question is
  open and needs direct instrumentation, not just inference from match
  trends.
- LMR (reduction) work has not actually started yet beyond the
  `LMR_AUDIT` miss-rate measurement infrastructure and this discussion
  of how the risk threshold should differ from pruning's -- next in
  line once the pruning side's open questions are resolved.

---

# Update, 2026-09-27/28: table-driven EFP goes fully live, two real
# bugs found and fixed, self-play verdict is "roughly even"

Continuation of the above, same overall project. This section covers
what changed from "status as of 2026-09-26" (real pruning OFF,
conservative audit-only config) through making it genuinely live,
finding and fixing two real bugs via self-play (not suites), and the
honest current verdict.

## Old EFP fully replaced, not unioned

Early in this stretch, discovered the old margin-based EFP block and
the new table-driven block were BOTH live simultaneously (unioned via
a shared `fThisMoveEFPPruned` flag) -- an artifact of iterating on the
veto experiment without reconciling the two blocks. Fixed: old EFP
wrapped in `if (FALSE && ...)`, permanently disabled; the table-driven
mechanism is now tested as a genuine replacement, not an addition.

Also added, matching old EFP's own behavior that the table-driven
block had been missing: explicit `NODE_PV != pi->eNodeType` exclusion,
and the losing-capture admission switched from a depth-gated local
`iMoveSee` (only computed for `uDepth < THREE_PLY`, silently
defaulting to "non-losing" outside that range) to the cached
`MVF_SEE_KNOWN`/`MVF_SEE_NONNEGATIVE` move-stack flags `generate.c`
already sets unconditionally at generation time for every capture/
promotion -- correct at every depth, not just near the horizon.

**Also discovered `ComputeMoveScore` is a no-op for quiet/leftover
moves** under `MOVE_SCORING_NORMAL` (always returns 0) -- meaning old
EFP's real selectivity, historically, came ONLY from its coarse
depth/margin gate plus the killer/FH-history exemptions, never from
any actual per-move tactical read. Worth remembering next time
something is attributed to "EFP's margin test was smart about this
move" -- it wasn't; it was smart about depth alone.

## Depth-scaled threshold, not a flat one

A flat FH% threshold applied uniformly across the whole prune-eligible
depth range is structurally wrong: old EFP only ever operated within
~1-2.75 ply of the horizon, where a wrong prune costs little (a small,
bounded subtree). This mechanism's point is to extend pruning deeper,
where a wrong prune is far more expensive (an exponentially larger
subtree). `_EFPThresholdForDepth()` (search.c) interpolates
GEOMETRICALLY (not linearly -- the two endpoints span orders of
magnitude) between `uEFPThresholdShallowHundredths` (depth 1,
permissive -- old EFP's own real gate tolerated FH% up to the tens of
percent at 1-2 ply and still won) and `uEFPThresholdDeepHundredths`
(at `uEFPMaxPruneDepth`, near the table's measurement floor).

## `MoveLooksDumb`: from boolean, to magnitude bucket, to the joint table

A new 4th signal, alongside depth/move/gap: does this quiet move
statically look like it hangs material? Went through several rounds:

1. **First attempt**: three OR'd sub-checks (a "reversal" move, a
   pawn-attack, an ignored-hanging/trapped-piece read). Isolation
   testing found only the pawn-attack idea behaves as intuition
   predicts. The other two were retired for real, understood reasons,
   not just "didn't help": the reversal check had a genuine indexing
   bug (compared against the WRONG ply's move, `uPly-2` instead of
   `uPly-3`) that masked a deeper conceptual problem once fixed --
   reversal moves measure BACKWARDS (higher FH%, not lower), because a
   reversal responds to the opponent's own intervening move and is
   often a genuine correct reaction, not a wasted tempo. The
   hanging/trapped-piece check also measured backwards: `cEnprise`/
   `cTrapped` are written by the same parent `Eval()` call that
   produces `iEval` (the gap axis's own input) -- a real confound with
   gap, not independent signal.
2. **Second attempt** (kept, still live): "does this move hand
   material away for no reason" -- unconditionally for a strictly
   cheaper attacker (pawn attacks any non-pawn; minor attacks a rook;
   minor or rook attacks a queen), and conditionally for a
   queen-vs-queen "undefended" case discussed and then REMOVED below.
   Tried and retired: an equal-value-attacker-AND-zero-defenders test
   for minors/rooks -- structurally broken, because
   `pos->bbMinorAttacks[uMover]`/`bbRookAttacks[uMover]` (computed
   PRE-move) already includes the mover's own pre-move reach to its
   destination square (that's what makes the path legal), so "zero
   defenders" was almost never true with multiple minors/rooks per
   side and no cheap way to tell "the piece that just left" from "a
   different piece of the same type still defending." The QUEEN case
   sidesteps this because there's normally exactly one queen per side
   (gated on `FastCountBits(bbPieces[uMover][QUEEN]) == 1`).
3. **Boolean -> magnitude bucket**: "dropping a bishop to a pawn" and
   "dropping a queen to a pawn" were both just `TRUE` before, throwing
   away real information (a ~28,000x FH% swing across gap buckets
   alone says magnitude matters). `MoveLooksDumbLossCp()` now returns
   an approximate centipawn loss; `MoveLooksDumbLossBucket()`
   coarsens it into `DUMB_LOSS_BUCKETS` (chess.h, =6) piece-shaped
   tiers.
4. **A real UnmakeMove-ordering bug**, caught before it shipped: the
   FH-side counter increment originally called `MoveLooksDumbLossCp`
   a SECOND time, after `UnmakeMove` had already restored the board
   and flipped side-to-move -- silently classifying a different
   position. Produced an impossible >100%-FH cell in testing. Fixed by
   computing the dumb bucket ONCE (before `UnmakeMove`) and reusing
   the stored value on both the tries-side and FH-side counters.
5. **Joint (4D) table**: built `(depth, move, gap, dumb)` jointly, not
   just as two separate marginal cuts, to test whether "dumb"
   compounds with "gap" rather than being redundant. Confirmed real
   compounding (e.g. depth 3, gap bucket ~4: dumb=0 -> 0.948% FH,
   dumb=4 (post-coarsening numbering) -> 0.008% FH, a ~120x reduction
   at 3.2M samples) -- this justified keeping dumb as a real 4th axis
   instead of folding it into gap.

### Queen-vs-queen removed outright (2026-09-27, this session's tail)

The queen-vs-queen-undefended sub-check (originally bucket 5, ~975cp
loss) was the one case that never behaved like the rest. Direct
analysis of the 45-minute joint-run data: marginal FH% by dumb bucket
was 0.057%/0.072%/0.098%/0.010% for buckets 1-4, but **0.457% for
bucket 5 -- higher than "not dumb" (0.335%) baseline**, and this
persisted (0.0147% vs. 0.0195% baseline, still elevated relative to
bucket 4's 0.0045%) even after controlling for the gap axis. Root
cause suspected but NOT confirmed: `_WhoAttacksSquareBB` (see.c) has
no pin/legality awareness, so a pinned enemy queen still "shows" as
attacking. Rather than ship a signal nobody understands, **the
queen-vs-queen check was deleted from `MoveLooksDumbLossCp` entirely**
(search.c), not just excluded downstream -- "losing a rook to a pawn
is never a good idea" is a claim we can defend; "queen attacked by
queen looks safe to prune" turned out not to be, for reasons still
unexplained. `MoveLooksDumbLossBucket()` still has 5 tiers (0=not
dumb, 1-4 = increasing loss magnitude: ~200/~400-475/~675/~875cp);
bucket 5 (975cp) is simply unreachable now. `DUMB_LOSS_BUCKETS` stays
at 6 in chess.h so the baked tables (sized `[...][6]`) don't need
reshaping -- the top slot is just always zero.

**A bug caught during this edit, worth remembering**: the first pass
at simplifying `MoveLooksDumbLossBucket()` collapsed the 675cp and
875cp tiers into one bucket, based on an incorrect assumption that
removing the queen-vs-queen check also removed the 875cp value. It
didn't -- 875cp (a queen attacked by a pawn) is still produced by the
generic, unconditional pawn-attack branch at the top of the function,
which was never touched. Caught by rereading the actual reachable
value set before trusting the "simplification."

## Real pruning: Tier 1 / Tier 2, then unified into one mechanism

Two designs were tried, in order:

**Tier 1 + Tier 2 (retired)**: Tier 1 = percentage-curve pruning,
capped at `uEFPMaxPruneDepth`. Tier 2 = `JointMoveNeverFailsHigh()`
(data.c) -- prune unconditionally at ANY remaining depth when the 4D
joint table shows a confirmed zero fail-high over a large-enough
sample (rule-of-three statistical confidence), EXCLUDING dumb buckets
0 (not dumb -- this table says nothing about ordinary moves) and 5
(queen-vs-queen, even before it was removed from the signal entirely).
Audited before trusting: found real misses at depth 3-4 even at
`MinSamples=10000` -- one exact miss traced to a genuine, deliberate
queen sacrifice (`4rrk1/6p1/p7/1p1Np3/1bq1P3/7Q/PPP5/1K1R2R1 w - - 3 0`,
`Qh6`) near the enemy king, not a bug. Added `Tier2MinDepth=5` after
confirming clean audits at depth >=5 across all three curated suites.

**Unified (current, live)**: per explicit direction ("I think we
should not have tier1 and tier2 ... prune everywhere again but use a
graduated threshold" -- then, when the deep end of the percentage
curve flatlined at its representable floor, "I don't want 0.01%. I
want 0 FHs ever seen with >5k positions, >10k positions, etc"):

```
uCandDepth = uDepth / ONE_PLY
if (uCandDepth < g_Options.uEFPMaxPruneDepth):
    prune if PredictedFHPercentHundredths(...) < _EFPThresholdForDepth(uCandDepth)
else:
    prune if ConfirmedZeroFailHigh(uCandDepth, uMoveNum, gap, dumbBucket)
```

`ConfirmedZeroFailHigh()` (data.c) requires a literal zero observed
fail-high over a sample count that DOUBLES each ply past
`uEFPMaxPruneDepth` (base `g_Options.uZeroConfBaseSamples`, default
5000 at the boundary depth, 10000 the next ply, 20000 the next, ...).
**As of 2026-09-27 this reads the same 4D joint table Tier 2 used**
(`g_uJointTries`/`g_uJointFH`), not a separate gap-only marginal --
see "the self-play regression and its real cause" below for why that
change was necessary.

## The self-play regression and its real cause

First live self-play run of the unified design (30 games): **-8=21+1,
score=0.383, llr=-0.42** trending firmly toward reject, with `ebf`
WORSE for candidate (3.590 vs head 3.522) and candidate searching
SHALLOWER (12.87 vs 12.92 avg depth) at similar nps -- exactly
backwards from what a working pruning mechanism should show. This was
"clearly a bug, not just an aggressive tradeoff" territory, matching
Scott's own read: "I think there's a bug in the pruning impl."

An advisor consultation (mid-investigation) proposed a structural
theory -- pruning removes move-ordering/TT information that's
load-bearing elsewhere in the tree, and this cost scales with how deep
the prune fires, which old EFP never paid because it stayed within
1-2.75 ply of the horizon. That's a real, general risk (see "on
reduction vs. pruning" below), but a direct code audit found something
more concrete and immediately actionable: **`ConfirmedZeroFailHigh`
was reading `g_uGapRawTries`/`g_uGapRawFH` -- the (depth, move, gap)
marginal SUMMED OVER THE DUMB AXIS.** A cell could read "zero FH in
5000+ tries" purely because ordinary, non-dumb moves dominated that
cell's volume, while a rare queen-hanging move sharing the exact same
(depth, move, gap) cell had never actually been safely tested on its
own. Fixed by switching `ConfirmedZeroFailHigh` onto the 4D joint
table (adding a `uDumbBucket` parameter, same table
`JointMoveNeverFailsHigh` already used), refusing to trust a zero read
for the queen-vs-queen bucket outright (matching Tier 2's own
caution, at the time this bucket still existed).

**Result, same match restarted after the fix (60 games total, 30 new
under the fixed binary)**: ebf flipped from "candidate worse" to
EXACTLY TIED (3.619 = 3.619), and average depth flipped from
"candidate shallower" to "candidate deeper" (13.05 vs 12.81) -- the
signature of a real fix, not noise. `g_uGapRawTries`/`FH` and the
`GAPRAW_*` macros are now fully dead code, left in place (unused) at
the time of this write-up but candidates for deletion.

**The `g_uGapRawTries`/`g_uGapRawFH` tables were later deleted
entirely** (2026-09-27, same session) once the gap-bucket rework below
made keeping them around pointless.

## Quantifying "how much statistical confidence is actually needed"

A real question raised directly: given a per-cell "confirmed zero"
read only bounds the true rate via rule-of-three (~3/N for N samples),
and this gate fires many times per real game, how much does that
matter? Worked from real counters, not guesses:

- Real `sd10` counters (post-fix): ~303 actual deep-zone (depth >=6)
  prunes per 31.3M nodes searched, i.e. ~1 per 103,000 nodes.
- Scaled to a real blitz budget (1.5M nps x 350s ~= 500M nodes/game):
  **~4,800 deep-zone prunes per game.**
- Rule-of-three bound at the current sample bar (5000 at depth 6,
  doubling deeper): expected WORST-CASE misses per game ~= 2 (not
  thousands -- an earlier back-of-envelope estimate using a wrong,
  much larger node-count assumption was corrected in-session).
- To hit a "1 unlucky game in 1000" target at that per-game
  application volume: `required_N ~= 3 * applications_per_game /
  target_misses_per_game ~= 3 * 4800 / 0.001 ~= 14.4 million` samples
  in the specific cell being trusted, not 5,000-40,000.
- **Brute-force collection to reach that is not viable**: the
  45-minute run put ~5,000 samples into a representative deep cell:
  getting to 15M is ~3000x more volume, i.e. ~94 days wall-clock even
  running all 16 shards this box supports in parallel (already at the
  ceiling -- more shards doesn't help past core count). Confirmed and
  accepted as a dead end in-session.

**2026-10-01 correction: the "1 unlucky game in 1000" target itself was
never validated, and the whole framing here was too conservative.**
Scott's direct pushback: nobody decided 1/1000 was the right number --
it was picked, not derived -- and 1/100 or even 1/20 is reasonable. Two
things compound this:

1. **Not every prune mistake costs the game.** A silent miss (we prune
   a line that would have fail-highed) only matters if that line
   actually would have changed the game's outcome -- a strict subset of
   "a miss occurred." The rule-of-three math above bounds the rate of
   MISSES, not the rate of GAMES ACTUALLY LOST to a miss, so even the
   loosened budgets below are still conservative relative to the thing
   that actually matters (Elo).
2. **The deeper problem: this section was optimizing the wrong
   quantity.** It treats "minimize miss probability until some arbitrary
   confidence bar" as the goal in itself. The actual goal is net playing
   strength: aggregate nodes saved by pruning/reducing get reinvested as
   extra search depth elsewhere, and depth finds tactics -- that's a real
   benefit that has to be weighed against the real (but apparently
   small, see "the self-play regression" and the LMR self-play match
   above) cost of occasional misses. A `g_uJointFHThousandths`-driven
   gate exists precisely so this tradeoff can be made *intelligently*
   (deeper/leaner where the data says it's safe-ish) rather than via a
   single global, hand-picked confidence bar -- but picking that bar by
   "make misses astronomically rare" short-circuits the whole exercise
   and defaults back to maximal conservatism regardless of what the
   table actually knows. The open comparison against Crafty (much
   leaner trees, routinely 7-8 plies deeper at equal node budgets) is
   the concrete signal that this project has been on the over-cautious
   side of that tradeoff.

Recomputed the same formula at looser budgets, same 4,800
applications/game assumption:

```
1/1000 games -> N ~= 14.4M   (0 deep-zone zero-FH cells clear this, as of this run)
1/100  games -> N ~= 1.44M   (0 deep-zone zero-FH cells clear this -- largest is 1.34M, close)
1/20   games -> N ~= 288K    (16 deep-zone zero-FH cells clear this TODAY, no more collection needed)
```

**Decided direction, not yet executed**: stop treating the
rule-of-three sample bar as the primary lever at all. Keep it as a
floor against outright garbage (near-zero-sample cells), but the real
experiment going forward is to try materially more aggressive
settings on both mechanisms -- the 1/20-budget prune bar (288K, usable
immediately) and reduce tiers picked to target real extra depth (aim
for whole plies, not the +0.3 ply the first p75/p25/p5 self-play run
produced) -- and read the result by playing strength and search depth
in self-play, not by how rare a miss is in isolation. The right
long-term framing: **the actual metric to tune against is net Elo
(or a cheap proxy -- depth/nps/ebf movement under a fixed node budget),
with the FH% table used to bias WHERE cuts happen, not to justify an
arbitrarily tiny global miss rate.**
- **What DOES help, cheaply**: coarsening the bucket grid concentrates
  the same collected volume into fewer, denser cells -- see next
  section. This buys real headroom without any extra collection time.

## Open gap, 2026-10-01: compounding errors under reinvested depth is unmeasured and unmeasurable by anything built so far

Every statistic gathered for this project so far -- raw FH%, the
`JOINT_EXTRA_VERIFY` silent-miss rate, the rule-of-three sample bound
above -- is a SINGLE-DECISION statistic: it characterizes the chance
that one gate, at one node, misjudges. None of it says anything about
what happens once that misjudgment is embedded in a continuation that
is then searched DEEPER, specifically because the nodes saved by
pruning/reducing elsewhere get reinvested as exactly that extra depth.

Scott's framing: we have no information about what happens if we prune
wrong ~5x/game and reduce wrong such that a reduced-depth search
fails low where a full-depth search would have failed high, AND in
doing so we bought ourselves ~2 extra plies per move on average --
because searching those 2 extra plies means the SAME gate fires again,
2 more times, deeper under the same kind of position, with more real
tree beneath each repetition.

**Why this could be qualitatively worse than the per-decision rates
suggest, not just "the same risk, more often":** if the 4-axis model's
misjudgments were i.i.d. noise, compounding wouldn't be very dangerous
-- a short chain of independently low-probability errors is still
low-probability, same as any noise process. But this project has
already found the model has CORRELATED, structured blind spots, not
just noise: the queen-vs-queen dumb-bucket (removed outright, see
above -- "safe" reads that weren't, for reasons still not fully
understood) and the null-move-bailout signature bug (silent-miss
investigation, see above) were both systematic, not random. Reinvesting
saved nodes as depth specifically UNDERNEATH the branches just judged
"safe" means that if a blind spot like those exists, extra depth
doesn't dilute it with more search -- it drills further into exactly
the part of the tree most likely to share it.

**This cannot be measured by instrumentation built so far, even in
principle.** `JOINT_EXTRA_VERIFY`'s silent-miss rate and the rule-of-
three bound both describe P(one gate misfires once); there is no way
to decompose an aggregate self-play score back into "cost from
compounding errors under reinvested depth" vs. "benefit from genuinely
sound extra depth" -- they are inseparably confounded in that single
number by construction. Per-node instrumentation was always going to
be structurally blind to this, no matter how much more data is
collected at the per-cell level.

**Practical implication for the next (more aggressive) self-play run**:
don't just read the final SPRT score. Pull the actual lost/drawn games
under the aggressive settings and check whether losses cluster around
a recognizable, repeated position type (a live blind spot, same shape
as queen-vs-queen) versus being spread uniformly (ordinary noise at a
higher-variance operating point). `SILENT_MISS_TRACE`'s existing
FEN/move/score logging is most of the needed infrastructure already --
it would need to stay attached through to which game a flagged miss
occurred in (not just logged as an isolated per-node event) to make
this check possible. Not yet built; flagging as the concrete next
instrumentation gap before trusting a bolder self-play result as
"just noise" either way.

## Gap-bucket redesign: 32 uniform bins -> 6 piece-value-aligned bins

Per direct instruction ("Why do we have 32 gap buckets?! I think they
should be like: alpha-100, alpha-300, alpha-500, alpha-900,
alpha-1500, worse than that"), `FH_GAP_BUCKETS` (chess.h) went from
32 uniform 50cp-wide bins (`FH_GAP_BUCKET_MIN=-200`,
`FH_GAP_BUCKET_WIDTH=50`) down to 6:

```
bucket 0 : gap <  100
bucket 1 : 100 <= gap <  300
bucket 2 : 300 <= gap <  500
bucket 3 : 500 <= gap <  900
bucket 4 : 900 <= gap < 1500
bucket 5 : gap >= 1500
```

Rationale: the fine 50cp resolution was mostly wasted precision in the
near-alpha region (highest FH%, 9%+ -- never going to pass any sane
pruning threshold regardless of exactly which of ~6 fine bins it lands
in) and starved precision in the tail, which is exactly where a
pruning DECISION actually lives. A 32->6 collapse is a ~5.3x reduction
in total cell count, i.e. a ~5.3x increase in effective samples per
cell for the SAME collection run, for free.

**`GapCpToBucket(iGapCp)` (data.c) is now the single source of truth**
for this mapping -- previously the -200/50 arithmetic was duplicated
inline at SIX separate call sites (two lookup functions, one
collection increment... actually three lookup functions and three
collection sites) with no shared helper, exactly the kind of
duplication that drifts silently. Every lookup
(`PredictedFHPercentHundredths`, `JointMoveNeverFailsHigh`,
`ConfirmedZeroFailHigh`) and every collection increment (search.c, two
sites) now calls this one function.

**Both baked tables were rebuilt from scratch** using this new
bucketing, sourced from the SAME 45-minute joint-run pickle
(`reassembled_joint.pkl`'s `gap` marginal for the percentage table,
`joint` dict for the 4D table) -- this also fixes a real, previously
flagged inconsistency: the percentage table used to be baked from an
EARLIER, smaller, differently-composed collection run
(`reassembled_gate1.pkl`) than the joint table, meaning the two halves
of the "one seamless mechanism" disagreed about what population they
described. Now both come from one dataset.

**Verified after rebake** (`sd10`, all three curated suites): solve
counts essentially unchanged (one single-position flip on
`ecm_confident_quick`, within normal suite noise), and candidate
volume at depth 6-9 on that suite jumped from ~132/85/50/1 tries
(before coarsening) to **4,177/4,603/3,150/170** (after) -- a ~30-60x
increase in samples per surviving cell, exactly the intended effect,
with real observed FH still 0.000% at every one of those depths.

## Self-play verdict as of this write-up (2026-09-28)

A fresh match (post queen-vs-queen removal + gap-bucket rebake)
against `head_reference`, tracked over time:

| games | score | llr | ebf (cand vs head) | avg depth (cand vs head) |
|---|---|---|---|---|
| 100 | 0.520 | +0.10 | 3.641 < 3.654 (better) | 13.21 > 12.93 (better) |
| 170 | 0.521 | +0.16 | 3.679 < 3.685 (better) | 12.94 > 12.69 (better) |
| 1000 | **0.479** | **-1.34** | 3.617 > 3.600 (worse) | 13.19 > 13.05 (still better) |

**Honest read: the early (100-170 game) positive trend did not hold
at 1000 games.** llr swung from +0.16 to -1.34 -- more than a third of
the way to the H0=-2.94 reject boundary. Average search depth still
favors candidate at every checkpoint (the mechanism genuinely is
reaching deeper), but that extra depth is not converting into a score
edge, and ebf has flipped to favor head at the larger sample. This is
roughly consistent with nearly every measurement across this whole
project once the concrete bugs (EFP/table union, dumb-axis pooling,
queen-vs-queen) were fixed: **"even," not "clearly ahead."**

This matches the advisor's structural argument from the mid-session
consultation: pruning at depth ranges beyond old EFP's narrow 1-2.75
ply removes move-ordering/TT information that's load-bearing
elsewhere in the tree, and that cost doesn't fully disappear just
because the individual prune decisions are well-calibrated. Fixing
concrete bugs recovers lost ground (30-game catastrophe -> 1000-game
parity) but does not, on its own, produce a clear win.

**Decision point reached, not yet resolved**: either (a) let the
current match run to a real statistical conclusion (llr crossing
either bound) before drawing further conclusions from noisy
checkpoints, or (b) accept "even" as pruning's ceiling at this depth
range and pivot to reduction (see next section) instead of continuing
to chase a clear win from harder pruning alone.

## On reduction vs. pruning, as the likely next step

Not yet implemented. The core argument for why reduction should not
need anywhere near the ~15M-samples-per-cell bar pruning would need at
a permissive threshold: **a reduction is self-correcting when wrong.**
The standard PVS/LMR re-search machinery already re-verifies a reduced
move whose shallow search unexpectedly raises alpha or fails high. A
wrong prune is permanently lost; a wrong reduction costs one extra
re-search. That changes the risk calculus completely and was always
the second half of the original plan ("beat EFP at its own game and
then use this table to layer on reduction with a less conservative
threshold").

Sketch discussed, not yet built: stack an additive `extra_reduction_
plies` term onto whatever `GetLMRReduction()` already computes for a
gate-1-eligible move, driven by `PredictedFHPercentHundredths`:

```
FH% >= 5%              : +0  (no extra reduction)
1% <= FH% < 5%          : +1 ply
0.1% <= FH% < 1%        : +2 plies
0.01% <= FH% < 0.1%     : +3 plies
confirmed zero, or
  FH% < 0.01%           : +4 plies
```

Clamped so total depth never goes below some floor, same clamping
discipline `ComputeMoveExtension`/`GetLMRReduction` already use.
Reuse the SAME gate-1-eligible population and the SAME table this
whole document is about -- no new data collection needed to start
this, only new search.c wiring at the LMR call site.

## How to regenerate the tables (reproducibility notes)

Both baked tables (`g_uFHPercentHundredths` and `g_uJointTries`/
`g_uJointFH`, all in data.c) are derived from `.pkl` files produced by
reassembling a sharded collection run's raw `FHGAPRAW`/`FHJOINTRAW`
output lines (see "how the data is collected" above for the format).
**These `.pkl` files and the reassembly/codegen scripts live under
`/tmp/typhoon/` (scratch, per this repo's `CLAUDE.md` policy) and are
NOT checked into git** -- if they're gone, the tables can only be
regenerated by re-running collection from scratch. As of this
write-up they still exist at `/tmp/typhoon/fh_joint_run/
reassembled_joint.pkl` (the current, live source for BOTH tables).

To regenerate from an existing reassembled pickle after a bucket/table
shape change (what was actually done for the 2026-09-27 gap-bucket
rework):

1. Load the pickle (`pickle.load`) -- top-level dict with `joint`
   (key: `(depth, move_idx[0..15], old_gap[0..31], dumb[0..5])` ->
   `[fh_count, tries]`, note **move_idx here is already 0-based,
   pre-capped at collection time** -- do NOT subtract 1 again) and
   `gap` (key: `(depth, move_raw[1..63], old_gap[0..31])` ->
   `[fh_count, tries]`, note **this one IS 1-based raw move number**,
   a genuinely different convention between the two dicts that cost a
   real debugging cycle to catch -- verify which convention a given
   dict uses before writing a regeneration script, don't assume they
   match).
2. Re-bucket the gap axis using whatever the CURRENT `GapCpToBucket`
   boundaries are (old bucket `g`'s underlying range is
   `[-200+50g, -200+50(g+1))`; map each old bucket to whichever new
   bucket contains that range -- boundaries were deliberately chosen
   as multiples of the old 50cp bin width so this mapping is exact
   except for the old table's final overflow bin, which has no
   defined upper edge and needs a documented judgment call).
3. Sum `[fh, tries]` for every old cell that maps to the same new
   `(depth, move, new_gap, dumb)` key.
4. Emit C initializer text matching the existing array declarations
   exactly (dimension order, `// move N` / `// depth N` comments for
   readability), then splice it into data.c replacing the old array
   body between its declaration line and matching closing `};` (brace-
   depth-tracked, not a fixed line-count assumption -- table bodies
   are tens of thousands of lines and shift size on every rebuild).
5. Rebuild (`gmake clean && gmake -j5 GENETIC=1 PERF_COUNTERS=1 MP=1
   SIXTYFOUR=1`) and re-run the `sd10` three-suite sanity check before
   trusting the result -- solve counts should be stable (+/-1 on
   `ecm_confident_quick` is normal suite noise) and the EFP candidate
   audit block's real-FH% should still read ~0.000% at depth >=
   `uEFPMaxPruneDepth`.

To regenerate from a FRESH collection run instead of an existing
pickle: re-run the same sharded, `--cpus 1`, `FH_TABLE_COLLECTION`-
gated collection process described earlier in this document (16
shards, wall-clock-bounded via `timeout`, mixed real-game/tactical
position pool), reassemble each shard's LAST checkpoint block's
`FHGAPRAW`/`FHJOINTRAW` lines into the same pickle shape, then follow
steps 2-5 above.

---

# Update, 2026-09-28/29: phase 2 (graduated LMR), a real measurement
# bug found and fixed, EFP's per-move check confirmed dead, and the
# "deep prune" idea

Picks up where the previous section left off (reduction as the
likely next step after pruning's self-play verdict landed at "even").
This is a companion, largely independent table build -- a fresh 4-axis
`(depth, dumb, gap, late)` joint table baked from a new 60-90 minute
16-shard `sd 16` collection run (see
`lmr_testing/2026-09-28_four_axis_joint_table_plan.md` for the full
collection/bake recipe), `g_uJointFHThousandths[12][11][11][15]` in
`data.c`, thousandths-of-a-percent resolution, `0xFFFF` sentinel below
a 500-tries-per-cell floor. Same underlying idea as the EFP work
above, applied to reduction instead of pruning, and building its own
independent measurement infrastructure along the way.

## The mechanism: strictly additive over `GetLMRReduction`

Deliberately the smallest first experiment: HEAD's existing flat
`-ONE_PLY` reduction (`GetLMRReduction`, searchsup.c) is left
completely unchanged and still decides whether a move gets reduced at
all. The table is only ever consulted to add MORE plies on top of a
move already reduced -- never less, never on a move `GetLMRReduction`
wouldn't have touched anyway. `EFP is left untouched too (including
its known bug, see below) -- this round is measuring the LMR
augmentation in isolation, on purpose.

Tier thresholds (tries-weighted percentiles of the table's predicted-
FH% distribution, computed against the ACTUAL LMR-eligible population
-- `uLegalMoves > 5`, real `uDepth > TWO_PLY`, dumb bucket 1 excluded
as ~97% SEE-scored losing captures/underpromotions misfiled onto the
quiet-move axis, same lesson as the EFP work's dumb-bucket-1 exclusion
above):

```
FH% >= p75 (0.072%) : +0 (no extra reduction, baseline -1 ply only)
FH% <  p75           : +1 ply
FH% <  p25 (0.005%)  : +2 plies
FH% <  p5  (0.001%)  : +3 plies (max)
```

`JOINT_EXTRA_MIN_REMAINING_DEPTH` floor: currently `ONE_PLY` (the
original, most aggressive setting) after a round-trip described below
-- briefly raised to `FOUR_PLY` out of caution, then reverted once the
apparent danger was shown to be a measurement artifact, not a real
safety problem.

Main move loop (`search.c`) only, same as the EFP work -- not wired
into `split.c`'s `HelpSearch`, for the same reason (per-node snapshot
arrays with no path into a helper thread's own context; every
comparison protocol in this project already runs `--cpus 1`, so this
costs nothing in what can currently be validated).

## The null-move-bailout measurement bug (the big one this round)

Built `JOINT_EXTRA_VERIFY` (shadow-search verification: after a real
reduced search fails to raise beta, re-run at
`uNextDepth + tier*ONE_PLY` and check if THAT would have failed high --
same idea as the EFP-side "audit before trusting" discipline
throughout this whole project) and `SILENT_MISS_TRACE` (log FEN/
scores/moves on every detected miss). First numbers were alarming: up
to 1-11% silent-miss rates across various configurations -- a
"reduced search fails low, hiding a fail-high the real search would
have found" rate that would have meant this mechanism was unsafe.

**Root cause, found via live gdb stepping through an actual flagged
case, not guessed**: `TryNullmovePruning`'s (searchsup.c) intentional
Tord Romstad idea --

```c
// This is an idea from Tord Romstad on ccc: if we are below a
// history reduced node and we try a nullmove and the nullmove
// fails low and the move that refuted the nullmove involves
// the same piece moved in the reduced move at ply-1 then bail
// out of the reduced depth search now and research at full
// depth. ...
if ((mv.cFrom == (pi-1)->mv.cTo) && ((pi-1)->iExtensionAmount < 0))
{
    ...
    *piNullScore = iAlpha - 1;
    return TRUE;
}
```

deliberately FABRICATES a score of `iAlpha - 1` and returns `TRUE`
instead of doing a real re-search, specifically because that
fabricated value negates up to exactly `iBeta+1` one level up --
correctly triggering the REAL search's own existing
re-search-on-fail-high machinery. This is correct, intentional, and
safe in the real search path. It is NOT safe inside our bespoke,
one-off shadow-verification `Search()` call, which has no surrounding
wrapper to catch that "please re-search me" signal -- our own
verification code misread the fabricated fail-high as a genuine one.

**Fix**: `NEUTRALIZE_NULL_BAILOUT` (GNUmakefile flag) -- temporarily
zero `pi->iExtensionAmount` (save/restore) around just our own shadow
`Search()` call, disabling only this one precondition for our own
verification, leaving every other occurrence of the real mechanism
(anywhere else in the tree, on any other move) completely unaffected.

**Result**: true silent-miss rate, measured correctly, is ~0.00056%
(~1 in 178,000) even at the most aggressive `ONE_PLY` floor -- the
80-99.8% of "misses" the earlier numbers showed were this measurement
artifact, not real danger. This is why the floor was reverted from
`FOUR_PLY` back to `ONE_PLY`: the conservative retreat had been a
reaction to contaminated data.

**Lesson for next time a shadow/audit `Search()` call is built
anywhere in this codebase**: any node-local mechanism that
communicates results by fabricating a boundary score and relying on a
CALLER a level up to interpret it correctly (this null-move bailout,
and structurally anything similar) is a landmine for exactly this
kind of one-off internal verification call. Check for this class of
mechanism specifically before trusting a shadow-search-based audit's
raw miss rate.

## Genuine (post-fix) misses: two distinct classes, neither cheaply catchable

Investigated the ~handful of genuine misses surviving the fix by hand,
one at a time, using a fresh, independent `sd1` vs `sd8`-through-`sd12`
comparison per position (NOT the `SILENT_MISS_TRACE` hash-chain
`shadowRefutation` guess, which was separately confirmed unreliable --
`GetPonderMove`'s hash-chain walk can read stale/unrelated entries
since the hash table is global, not scoped to the specific
verification call; one hand-traced "refutation" turned out to be a
completely different line than what an independent `sd12` search
actually found). Two real, distinct patterns found:

1. **Narrow-window boundary noise, not a real miss.** One traced
   example showed `realScore`/`shadowScore` swinging wildly
   (`-500` to `+144`) at the trace's logged `alpha=143 beta=144` --
   but independently re-searching the SAME position freely (`sd1`
   through `sd12`, no artificial window) showed a stable, consistent
   assessment the whole way (-3.5 to -3.75), no swing at all. The
   trace's narrow inherited PVS window means `shadowScore >= beta`
   only proves "clears this specific tight ancestor-relative
   threshold," not "the position is actually good" -- a real but
   mundane form of alpha-beta boundary noise, not a blind spot a
   heuristic could detect or avoid.
2. **Genuine horizon-effect quiet tactics.** A second traced example
   (`3r1rk1/1pq1bppp/p7/n2R4/P1N3Q1/1P6/1B3PPP/R5K1 b - - 3 0`, after
   White's `Qd4`) was real: `sd1` likes `Qd4` (+2.06), but `sd8`
   through `sd12` all agree it loses to `Bb6` (a quiet, non-capturing
   deflection attacking the queen -- `Qxb6 Qxd7` nets Black the
   exchange). `Bb6` isn't a capture or check, so it's entirely outside
   `sd1`'s horizon; this is inherent to any bounded qsearch
   (captures/checks only), not a specific bug.

**Tried and abandoned, per direct experimentation with
`QSearchFromCheckNoStandPat`'s (search.c) check-extension gate**:
loosened the `+2 uQsearchCheckExtension` conditions three times in a
row (narrowed the inner OR-conditions, then removed the
`CountKingSafetyDefects(pos, ...) > 4` outer gate entirely), rebuilding
and re-running the SAME automated `sd1`/`sd8` batch comparison
(224 unique genuine-miss positions) after each change. **All three
variants showed no measurable difference from baseline** (median
swing 1.35, mean ~2.71-2.73, same top-10 largest-swing list/PVs, ~20.5%
sd1==sd8 move-agreement rate, every time) -- this specific lever
doesn't touch the class of miss the genuine examples actually show
(the `Bb6` example isn't a check, so a check-generation-budget change
was never going to catch it). Reverted to the original condition set.
Conclusion, by mutual agreement: this is a quirk inherent to low-depth
search, not a systematic, cheaply-fixable blind spot -- punt on
chasing it further.

## EFP's `ComputeMoveScore` per-move check confirmed dead, quantitatively

Separately (but directly relevant to "should the table eventually
replace EFP"), traced `ComputeMoveScore`/`DecodeMoveValueEstimate`
(searchsup.c) end to end and confirmed by code reading (not just
inference) that EFP's per-move sanity check --

```c
(ComputeMoveScore(ctx, mv, (x - 1)) < uFutilityMargin)
```

-- is a tautology given the gates already passed at that point in the
`if`: every quiet (non-capture/promotion) move decodes to exactly `0`
under `MOVE_SCORING_NORMAL`/`MOVE_SCORING_QSEARCH_*`, and
`IS_CAPTURE_OR_PROMOTION(mv)` is already excluded earlier in the same
condition -- so this check can never evaluate to anything but `0 <
uFutilityMargin`, which is already guaranteed true by the
`(uFutilityMargin)` gate a few lines up. It contributes nothing; EFP's
real (and only) per-move selectivity has always come from the
killer-adjacency and `GetMoveFailHighPercentage` history exemptions
below it, confirming and extending the earlier (2026-09-27) finding in
this same doc that "EFP's margin test was never smart about the
specific move."

## Quantifying what EFP is blind to, using the new table

Queried `g_uJointFHThousandths` directly over EFP's actual typical
operating envelope (1-2 plies remaining, gap 300-500cp below alpha --
the `VALUE_KNIGHT`-to-`VALUE_ROOK` margin -- leftover moves,
move-number bucket ~6-30+):

- Overall range across that envelope: ~0.000% to ~0.37%, median
  ~0.009% (p10-p90: 0.001%-0.046%).
- **The `dumb` axis -- EFP is completely blind to this -- swings FH%
  15-30x within an otherwise-fixed cell** (e.g. depth=1, gap=4,
  late=8: `dumb=0` -> 0.032%, `dumb=5` -> 0.002%, `dumb=9` -> 0.001%).
- **The `gap` axis -- EFP's only real lever -- only swings FH% ~4x**
  across its ENTIRE knight-to-rook operating range (gap=3 -> 0.068%,
  gap=5 -> 0.017%).
- The `late` axis (partially, structurally captured by EFP's
  `uLegalMoves > 1` gate, but not gradedly) also swings ~4-5x
  (late=5 -> 0.041%, late=14 -> 0.009%).

**The axis EFP cannot see (dumb) has a bigger effect than the one axis
it does look at (gap).** Direct, quantified evidence that the table
carries real information EFP structurally cannot use, independent of
the `ComputeMoveScore` bug above.

## The "deep prune" idea: cut hopeless-looking branches much higher in the tree

Scott's proposal, motivated by the above: rather than (or in addition
to) EFP's high-volume, low-value pruning right at the horizon (1-2
plies remaining, ~30-100 node subtrees, mostly qsearch), use the same
table to prune a MUCH smaller number of branches much higher up (6-8
plies remaining) where a real hopeless/late/dumb-material cell reads a
genuinely evidence-backed (500+ tries per the shipped floor) exact
`0.0000%` observed FH rate.

**Order-of-magnitude check, from real measured numbers (self-play
`ebf`~3.6, and an observed ~84% qsearch-node share in a real `sd8`
trace)**: full-width-nodes-times-qsearch-multiplier estimate puts a
1-2-ply subtree at ~30-100 nodes (matches EFP's actual footprint) vs.
~20,000 (depth 6) to ~250,000 (depth 8) nodes for a deep subtree --
several orders of magnitude more payoff per prune decision.

**Confirmed the table actually has real, evidence-backed near-zero
cells there**: at depth=6-8, gap=10 (>=901cp behind -- roughly a queen
or more), late=14 (very late/leftover), dumb=6-9 (materially hanging),
observed FH% reads exactly `0.0000%`, and because every non-sentinel
cell already cleared the 500-tries collection floor, this is a real
"watched 500+ times, never once failed high" statement, not a guess.
The most extreme combination (`dumb=10` AND `gap=10` together) doesn't
even have data yet -- too rare to have cleared the floor in the
current (60-90 minute) collection run, which is itself part of the
argument for a bigger rebuild (see next section).

**This is explicitly the highest-conviction next direction discussed
this session** -- higher potential payoff than either the LMR
augmentation above or an EFP patch, but also acknowledged as the
riskiest (an irreversible prune, firing deeper in the tree where a
wrong call costs an exponentially bigger subtree than either EFP or
this round's LMR work ever risked).

## Agreed design requirements for the deep-prune mechanism, before it's built for real

1. **One unified eligibility gate for ALL pruning/reduction
   mechanisms** (EFP, this round's LMR augmentation, any future LMP,
   and the deep prune) -- extend the existing `fPruneReduceEligible`
   checklist (already shared infrastructure from the earlier EFP work)
   to explicitly cover: no checks, no escaping check, leftover moves
   only, not a killer (previous ply or this ply), no reason to extend
   this node, no reason the LAST move by this side was extended
   (EFP's existing gate has this piece already), `!fThreat`, not a
   capture unless losing, `iAlpha < +NMATE`. Rationale, per Scott
   directly: these preconditions define "unremarkable move," which is
   also exactly the population the table itself was trained against --
   using a different gate at consult time than at collection time is
   the same population-mismatch mistake the EFP work already paid for
   once (see "the population the data is measured over" above).

2. **A rebuilt, larger, deeper table before trusting "confirmed
   zero" for anything irreversible.** Current table: 60-90 minutes,
   16 shards, `sd 16`. Planned rebuild: overnight or longer (8-12+
   hours/shard), deeper (`sd 18-20`+, to populate depth 10-13 with
   real signal instead of the coarsened overflow bucket), bigger/more
   varied position pool specifically to get real sample volume into
   the deep+hopeless+late cells that are currently unpopulated (e.g.
   `dumb=10`+`gap=10` combined). Blocked for now: this box doesn't
   have spare cores to run a 16-shard `--cpus 1` collection run
   alongside the ongoing 12-worker self-play match (each `match_play.py`
   worker spawns 2 single-threaded engine processes, so 12 workers is
   already ~24 concurrent processes) -- waiting for the self-play LLR
   to resolve (cross either SPRT bound) before starting this.

3. **"Certain" means literally `0.0%` observed, at real sample
   volume** -- not a low-but-nonzero tier, per Scott's explicit
   framing. A follow-on, not-yet-designed refinement: how much sample
   volume is actually enough given how many times per game this gate
   would fire (see the earlier EFP section's "how much statistical
   confidence is actually needed" analysis -- same rule-of-three
   reasoning applies here and needs re-deriving for whatever
   application rate the deep-prune gate turns out to have).

4. **A prune-specific shadow-verification harness, built BEFORE any
   real pruning, same discipline as `JOINT_EXTRA_VERIFY` above but
   answering a different question**: not "would a deeper search of
   this reduced move have failed high" but "would searching this move
   to real depth at all have failed high, given we were about to skip
   it entirely." Must apply the (now understood) `NEUTRALIZE_NULL_
   BAILOUT` fix from day one, and must dump FEN + full details to a
   log on every actual miss found, for direct manual investigation
   (matching how the two genuine-miss classes above were understood) --
   agreed explicitly: any wrong guess gets looked at and reasoned
   about, not just counted.

5. **A dynamic, per-move-identity component is needed and is
   currently MISSING from this round's design** -- `GetLMRReduction`'s
   own existing pattern (require the specific move's own
   `GetMoveFailHighPercentage` history to independently agree, not
   just the static table cell) was flagged mid-session as something
   the deep-prune gate must also have: require both the static table
   cell to read genuine evidence-backed zero AND this exact move's own
   accumulated in-search history to show zero fail-highs over some
   minimum tries, before ever pruning for real. Countermove-conditioned
   dynamic veto was raised as a further, explicitly UNTESTED idea for
   later, not part of the first attempt.

## Self-play status as of this write-up (2026-09-29, ongoing)

First live test of the LMR-only augmentation (EFP left untouched,
including its known `ComputeMoveScore` bug) against `head_reference`,
`match_play.py`, `--st 4`, 12 workers, SPRT (`elo0=0, elo1=5`).

**Pruning/reduction heuristic settings live for this entire match**
(`search.c` constants, unchanged for the whole run -- see "the mechanism:
strictly additive over `GetLMRReduction`" and "tier thresholds" sections
above for how these were originally derived):

```
JOINT_EXTRA_TIER1_THOUSANDTHS   = 72  // ~p75 of LMR-slice FH%: below this, +1 ply
JOINT_EXTRA_TIER2_THOUSANDTHS   = 5   // ~p25: below this, +2 plies
JOINT_EXTRA_TIER3_THOUSANDTHS   = 1   // ~p5: below this, +3 plies
JOINT_EXTRA_MAX_PLIES           = 3
JOINT_EXTRA_MIN_REMAINING_DEPTH = ONE_PLY   // most aggressive floor, post round-trip (see above)
```

i.e.: HEAD's own baseline `GetLMRReduction` (`-ONE_PLY`) is left untouched;
on top of it, a gate-1-eligible move whose 4D joint-table predicted FH%
falls under 0.072% gets +1 extra ply, under 0.005% gets +2, under 0.001%
gets +3 (capped by `JOINT_EXTRA_MAX_PLIES` and by the
`JOINT_EXTRA_MIN_REMAINING_DEPTH` floor so combined reduction never drops
remaining depth below one ply). EFP was deliberately left untouched for
this round (see below).

| games | score | llr | avg depth (cand vs head) | ebf (cand vs head) | nps (cand vs head) |
|---|---|---|---|---|---|
| 110  | 0.532 | +0.16 | 12.90 > 12.77 | 3.561 < 3.664 | 1,625,612 > 1,570,311 |
| 290  | 0.533 | +0.45 | 13.21 > 12.80 | 3.545 < 3.651 | 1,618,609 > 1,576,194 |
| 730  | 0.510 | +0.28 | 13.30 > 12.99 | 3.546 < 3.622 | 1,621,970 > 1,589,133 |
| 2212 (final, killed by hand) | 0.502 | -0.19 | 13.28 > 12.99 | 3.543 < 3.615 | 1,604,237 > 1,577,331 |

Trending positive early (110-730 games) but the match was killed by hand
at 2212 games once it settled back to dead-even: `SPRT=INCONCLUSIVE
LLR=-0.189 BOUNDS=[-2.944,2.944] ELO=+1.4+/-14.5 LOWER95=0.4812`. At this
sample size SPRT hadn't crossed only because `elo1=5` sits inside the ~12
Elo-wide 95% confidence interval the match had already converged to --
another 75 hours (the printed ETA) would have bought a label, not new
information, so continuing to let it run was judged not worth it.

**Caveat added 2026-10-01**: this entire match ran under the
pre-unification gate (`uLegalMoves > 5`, the population the p75/p25/p5
tier thresholds above were themselves derived against). EFP/LMR were
unified onto `fPruneReduceEligible` (`uLegalMoves > 1`) afterward, which
widens the live population these thresholds now classify without
re-deriving or re-testing them. So this "even, no strength gain" verdict
is evidence about the *original* p75/p25/p5 thresholds against the
*narrower* pre-unification population -- it says nothing yet about
their behavior (or the current hand-adjusted 150/5/1 thresholds, see
`search.c`'s `JOINT_EXTRA_TIER*_THOUSANDTHS`) against the current,
wider `uLegalMoves > 1` population. Needs its own self-play run before
treating this verdict as settled for the current code.

**Verdict, Scott's read, 2026-09-30**: the mechanism *works* in the narrow
sense that it delivers real, consistent extra depth/speed (+0.3 ply,
~+1.7% nps, leaner `ebf`) for no loss in measured playing strength -- but
that 0.3 ply isn't enough on its own to show up as a score edge; the
extra depth has to be large enough to let the engine see things HEAD
doesn't. Two candidate next steps, not yet decided between:
1. **Find more *sound* pruning/reduction opportunities** via the planned
   larger/deeper table rebuild (below) -- current table has real
   low-traffic cells (sparse or unpopulated deep+hopeless+late
   combinations) that a bigger collection run should fill in, unlocking
   additional reduction without cost.
2. **Tune the existing curve to be a bit more aggressive as a starting
   point.** Scott's specific framing: the current tier thresholds
   (`p75`/`p25`/`p5` -> `+0`/`+1`/`+2`/`+3` plies, see above) aren't
   hurting playing strength but also aren't buying enough depth/speed to
   realize a gain -- worth pushing slightly harder on the SAME curve
   shape before waiting on a full table rebuild.
Leaning toward trying (2) as a cheap, fast experiment first (doesn't need
the rebuild or its core budget) while (1)'s rebuild runs in parallel once
the match's ~24 `match_play.py` engine processes are confirmed dead.

**The ~3-4% nps and ~0.4-ply depth advantage are almost certainly the
SAME underlying effect measured two ways, not two independent
confirmations.** This is a fixed-wall-clock-per-move match (`--st 4`),
so depth reached is a direct function of average cost per node. If
extra reduction shifts the tree's node mix toward proportionally more
(cheap) qsearch nodes and fewer (expensive) full-width nodes -- which
the lower `ebf` for candidate at both checkpoints is directly
consistent with -- that simultaneously raises the blended nodes/second
figure AND lets iterative deepening reach one more ply in the same
time budget. Confirmed as a real, structural effect (not process
scheduling noise): stable over an hour-plus of real gameplay across
~24 concurrent single-threaded engine processes on a shared,
contended box, not a short or single-sample reading.

## Build hygiene note (2026-09-29)

Before this self-play run started, did a full audit + clean rebuild to
confirm the shipped binary carries none of the investigation-only
instrumentation: `JOINT_EXTRA_VERIFY`, `SILENT_MISS_TRACE`,
`NEUTRALIZE_NULL_BAILOUT`, `DISABLE_EFP`, `DEPTH_ONLY_EXTRA_REDUCTION`
are all `ifdef`-gated behind their own opt-in `GNUmakefile` flags, none
part of the standard `GENETIC=1 PERF_COUNTERS=1 MP=1 SIXTYFOUR=1`
profile; confirmed absent from a clean build's own
`--command ""`-printed `Make profile used:` line. `QSearchFromCheckNo
StandPat`'s three rounds of live manual experimentation (see above)
were confirmed reverted to their original condition set via `git diff`.
No stray debugger hooks (`BREAKPOINT`) remained in `search.c`. Modified
files vs. HEAD for this whole effort: `GNUmakefile`, `chess.h`,
`data.c`, `root.c`, `script.c`, `search.c`.

## Status / open questions as of this write-up

- LMR augmentation: live, in self-play, inconclusive but trending
  positive (llr +0.45 at 290 games). No further code changes planned
  until this resolves one way or the other -- can't run the planned
  table rebuild concurrently with self-play on this box's core count.
- EFP: left deliberately untouched (including the confirmed-dead
  `ComputeMoveScore` check) for this round, on purpose, to keep the
  LMR-only test interpretable. Two candidate next steps discussed and
  NOT yet decided between: (a) a conservative "veto EFP's prune when
  the table thinks this move is surprisingly dangerous" patch (small,
  reversible, directly fixes the confirmed dead-check bug), or (b)
  skip straight to the deep-prune mechanism and let IT eventually
  subsume/retire EFP rather than patching EFP incrementally. Leaning
  toward (b) as the real long-term direction, but agreed the
  incremental-experiment discipline that's served this whole project
  well should still apply -- not committing to skipping (a) outright.
- Deep-prune mechanism: designed in discussion (this section), not yet
  implemented. Blocked on: (1) the overnight/longer table rebuild
  (itself blocked on self-play finishing, core-count constrained), (2)
  building the prune-specific shadow-verification harness, (3) the
  unified-gate refactor, (4) deciding on the dynamic per-move-identity
  component's exact shape.
- Open, explicitly undecided strategic question: does the table
  eventually fully replace EFP (one mechanism, deep+shallow both
  handled by table-driven decisions), or do EFP (shallow, high-volume,
  patched) and a new deep-prune mechanism (deep, low-volume,
  table-driven) coexist as two mechanisms answering the same
  underlying question from different depths? Deferred until real
  numbers exist from both the current self-play match and a future
  deep-prune experiment.