summaryrefslogtreecommitdiff
path: root/src/docs/DEEPER_PRUNING.md
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-23 13:19:17 -0700
committerScott Gasch <[email protected]>2026-10-23 13:19:17 -0700
commit69525eb2f42ea9cca8255f6cc3b85e6b1312fd05 (patch)
treed78420b6c45d584a3f09b4dcbaf4f1edb36e8d5d /src/docs/DEEPER_PRUNING.md
parent6b883e83935f5e8110788565cfcb77cd6fbf8950 (diff)
NNUE search integration: FH% tables, NODE_TYPE refinement, TREEDUMP rewrite
- data.c/chess.h: widen the joint FH% table's late axis 16->17, dedicating the new bucket 0 to a whole-node ("will something here fail high at all") sample rather than sharing it with the first-leftover-move curve. Add an NNUE-eval counterpart table (g_uJointFHThousandths_NNUE) and an NNUE counterpart of the beta-side table, selected at runtime based on whether NNUE eval is active. - search.c/searchsup.c: add _RefineNodeTypeGuess, which uses the FH% tables (via g_NodeTypeGuessTable) to refine a node's NODE_TYPE guess instead of the old heuristic; thread uParentMoveFHEstimate through PLY_INFO so a node can see its parent's prediction for the move that led to it. - generate.c: initialize sKillers[2]/[3].uBonus unconditionally (an uninitialized-stack read caught by valgrind under LTO; harmless today but a real bug). - dumptree.c/chess.h/GNUmakefile: replace the long-dead DUMP_TREE XML tree dump (written for a GUI this project no longer has) with TREEDUMP, a flat, path-keyed move/result trace meant to diff against an identically-instrumented Stockfish build -- see docs/TREEDUMP_FORMAT.md. - docs/DEEPER_PRUNING.md: notes on closing the search-efficiency gap vs Stockfish. - crafty_egtb/stockfish_xboard/typhoon_nnue: wrapper scripts so match_play.py's Engine class can drive Crafty, Stockfish (via PolyGlot), and an NNUE-enabled typhoon the same way it drives HCE typhoon. - tests/lop: rename lop.txt -> lop.ep_; update compare_lop.cpp. - annotate_pgns: add aggregate_marginsafety.py, dedup_pgn.py, fit_lazy_probe.py; update annotate.py/run_nnue_pipeline.py for the NNUE training pipeline. - .gitignore: ignore compile_commands.json (clangd build DB) and eval.nnue (a symlink into the untracked head_reference/ comparison baseline), neither of which belongs in the repo. Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/docs/DEEPER_PRUNING.md')
-rw-r--r--src/docs/DEEPER_PRUNING.md382
1 files changed, 382 insertions, 0 deletions
diff --git a/src/docs/DEEPER_PRUNING.md b/src/docs/DEEPER_PRUNING.md
new file mode 100644
index 0000000..b2ff6bc
--- /dev/null
+++ b/src/docs/DEEPER_PRUNING.md
@@ -0,0 +1,382 @@
+# 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.
+
+## 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 |
+
+Recommended order: **3 → 1 → 4a → 5 → 6 → 2 → 4b → 7** -- cheapest/
+lowest-risk (re-sweeping an existing, already-partially-recalibrated
+knob) first, brand-new mechanisms (6, 2, 4b, 7) later since those need
+new code plus the most expensive validation (step 5 for the Category B
+ones). #6 moved down from its original #2 slot in this ordering once
+it was corrected from "re-sweep an existing threshold" to "build a new
+one" -- it's no longer the cheap win it looked like.