summaryrefslogtreecommitdiff
path: root/src/docs/DEEPER_PRUNING.md
blob: e71cf21babcbb3ee10adcdbeb532e38bda11b352 (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
# Deeper pruning in typhoon: ideas and how to test them

Context: typhoon searches ~800x more nodes than Stockfish to reach a
nominally comparable depth on tactical middlegame positions (see session
notes / `head_reference/`). The gap traces mainly to how conservatively
typhoon's LMR/LMP/null-move gates are calibrated, not to a missing
technique -- see "Why past LMR experiments failed" below before trying
any of this blindly, because that pattern has repeated multiple times in
this codebase already.

## Empirical evidence from TREEDUMP (2026-10-22)

Built a matching diagnostic trace into both engines (`TREEDUMP` build
flag; see `docs/TREEDUMP_FORMAT.md` for the build commands for both
engines, the output format, and a Stockfish-side gotcha worth knowing
before re-running this -- piping commands into Stockfish via a closing
pipe races its input thread against the search actually starting and
silently produces a 0-node "search," observed ~90% of the time on this
box; hold stdin open past the search, e.g. `(printf '...'; sleep 2) |
./stockfish`) that logs every move-level decision
(full search / reduced / pruned-without-searching / null-move probe)
keyed by the move path from root, so the two engines' trees can be
diffed on the same FEN. Ran both on
`2q1r1k1/1ppb4/r2p1Pp1/p4n1p/2P1n3/5NPP/PP3Q1K/2BRRB2 w - -` at the
depth each engine first converges on the correct `f6f7+` tactic (`sd
11` for typhoon, `go depth 11-12` for Stockfish; single-threaded,
--cpus 1 / Threads 1 for both).

**Node counts**: typhoon 96,080 nodes vs. Stockfish 5,666 nodes --
~17x, consistent with the ~20x seen at a shallow `sd 4`/`go depth 4`
comparison and the ~800x seen at `sd 18`/`go depth 18` in the original
session transcript. The ratio grows with depth, as expected from a
per-ply branching-factor difference compounding multiplicatively.

**Per-move-decision (`act=`) breakdown, same run** (counts are move-
decision lines, not nodes):

| typhoon | count | | Stockfish | count |
|---|---|---|---|---|
| LMP (pruned, unsearched) | 212,501 | | FP-QUIET (futility, unsearched) | 4,126 |
| FULL | 81,776 | | LMR | 3,340 |
| NMP (null-move probe) | 24,394 | | FULL | 1,681 |
| LMR | 14,315 | | SEE-QUIET (unsearched) | 895 |
| EXT | 11,788 | | FP-CAP (unsearched) | 408 |
| NULLBAILOUT | 486 | | SEE-CAP (unsearched) | 329 |
| | | | PROBCUT | 164 |
| | | | NMP | 130 |
| | | | LMR-RESEARCH | 73 |
| | | | HIST-PRUNE (unsearched) | 57 |

Three concrete, previously-inferred-from-reading-code points that this
data now confirms directly:

1. **LMR volume gap, confirmed**: Stockfish reduces ~26% of its
   move-decisions (3340/12622); typhoon reduces ~3% (14315/478340).
   Matches idea #1 below -- not a hunch, a measured 9x gap in how often
   the mechanism even fires, before even asking about magnitude.
2. **Stockfish's "cheap unsearched skip" is three separate, narrow
   mechanisms; typhoon's is one wide one.** Stockfish splits shallow-
   depth forward pruning into FP-QUIET/SEE-QUIET/HIST-PRUNE (futility
   margin, SEE margin, continuation-history floor -- each independently
   tunable, each catching a different shape of bad move) totaling
   5,078 decisions. typhoon's `ShouldLatePrune`/LMP is a single
   FH-table lookup doing all of that discrimination through one gate,
   and it accounts for 212,501 decisions on its own -- 44% of
   typhoon's *entire* move-decision volume. This reframes idea #2
   (reinstate EFP) and idea #6 (continuation-history re-sweep): the
   real gap may not be "typhoon lacks these mechanisms" so much as
   "typhoon has fused them into one coarser gate," which is harder to
   calibrate precisely than three narrow ones. Worth considering
   *splitting* LMP back into independently-tunable sub-checks rather
   than only adjusting its one shared threshold.
3. **ProbCut (idea #7) is confirmed to matter at non-trivial volume**:
   164 decisions in this one search, each capable of cutting an entire
   node's remaining move list without searching it. Not a rounding
   error.
4. **The Tord Romstad null-move bailout (`searchsup.c:3199`,
   "NULLBAILOUT" above) is real and active** -- 0 fires at `sd 4`, 486
   at `sd 11`, confirming it's a deeper-search phenomenon (it requires
   a parent move that was itself LMR-reduced, which barely happens at
   shallow depth given point 1 above).

See the conversation this came from for the methodology (path-keyed
move-by-move trace, `meld`-able logs) and a worked example of chasing
an apparent score anomaly down to its real (benign) cause -- a
checking-move extension tying alpha, not a bug -- using exactly this
instrumentation.

### Adding a third engine: Crafty (2026-10-23)

Single-position comparisons can't distinguish "this is a real
engine-independent pruning norm" from "this is just how Stockfish
happens to be tuned." Crafty (`~/programming/chess/crafty-25.2`, HCE,
no NNUE) now speaks the same TREEDUMP format (see
`docs/TREEDUMP_FORMAT.md`'s new Crafty section) specifically to add a
second, independently-play-tested reference point with a *much weaker*
eval than typhoon's own NNUE. The logic: if Crafty's LMR/LMP/futility
magnitudes still land near Stockfish's despite a cruder eval backing
the search, that's a better argument for "there's a roughly right
amount of forward pruning, independent of eval strength" than agreement
between two strong-eval engines would be -- agreement there could just
as easily mean "strong eval permits more aggressive pruning," not
"this magnitude is universally right."

A 20-position batch (7 opening / 7 middlegame / 6 endgame, all >=8
pieces to stay clear of heavy EGTB-probe territory, drawn from
`tests/cross_sectional_200k.ep_`) at `sd 6` across all three engines is
the first cut at this -- see the conversation/session this came from
for the resulting per-engine LMR-rate and EBF comparison once run.

**Caveat already known going in, from reading Crafty's `search.c`
directly**: Crafty's FP ("futility") check is raw-material-based
(`MaterialSTM(wtm) + FP_margin[depth] <= alpha`), not a full static-eval
call the way typhoon's and Stockfish's futility-flavored checks are --
cheaper and cruder. And Crafty's FP/LMP forward-pruning is a `break`,
not a `continue`: once it fires on one move at a node, every remaining
move in that node's move list is abandoned too, not just that one move
(confirmed by reading `search.c` directly, matches the engine's own
comment this investigation started from). Both engines' `act=LMP`/
`act=FP-QUIET` counts are therefore not directly comparable to
Crafty's -- don't read Crafty's count as "N individually-pruned moves."

### Correction (2026-10-23, same day): typhoon's own batch runs were also contaminated

The first full batch run (numbers below were originally drawn from it,
now corrected) had a second bug symmetric to the Crafty one above, this
time in typhoon itself. `go` sets `ePlayMode = I_PLAY_WHITE/BLACK`
(`command.c:1396`, standard xboard semantics: "go" means "you now play
this color"), and the main loop (`main.c:837`) auto-starts a background
`Ponder()` call the instant the real move is delivered -- its own,
separate `SEARCHER_THREAD_CONTEXT` (`root.c:1873`). In batch mode with
no further input queued, that ponder search just kept running and
writing to the same shared `treedump.log` until EOF finally killed the
process, inflating every count by a position-dependent, unpredictable
amount (confirmed by tagging `TDMove`'s `ctx` pointer: one pointer's
LMP count matched the engine's own "Overt move skips" report exactly;
a second pointer -- the ponder context -- contributed another 1-4x on
top, varying per position with no fixed ratio). **Fix: send `easy`**
(`command.c:453`, sets `g_Options.fShouldPonder = FALSE`) as part of
the batch invocation -- confirmed byte-exact match against the engine's
own internal counters afterward. Stockfish and Crafty were checked and
are not affected (UCI's `go depth N` with no `go ponder` doesn't
auto-continue; Crafty's `ponder off` from the earlier fix already
covers it). `run_batch.sh` now includes `easy` in every typhoon
invocation. All numbers in this section are from the corrected rerun;
the original run's typhoon totals were roughly 2x inflated (859k raw
MOVE/RESULT lines dropped to 456k after the fix).

### 20-position batch results (2026-10-23, corrected)

Ran all three TREEDUMP-instrumented engines (single-threaded, `sd 6` /
`go depth 6`) across 20 positions from `tests/cross_sectional_200k.ep_`
(7 opening / 7 middlegame / 6 endgame, all >=8 pieces to stay clear of
heavy EGTB territory), parsed into a common schema, and aggregated
`act=` counts, node counts, and LMR admission/magnitude per engine per
phase. Script: `/tmp/typhoon/treedump_crafty_sf/analyze.py` (scratch,
not checked in); raw logs at `/usr/local/tmp/typhoon_treedump/` (not
checked in, 38MB).

**Build-time trap hit along the way, worth remembering for any future
Crafty TREEDUMP run**: driving Crafty with bare `force`/`go` (no prior
`xboard` or `ponder off`) does NOT behave like typhoon's/Stockfish's
batch invocations. Crafty's `force` command only toggles force-mode
when its internal `xboard` flag is already set (`option.c:727-735`);
without it, bare `force` falls through to a "usage: force <move>"
no-op, and `go` then runs under Crafty's own default-time-control
pondering behavior instead of being capped by `sd`. This produced a
14GB, 110-million-line `treedump.log` for a single opening position
before being killed. **Fix: send `ponder off` as the first command**
(confirmed interactively) -- cleaner than engaging full `xboard` mode
for this single-`go`-per-position use case, and produces an identical
tree to the correctly-xboard-moded version. `run_batch.sh`'s current
version already has this fix plus a 2M-line trip-wire abort and a
`timeout 30` on every engine invocation as defense in depth.

**EBF (effective branching factor, `mean_nodes^(1/6)`, same depth=6 for
all three -- RESULT-node counts only, qsearch excluded per the Known
Gaps section above):**

| | typhoon | crafty | stockfish |
|---|---|---|---|
| mean nodes/position | 6,754 | 2,187 | 467 |
| EBF | 4.35 | 3.60 | 2.79 |

typhoon searches ~3.1x more nodes than Crafty and ~14.5x more than
Stockfish at this depth -- same direction as the original single-
position finding, now confirmed at n=20 across all three phases, though
the magnitude is smaller than the (contaminated) first pass of this
batch suggested -- see the correction above.

**The standing hypothesis going into this batch -- that Crafty and
Stockfish, as two independently-play-tested engines, would show similar
LMR amounts, pointing at a roughly engine-independent "right" amount --
did not hold.** typhoon and Crafty cluster together, far below
Stockfish, on both admission rate and magnitude:

| | typhoon | crafty | stockfish |
|---|---|---|---|
| LMR admission rate (% of MOVE decisions) | 2.11% | 3.36% | 22.36% |
| LMR magnitude, mean plies when reduced | 1.23 | 1.32 | 2.44 |

This holds per-position, not just in aggregate -- Stockfish is
13-37% in every one of the 20 positions; typhoon and Crafty never
exceed ~5% in any of them (see the per-position table in the session
transcript this came from). Broken out by phase, the same ordering
holds in opening/middlegame/endgame alike, with LMR% roughly flat per
engine across phases (typhoon ~1.9-3.0%, Crafty ~2.7-3.9%, Stockfish
~13-26%) -- not a phase-dependent effect.

**Why Crafty's tree is still 6x smaller than typhoon's despite having
~similar LMR**, and the resulting reframe: Crafty does almost all of
its forward-pruning via outright skips, not LMR -- `FP-QUIET` (34%) +
`LMP` (22%) = 56% of its move-decisions are unsearched skips, close to
typhoon's 59% LMP-alone figure. But because Crafty's skip is a `break`
(kills the rest of the move list at that node) rather than typhoon's
`continue` (skips just the one move), each Crafty skip event compounds
into a much larger tree-size reduction per occurrence than an
equal-count typhoon LMP figure would suggest. Stockfish's combined
skip categories (FP-QUIET/FP-CAP/SEE-QUIET/SEE-CAP/HIST-PRUNE) land in
a similar 36-52%-of-move-decisions range across phases -- comparable
*skip volume* to both typhoon and Crafty -- but Stockfish *also* layers
aggressive LMR (13-37%) on top of that, which neither of the other two
engines does at meaningful volume.

**Revised framing for idea #1 (LMR magnitude) and the recommended
order**: the data does not support "typhoon's LMR should converge
toward a shared Crafty/Stockfish amount" as cleanly as hoped going in
-- Crafty's own LMR admission/magnitude is close to typhoon's, not to
Stockfish's. The better-supported, higher-priority lever coming out of
this batch is the **break-vs-continue skip mechanism itself**
(effectively a new idea, call it **#8**): no amount of threshold
tuning on typhoon's current `continue`-based LMP can reproduce the
per-skip tree-size compounding that Crafty's node-abandoning `break`
gets "for free." This is a structural difference, not a calibration
one, and is unambiguously Category B (deliberately accepting that a
late move which *might* have been fine now never gets tried at all,
not just the specific move that triggered the skip) -- needs the full
step-5 validation ladder (self-play, not just suite diff) before
shipping, precisely because it changes the *shape* of what's being
accepted as an error, not just the threshold.

**Correction (2026-10-23, same day): idea #8 explains Crafty's small
tree, but NOT Stockfish's.** Checked directly against Stockfish's
`search.cpp` move loop (~line 1241-1337): every one of its
forward-pruning skips (`FP-CAP`, `SEE-CAP`, `HIST-PRUNE`, `FP-QUIET`,
`SEE-QUIET`) is a plain `continue`, same per-move-independent semantics
as typhoon's own LMP, not Crafty's `break`. The one thing that looks
break-adjacent, `mp.skip_quiet_moves()` (Stockfish's actual LMP
equivalent -- pure movecount threshold, `moveCount >= (3 +
depth*depth)/(2-improving)`, same shape as Crafty's `LMP[]` table), is
strictly softer than Crafty's: it only stops the move picker from
*yielding further quiet moves*, while captures/checks after that point
still get tried individually through the normal continue-gated path.
So Stockfish's much smaller tree is not explained by any break-like
shortcut at all -- it comes from sheer LMR admission rate and magnitude
(22% of moves at ~2.4 plies mean, both roughly an order of magnitude
above typhoon's/Crafty's own figures above), individually `continue`-
gated the whole way through.

**Net effect on priority**: idea #8 (break semantics) and idea #1 (LMR
magnitude/volume) are now understood as two *independent*,
non-competing levers rather than one superseding the other -- Crafty
demonstrates break-based skipping works (for Crafty's eval/search
combination), Stockfish demonstrates aggressive continue-gated LMR
works (for Stockfish's), and nothing here shows they're mutually
exclusive for typhoon. Both stay in the queue; #8 is still the newer,
less-explored one and #1 is still worth revisiting on Stockfish's own
terms (not "match Crafty+Stockfish", just "typhoon's LMR volume is an
order of magnitude below a strong continue-gated reference"), so the
recommended order's #8-ahead-of-#1 placement stands, but not because
#1 was invalidated -- only because its original two-engines-agree
rationale was.

### The leftover-population question (2026-10-23)

Scott's memory going in: typhoon's `fIsLeftoverMove` gate (base
eligibility requirement in `CanWeReduceOrPruneMove`, `searchsup.c:890-
985`) exempts the hash move, good (non-losing) captures, killers, and
countermove-matches-above-threshold from reduction/pruning consideration
entirely -- unlike Crafty (`order > 1`) and Stockfish (no separate tier
gate, just the reduction formula itself), which apply their
pruning/reduction machinery far more broadly. Measured from the
corrected 20-position batch (typhoon side, excluding `NMP`/
`NULLBAILOUT` diagnostic lines which aren't moves from the move list):

| | count | % of real moves tried |
|---|---|---|
| `LMP` (guaranteed leftover, pruned) | 188,465 | 63.4% |
| `LMR` (guaranteed leftover, reduced) | 6,772 | 2.3% |
| **leftover-tier, lower bound** | **195,237** | **65.7%** |
| `FULL` (searched at nominal depth) | 89,010 | 29.9% |
| `EXT` (extended) | 13,134 | 4.4% |
| **non-leftover-tier, upper bound** | **102,144** | **34.3%** |

So at most ~34% of real moves tried are exempt from reduction/pruning
consideration at all -- this is an upper bound, not a clean number,
since the current `TDMove` tagging can't distinguish "never eligible"
(hash move, good capture, PV-node) from "eligible but exempted"
(killer/countermove match) from "eligible, admission threshold missed."
A real population summary (median/stdev per node, not just an
aggregate percentage) and the by-exemption-reason breakdown would need
a small addition to `CanWeReduceOrPruneMove`'s exemption checks to
surface *which* check excluded a given move into `TDMove`'s otherwise-
unused tagging room -- not yet done, flagged as the natural next step
if this motivates code changes.

### Ideas added 2026-10-23 (Scott + this session, following the batch)

- **#9 -- Replace the hard `fIsLeftoverMove` gate with a graduated
  eligibility tier.** Scott: "reduce the GOOD_MOVE+ moves, but less
  than the leftovers" and "reduce the leftovers more aggressively" --
  two sides of the same structural fix. Rather than binary
  (leftover vs. exempt), a move's distance from the `GOOD_MOVE`
  boundary could scale a light reduction for near-boundary "good"
  moves on top of the existing (and, per idea #1, likely
  under-aggressive) treatment for true leftovers -- this is the
  mechanism underlying both halves of the idea at once, and is more
  surgical than just moving the boundary (which would only reclassify
  moves, not let the two curves differ in shape). Directly motivated
  by the ~34% exempt-population measurement above: Crafty/Stockfish
  don't carve out an exempt tier at all, their reduction formulas just
  naturally taper to near-zero for early/good moves.
- **#10 -- Apply the existing FH-rate evidence-calibration methodology
  to the *exempted* population, not just the leftover one.** Unknown
  whether the ~34% exempt moves are uniformly safe to leave untouched,
  or whether a sub-population (killer/countermove-match specifically,
  as opposed to hash-move/good-capture) would tolerate #9's light
  reduction without a solve-rate cost. Needs the same per-node
  exemption-reason tagging #9 depends on before picking a formula.
- **#11 -- Guard against "deterministic re-pruning" / horizon
  blindness from repeated reduction across iterative-deepening
  iterations (Scott, 2026-10-23).** The concern: if a move is
  (mis-)judged bad by the gating signals (late/gap/dumb/etc, all of
  which can correlate with each other and with history-table feedback)
  and is reduced or pruned at nominal depth 4, then again at 6, 8, 10,
  ... with the *absolute residual depth it actually gets searched at*
  staying flat or shrinking rather than growing, the engine can miss a
  real deep tactic indefinitely, no matter how deep the nominal search
  goes -- existing PVS re-search-on-beating-alpha only catches "this
  looks promising," not "the residual depth was too shallow to see the
  refutation at all," which looks identical to a correctly-judged bad
  move from the outside.

  The concrete, checkable property this implies: for any move, residual
  depth (nominal depth minus total reduction) must diverge as nominal
  depth grows, and grow fast enough to eventually resolve a tactic of
  realistic sharpness -- satisfied automatically by a *sublinear*
  (e.g. logarithmic, like Stockfish's `log(depth)*log(moveIndex)`)
  reduction-magnitude formula, but NOT guaranteed by a formula with a
  hard "leave only K plies" clamp. Checked directly: Crafty's own clamp
  (`InitializeLMR`, `init.c:1162`: `Min(LMR[d][m], Max(d-1-LMR_rdepth,
  0))`, `LMR_rdepth=1`) caps reduction so residual depth can shrink to
  as little as ~1-2 plies at large nominal depth regardless of how deep
  the overall search goes -- this wasn't visible in the sd6 batch data
  (where that clamp binds at a harmless d-2=4 ceiling) but would matter
  at the real game depths (16+) this whole investigation started from.
  **Typhoon's current scheme is, by accident rather than design, safer
  on this specific axis than Crafty's or Stockfish's**: the tiered
  `JOINT_EXTRA_TIER{1,2,3}` scheme caps total reduction at a fixed small
  constant (base -1 ply, up to +3 extra = -4 total) regardless of
  nominal depth, so residual depth grows 1:1 with nominal depth minus a
  constant -- unbounded, never stalls. Idea #1 (increase LMR magnitude
  toward Stockfish's/Crafty's own figures) directly trades away this
  safety property unless done deliberately: a depth-scaling or
  move-count-scaling magnitude increase needs its own explicit residual-
  depth floor that *grows* with nominal depth (not just a fixed small
  constant floor the way Crafty's `LMR_rdepth` is), or it risks
  reintroducing exactly the failure mode being guarded against.

  Not yet designed: what the growing floor should look like, or how to
  verify the failure mode isn't already happening somewhere in
  typhoon's existing (small-magnitude) reduction scheme. A cheap
  diagnostic worth running before any magnitude change ships: across a
  deep (16+ ply) iterative-deepening search on sharp tactical
  positions, log whether the PV ever flips late (a previously-reduced
  move getting picked up only at a much deeper iteration) as a weak
  signal that early iterations were systematically missing it --
  absence of late PV flips is consistent with either "no problem" or
  "still missing it even at the deepest iteration tried," so this is
  suggestive, not conclusive, but cheap to collect alongside whatever
  else idea #1/#9's validation ends up measuring.

**Caveats on this batch, before leaning too hard on it**: (1) n=20 is
still small enough that a handful of positions could be skewing the
per-phase breakdowns -- worth widening if any of the above motivates
real code changes. (2) The EBF formula here (`mean_nodes^(1/6)`)
assumes uniform branching at a fixed depth; it's internally comparable
across the three engines (same depth, same node-counting convention)
but is a simplification, not a reconstructed per-ply fanout. (3) None
of this says anything about whether Stockfish's specific LMR formula
would help typhoon if transplanted wholesale -- only that the
"triangulate via two agreeing reference engines" premise this batch
was designed to test turned out not to apply to LMR specifically (it
might still apply to other mechanisms; this batch's data would need
re-aggregating per-mechanism to check, e.g. null-move R, which wasn't
analyzed in isolation here).

## Why past LMR experiments failed (read this first)

`lmr_testing/README.md`'s 2026-08-27 sweep record shows a flat pattern:
*every* knob tried moved EBF and solve-count in **opposite directions** --
better branching factor, fewer solves, or vice versa, never both.
Table-only vs `+ONE_PLY` base, fail-high gate at 5/10/20, grandparent-PV
guard, Obsidian's formula vs Ethereal's, adding an "improving" signal --
none was a free win. Two root causes, both still live in the current
codebase and worth internalizing before the next attempt:

1. **Small-suite solve-count deltas are dominated by noise, not signal.**
   The ECM.016 case study in that README: the "stable" baseline PV at
   depth 11 was a shallow, unconvicted pick that flipped to the
   objectively-correct move only past 24.5M nodes. A solve-count delta on
   a position like that measures "which config happened to still be
   wrong at the sample depth," not "which search is better." The fix
   already exists in this repo (the three curated suites +
   `head_reference`'s dual `sd`/`sn` logs) but it has to be used
   correctly: trust `sd` deltas for tree-shape questions, `sn` deltas for
   cost-effectiveness questions, and treat any suite smaller than ~90
   positions as capable of flipping ±2 solves from ordering noise alone
   (see CLAUDE.md's "Dynamic move ordering experiments" section on
   `ecm_ringers`).

2. **Aggregate solve/EBF numbers are a lagging, confounded indicator of
   a *local* decision.** The same CLAUDE.md section (from the later
   move-ordering work, same lesson re-derived independently) is explicit:
   "aggregate solve counts ... are too noisy to tune a single
   move-ordering knob against ... instead, instrument the *local,
   per-move outcome*." Every past LMR sweep in `lmr_testing/` tuned
   purely against the aggregate number. None of them instrumented "of
   the moves this new threshold newly admits for reduction/pruning, what
   fraction actually fail high if searched at full depth" -- which is
   the one measurement that's actually causally close to the knob being
   turned. This is fixable with the existing `FH_TABLE_COLLECTION`/
   `MOVE_ORDERING_EVIDENCE_COLLECTION` infrastructure (see Section 3
   below) and should be done *before* touching an aggregate suite number
   again.

Net: the ideas below are listed with a test plan that leads with the
local/per-move measurement, uses `sd`/`sn` suite diffs as the fast
sanity filter, and reserves self-play/`match_play.py` for final
confirmation only -- specifically to avoid repeating the
aggregate-number-chasing pattern that burned the 2026-08-27 sweep.

## The standing philosophical question

typhoon's LMR/LMP admission is gated on an empirically-measured
"is this bucket almost never a fail-high" threshold
(`LMR_STATIC_ADMIT_PCT_THOUSANDTHS` = 0.5%, `searchsup.c`). Stockfish and
Crafty don't gate this way at all -- Crafty's own comment on its forward
pruning is explicit: *"this is classic forward-pruning and can certainly
introduce errors into the search. However, cluster testing has shown
that this improves play in real games"* (`crafty-25.2/search.c`, around
the FP/LMP block). Both ship it anyway because aggregate Elo over
realistic games is positive even though individual prunes are
provably sometimes wrong.

This means the ideas below fall into two different categories, and they
need *different validation*, not the same one:

- **Category A -- tighten the existing safe-by-construction machinery**
  (bigger tables, more axes, better calibration). These stay validatable
  by the FH-rate/silent-miss harness, because the goal is still "don't
  get it wrong," just "be less overcautious about what counts as safe."
- **Category B -- deliberately accept a nonzero, known error rate**
  because the aggregate payoff is worth it (Crafty/Stockfish's actual
  model). For these, the FH-rate harness tells you *where* the errors
  are, but **only self-play / `match_play.py` can tell you whether
  shipping them is a net win** -- see the earlier conversation for why
  that's not reducible to a suite diff. Don't skip straight to self-play
  though; use the suite-diff step first to confirm the knob is doing
  what you think before spending match-play budget on it (see the
  Validation Ladder below).

## Validation ladder (use for every idea below)

1. **Wiring sanity**: `gmake DEBUG=1 TEST=1` + `debug_smoke_test.sh` --
   confirms no crashes/assert failures before anything else.
2. **Local/per-move instrumentation** (the step every past LMR attempt
   skipped): for the exact population a new threshold newly admits,
   measure "if NOT pruned/reduced, would this move have failed high at
   full depth" via `FH_TABLE_COLLECTION`/`MOVE_ORDERING_EVIDENCE_COLLECTION`-
   style collection builds. This is the only step that tells you *why* a
   suite number moved, and it's what distinguishes "this threshold is
   fine, the suite noise is unrelated" from "this threshold is genuinely
   more dangerous."
3. **`sd 10` suite diff** against `head_reference` on all three curated
   suites (`ecm_ringers`, `ecm_confident_quick`, `ecm_hard_quick`) --
   fast (minutes), tells you whether the tree actually shrank the way you
   expect. Node count is the metric here, not solve count (too noisy at
   this size, see above).
4. **`sn 5000000` suite diff**, same three suites -- tells you whether the
   shrunk tree is cost-effective (same node budget, solved more or
   fewer). This is where Category A ideas should land flat-or-better; a
   regression here (not at `sd`) is the "shrank the tree but made it
   worse" pattern that killed most of the 2026-08-27 sweep attempts.
5. **Only if 1-4 pass**: `match_play.py` gated match
   (`LOWER95 >= 0.5`) against the current baseline DNA/eval, enough games
   for the lower-confidence-bound to actually clear break-even (small-N
   matches are noisy even with identical code, per existing notes). This
   is mandatory for any Category B idea, and a nice-to-have confirmation
   for Category A ideas before baking in.

Never skip straight to step 5. Every idea below should die at step 2 or
3 if it's bad; step 5 is expensive and, on its own, doesn't tell you
*why* something failed, which is exactly the information you need to
iterate.

---

## Ideas

### 1. Increase LMR *magnitude* on already-admitted moves (not admission rate)

Current: admitted moves get `-ONE_PLY` base, with +1/+2/+3 extra plies
reserved for the top 72%/5%/1% most-confident buckets
(`JOINT_EXTRA_TIER{1,2,3}_PCT_THOUSANDTHS`, `searchsup.c:685-692`).
Stockfish's reduction is continuous and unconditional:
`2872/128 * log(moveIndex)`, routinely 3-6 plies for ordinary late quiet
moves at depth 10+ -- more than an order of magnitude bigger than
typhoon's typical cut.

This is the single highest-leverage lever given the ~800x node gap, and
also the one every past attempt already poked at (see `GetLMRReduction`
rewrite history in `lmr_testing/`). The past failures were on the
*admission* side (which moves get reduced), not magnitude alone, and
were tuned against aggregate solve-count, which is exactly what Section
"Why past experiments failed" says not to do. Worth retrying with the
corrected methodology:

- Leave admission (`LMR_STATIC_ADMIT_PCT_THOUSANDTHS`) untouched.
- Replace the flat `_FlatTableExtraPlies` tier payout with something
  continuous in depth and move-number, shaped like Stockfish's/Crafty's
  log-product formula, for the *already-admitted* population only.
- Test: step 2 (does the newly-bigger-cut population's measured FH rate
  stay near the existing ~0.4% baseline, or does it jump specifically in
  the newly-added magnitude band?), then step 3/4.

### 2. Reinstate a cheap, unconditional move-level futility skip (EFP)

`searchsup.c:1094` documents that EFP ("extended futility pruning," a
near-free `eval + margin <= alpha` → skip the move outright, no search)
was removed and folded into the same FH-table-gated LMR/LMP path.
Stockfish still runs this as an *independent*, much cheaper layer
(`search.cpp:1185`, `:1221`) ahead of LMR, on almost every non-improving
quiet move near the leaves, with no empirical-safety gate.

Reintroducing this as a separate, deliberately Category-B (unsound,
margin-based, no FH-table gate) layer -- rather than re-folding it into
the evidence-gated system -- restores a pruning mechanism typhoon
currently lacks entirely. Test: step 2 to characterize the error rate
this specific population would have had (useful data even though you're
accepting it), then step 3/4, then step 5 since this is unambiguously
Category B.

### 3. Loosen null-move pruning's R factor / depth floor

`SelectNullmoveRFactor` and the `NULLMOVE_SKIP_FH_CEILING_THOUSANDTHS`
gate (search.c:82-108) were already recalibrated once this session
(300 → 36000) after discovering the old value was tuned against the
wrong proxy quantity and was "essentially never firing." Worth checking
whether R itself (the reduction applied to the null-move verification
search) is still conservative relative to Stockfish's dynamic R
(`search.cpp` Step 10, scales more aggressively with depth and eval
margin). Given the recalibration just happened and already moved a real
number, this is a good candidate for the *same* sweep methodology
(`nullskip_sweep.sh` already exists) applied to R itself rather than
just the skip ceiling.

Test: step 3/4 directly (R doesn't have a clean "admitted population" to
instrument the way LMR/LMP do -- the null-move verification search
itself is the ground-truth check), using the same sd8/sd11/sd12
cross-sectional sweep methodology as the existing
`NULLMOVE_SKIP_FH_CEILING_THOUSANDTHS` calibration.

### 4. Late Move Pruning: compare against Crafty's move-count-only shape

typhoon's `ShouldLatePrune` (`searchsup.c:1188-1205`) gates on a static
per-remaining-depth max-FH% table (`325, 300, 250, 200, 150, 100, 80, 25,
6, 1, 0` thousandths) *and* a dynamic per-move-identity check, both
empirically calibrated. Crafty's LMP is pure move-count × depth, no
empirical gate at all: `order > LMP[depth]` where `LMP[i] = LMP_base +
pow(i + .5, LMP_scale)`, active through `LMP_depth = 16` plies (own
`data.c`). Crafty prunes **unconditionally** on move count/depth, far
deeper into the tree (16 plies vs. typhoon's effectively much shallower
reach given how fast `uLMPStaticMaxPctThousandths[]` tightens to
near-zero by remaining depth 10).

Two sub-experiments, both worth separating:
- **4a (Category A)**: extend the existing FH-table-gated LMP's reach
  (loosen the tail of `uLMPStaticMaxPctThousandths[]`) -- stays
  validatable via step 2.
- **4b (Category B)**: add an unconditional move-count/depth LMP layer
  on top (or instead of) the gated one, à la Crafty, deliberately
  accepting the error rate. Needs step 5.

`searchsup.c:1138-1149`'s own comment flags that there's currently *no
explicit floor or ceiling* on how deep LMP can fire or how early at the
root it can trigger -- that's worth hardening (an explicit depth floor,
the way Crafty's `LMP_depth` is an explicit, tunable cutoff) regardless
of which sub-experiment you pursue, so a future bad interaction with
some other mechanism doesn't silently prune at the root.

### 5. SEE-based pruning of bad captures earlier/more aggressively

Stockfish's move-loop futility (`search.cpp:1232`,
`!pos.see_ge(move, -23*lmrDepth*lmrDepth)`) prunes losing captures with
a margin that scales quadratically with the already-reduced depth --
i.e. the deeper a losing capture already got reduced, the more freely it
also gets SEE-pruned outright. typhoon's eligibility check
(`CanWeReduceOrPruneMove`, `searchsup.c`) already restricts
captures/promotions to SEE-negative ones before they're even eligible,
but doesn't appear to apply a depth-scaled SEE *margin* the way SF does
-- worth checking whether a losing capture that clears the existing
SEE-negative bar by a wide margin (badly losing, not marginally losing)
could be pruned more aggressively than one that's only slightly losing.

Test: step 2 (bucket by SEE-loss magnitude, not just negative/
non-negative, and check FH rate per bucket -- likely to show the same
kind of monotonic signal the countermove-evidence calibration did), then
3/4.

### 6. Continuation-history-based pruning (new mechanism -- corrected)

**Correction (2026-10-22): this item previously claimed typhoon already
had a `history < -4136*depth`-style skip "mentioned in the 2026-08-29
move-ordering writeup."** That's wrong -- `-4136` is Stockfish's own
constant (`search.cpp`'s continuation-history pruning `continue`,
confirmed present only in `search.cpp`, grepped and absent everywhere
in typhoon's own sources). The 2026-08-29 work added a continuation-
history *table* (`g_ContinuationHistory`) for move-*ordering*
comparisons (`SelectBestWithHistory`) -- it has never been used to
*prune* (skip a move without searching it) at all. The TREEDUMP
empirical data above confirms this independently: Stockfish's
HIST-PRUNE bucket (57 decisions) has no typhoon counterpart in the
`act=` breakdown because there is no code path to tag.

So this is actually **idea #7's sibling**: a structurally absent
mechanism, not an existing one needing a re-sweep. Given typhoon
already tracks the evidence this would need (`g_HistoryCounters` +
`g_ContinuationHistory`, same tables the countermove-evidence
calibration already validated for ordering), adding a pruning check on
top is comparatively cheap to wire up -- the hard part (evidence
infrastructure) already exists; what's missing is the `continue`-style
skip itself plus a threshold, calibrated the same way as everything
else here.

Test: step 2 first and foremost here, since there's no existing
behavior to compare against -- bucket candidate moves by their
existing combined history+continuation evidence (log-ish bands, same
shape as the countermove-evidence calibration) and measure FH rate per
bucket before picking a threshold, rather than guessing one and
sweeping blind. Then 3/4, and 5 if the chosen threshold is deliberately
loose enough to be Category B.

### 7. Multi-cut / ProbCut-style forward pruning at CUT nodes (new mechanism)

Stockfish's "Step 11 internal iterative reductions" and ProbCut
(`search.cpp`, around line 1046-1090) are forward-pruning mechanisms
typhoon doesn't appear to have an equivalent of at all: try a handful of
moves at reduced depth with a raised beta, and if enough of them fail
high, cut the whole node without searching the rest -- a different shape
of pruning than LMR/LMP (both of which operate move-by-move) because it
operates on an aggregate signal across a small sample of moves at the
node. This is a bigger lift than tuning an existing threshold (new code,
not a constant change), so it belongs later in the queue, but it's worth
flagging as a structurally different -- not just more aggressive --
pruning technique that's currently entirely absent, rather than
something typhoon has a weaker version of. It's unambiguously Category
B (ProbCut is explicitly a statistical/unsound cut) and needs step 5.

---

## Summary table

| # | Idea | Category | Fast test (step) | Needs self-play? |
|---|------|----------|-------------------|-------------------|
| 1 | LMR magnitude on admitted moves | A (if gated) / B (if unconditional) | 2, 3, 4 | only if B |
| 2 | Reinstate cheap unconditional EFP | B | 2 (diagnostic only), 3, 4 | yes |
| 3 | Null-move R factor sweep | A (self-verifying) | 3, 4 (sweep script exists) | confirm only |
| 4a | Extend gated LMP's tail | A | 2, 3, 4 | confirm only |
| 4b | Add unconditional LMP layer | B | 3, 4 | yes |
| 5 | Depth-scaled SEE pruning margin | A | 2, 3, 4 | confirm only |
| 6 | Continuation-history pruning (new mechanism, corrected 10-22) | A (if gated) / B (if loose) | 2, 3, 4 | only if loose |
| 7 | Multi-cut / ProbCut (new mechanism) | B | 3, 4 (after implementing) | yes |
| 8 | Node-abandoning ("break") LMP semantics, à la Crafty (new mechanism, added 2026-10-23) | B | 3, 4 | yes |

Recommended order (revised 2026-10-23 after the 20-position 3-engine
batch, see above): **3 → 8 → 4a → 5 → 1 → 6 → 2 → 4b → 7** -- #8 moved
up from unlisted to #2 priority because the batch data undercuts #1's
original rationale (Crafty's LMR doesn't actually match Stockfish's,
so "converge toward a shared amount" isn't well-supported) while
directly supporting #8 (Crafty's much smaller tree despite
typhoon-like LMR is best explained by its break-not-continue skip
semantics, not by LMR at all). #1 is still worth doing eventually
(Stockfish's own magnitude is still larger than typhoon's on its own
terms) but no longer has two engines backing it as "the" right amount.
#6 stays down near the bottom since it was already corrected from
"re-sweep an existing threshold" to "build a new mechanism" and nothing
in this batch changes that.

---

## Session status as of 2026-10-23 (end of day): idea #1 implemented, self-play launched

A single long session turned idea #1 (increase LMR magnitude on
already-admitted moves) from a proposal into actual code, found and
fixed two real bugs along the way (one in Crafty, one in typhoon
itself), and ended with a self-play match in flight. Summary, in
roughly chronological order, for picking this back up tomorrow:

### What shipped (uncommitted, in the working tree as of session end)

**The magnitude formula itself** (`searchsup.c`, `_FhBasedExtraPlyTicks`
+ `GetLMRReduction` rewrite): replaces the old flat Crafty-shaped
`(depth, moveNum)` lookup with `extra_plies = k(depth) *
-log10(predictedFH% / 100)`, normalized against the existing 0.400%
baseline so an exactly-average move earns ~0 extra (just the base
`-ONE_PLY`) and only below-baseline (safer-predicted) moves earn
positive extra, continuously, no threshold cliff. `k(depth) =
0.32*ln(depth) + 0.48`, fit by direct regression against Stockfish's
*own* real reduction schedule across a depth 2-13 x move 2-32 grid
(R²=0.965) -- not a guessed shape. `depth` is capped at 13 before
computing `k` AND the table lookup itself independently saturates
there too (`_ShipDepthBucket`), so both halves plateau together past
that point -- this is deliberate, not an oversight: raw collection
data showed tries per cell falling from 56,021 at depth 13 to literal
zero by depth 20 (see the leftover-population section above), so the
formula is designed to stop extrapolating exactly where the evidence
backing it runs out. That, combined with a flat total-reduction
ceiling past depth 13, is this session's concrete answer to the
"deterministic re-pruning across iterations" failure mode discussed
at length early on (idea #11, informal number -- never got a
permanent slot in the numbered list, but is now load-bearing in the
shipped formula): residual depth (nominal minus the now-flat cap)
diverges without bound as nominal search depth grows past 13, so a
heavily-reduced move is guaranteed to eventually get a real look, no
matter how deep the overall iterative-deepening search goes.

**Works in exact quarter-ply ticks** (`ONE_PLY`/`QUARTER_PLY` fixed-
point), not whole plies -- the regression was fit in continuous plies
and truncating to whole-ply resolution in the engine would have thrown
most of that away for no reason (Scott's own observation, mid-session).

**The admission-side retirement**: `LMR_STATIC_ADMIT_PCT_THOUSANDTHS`
(the old static-table veto that could refuse the base `-ONE_PLY`
outright) is gone. Admission is now "any move in the leftover tier,"
full stop -- magnitude, not admission, is where the FH% signal does its
work now, matching how neither Crafty's nor Stockfish's own formula has
an admission cliff either.

**The dynamic per-move-identity blend** (Wilson upper confidence bound,
not the raw `FailHighs/Attempts` ratio): `GetMoveFailHighWilsonUpperBoundThousandths`
(`dynamic.c`) looks up a precomputed table (`g_uWilsonUpperBoundThousandths`,
bucketed by `FastLastBit(Attempts|1)` -- branchless bit-length, no
runtime divide/sqrt -- and `MIN(FailHighs,3)`) and the result is
combined into the main formula as `MIN(static_table_FH%, wilson_bound)`
-- a single substitution into the one formula, not a second branch or
veto. This was a long design discussion (see below) that landed on:
dynamic evidence of *safety* (a real, well-evidenced zero-fail-high
streak on this exact move identity) is allowed to win over a more
cautious static reading; dynamic evidence of *danger* (a real fail-high
history) is deliberately **not** allowed to decrease the reduction
below the base `-ONE_PLY` -- Scott's reasoning: the table's own depth
axis already shows shallower remaining depth correlates with *higher*
observed FH%, so "reduce less / search deeper" in response to a danger
signal has no causal footing to stand on, and neither Crafty nor
Stockfish has anything resembling a dedicated exemption rule for this
either (Stockfish's closest analog, the history-heuristic `statScore`
term, is one continuous signal inside one formula, never a separate
branch) -- "let's not invent our own heuristics" was the explicit
governing principle for this whole design pass.

Measured effect of the Wilson blend alone (`sd 10`, HCE, on top of the
magnitude formula): node counts dropped a further 0.9-25.8% across the
three curated suites (Scott had predicted it would never fire at all,
given his assumption `MIN` always picks the static side -- wrong,
because the dynamic per-move-identity table isn't reset within a
search, and a single deep `sd 10` tree revisits the same coarse
`(from,to,piece)` move identity often enough, across many lines, to
accumulate real evidence within one position's own search). `sn 5M`
(fixed node budget, HCE): 94/191 -> 96/191, net +2 across the three
suites -- a real if modest improvement, not just inert.

### Two real bugs found and fixed along the way (both already covered above, cross-referenced here for the session-summary view)

1. Crafty's `ponder off` requirement for TREEDUMP batch runs (background
   pondering via a second `SEARCHER_THREAD_CONTEXT` after each `go`,
   contaminating the shared `treedump.log`) -- see "Correction
   (2026-10-23)" under the Crafty section above.
2. typhoon's own `easy` requirement for the same reason (`go` auto-
   enters `I_PLAY_WHITE/BLACK` mode, triggering a background
   `Ponder()` call) -- see the matching correction a few paragraphs
   later. Both fixed by adding the missing command to
   `run_batch.sh`; both were silently inflating every typhoon-side
   TREEDUMP number in the first batch run by roughly 2x before being
   caught via a `ctx` pointer mismatch in the LMP counters.

### Measured but NOT yet implemented

**`IS_CAPTURE(mvHash)`** (was the hash move at this node a capture?):
measured via a new one-off collection macro (`HASHCAP_COLLECTION`,
`GNUmakefile`/`chess.h`/`search.c`/`searchsup.c`/`script.c` -- a new
`PLY_INFO.fHashMoveIsCapture` field set once per node, two small
`[2][2]` counter tables bucketed by `(hash move is capture, node type
is CUT)`, printed by `_PrintSuiteReport`). Real, substantial effect,
roughly halving leftover-move FH% in both node-type slices (500-
position sample, `sd 8`, tries from 40K to 3.1M per cell -- not noise):

| hash move | node type | tries | FH% |
|---|---|---|---|
| quiet/none | ALL/PV | 3,156,288 | 0.602% |
| quiet/none | CUT | 1,842,368 | 5.450% |
| capture | ALL/PV | 87,457 | 0.362% |
| capture | CUT | 40,826 | 2.467% |

Not yet wired into the reduction formula -- Scott's instinct (correct,
on reflection) was to measure first rather than assume, specifically
because this signal isn't fully independent of the table's own "dumb"
(hanging-material) axis and he wanted real numbers before trusting it.

**The CUT-vs-ALL node-type side-finding**: the same table above shows
something that directly bears on the Stockfish-reduction-at-CUT-nodes
discussion earlier in this doc -- among leftover moves that survive
long enough to *be* leftover (i.e. no early cutoff happened at that
node), a **CUT**-labeled node's leftover moves fail high roughly **9x
more often** than an **ALL/PV**-labeled node's (5.450% vs 0.602%,
quiet-hash slice; 2.467% vs 0.362%, capture-hash slice). That's the
opposite direction from Stockfish's own formula, which reduces *harder*
at CUT than at ALL -- consistent with the "sneaky ALL node in CUT
clothing" argument raised earlier in this session (a CUT prediction
that's survived past the early moves without cutting isn't neutral
information, it's evidence the label was probably wrong, and the
survivor population is measurably *more* dangerous than an honestly-
labeled ALL node, not less). Explicitly deferred for now (Scott: "let's
skip it... I can't wrap my head around [Stockfish's own choice]") --
flagged here because it's a real, substantial, already-measured number
that should inform whichever way this gets revisited, not a hunch.

### The open table-design tension, for whenever the re-bake happens

Both of the above are real, low-cardinality, general-purpose signals
-- the kind that "want" to live in the static table rather than bolted
on at runtime. But the shipped table is already `12x11x11x17x11` ≈
268K cells; a full 4x expansion (2 more binary axes) quadruples that
and thins out cells that are already sparse past depth ~13 (see the
leftover-population section). Scott's own framing at session end: since
both new signals look roughly *multiplicative* on FH% rather than
interacting in some complicated way with the existing five axes (the
capture effect was a consistent ~2x cut across both node-type slices,
not wildly different magnitudes), a *correction factor* learned from
exactly this kind of 2-cell measurement, applied on top of the existing
5D lookup, is worth considering as a cheaper alternative to adding full
new dimensions -- same data, same collection run, folded in as a
multiplier instead of new axes. Not decided; revisit once the self-play
result (below) is in and there's a real "do we even keep this
direction" answer first.

### What's running right now, and what happens next

A self-play match is in flight (`eval_tune/test_vs_head.sh`, two
hand-built wrapper scripts since the script's own `NNUE_WEIGHTS=`
handling only NNUE-wraps the candidate side and this match needs NNUE
on both): `typhoon_baseline_nnue` (git HEAD `69525eb`, unmodified, i.e.
today's starting point, NNUE eval) vs. `typhoon_wilson_nnue` (HEAD +
everything in this section, NNUE eval), `--st 4`, `--elo0 0 --elo1 15`,
2000-game cap, label `nnue_pruning_check`. This answers: given the same
NNUE eval (the actual bottleneck motivating this whole investigation --
NNUE runs at ~400-500K nps vs HCE's ~1.3M+, and "buy a faster CPU" isn't
an option), does today's more-aggressive reduction win or at least
break even against the pre-session baseline?

If it works out: commit, update `head_reference` (including its NNUE
subtree, which doesn't exist yet -- "we just merged the NNUE branch
today and didn't update head reference" per this session), then a
*second* match (NNUE-with-new-pruning vs. the HCE-at-HEAD reference,
same eval-architecture comparison this project already "knows" HCE
currently wins at `--st 4`) to answer the actual motivating question:
is the node-count saving from today's work enough to close any of the
gap against HCE's raw 3x speed advantage (observed ~3 full plies deeper
at `sd 4`), or at least move the needle. Then: revisit `IS_CAPTURE
(mvHash)`, node type, and the table-vs-correction-factor question above
with a real strength signal in hand instead of suite-solve-counts alone.