# 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 " 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 (below) is a precondition to doing this well. - **#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. We are collecting data for a regression model to predict any move or whole node's fail high probabilty to enable #9 (above). - **#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. UPDATE: 2026-10-24: Increased magnitude LMR reductions on fIsLeftoverMove moves was designed (reduction magnitudes proportional to the predicted FH% of the move, Wilson bound overriding), play-tested (llr=~+1 over 2k games -- not enough better to prove a 5 elo hypothesis but consistently positive llr over the entire test and likely not weaker), and committed and pushed to head_reference today. ### 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. Note from Scott: I really do like the cleanliness of using the FH% table (or, new regression model) to unify LMP and EFP. EFP is only based on one feature signal: the gap between eval and alpha. Instead of breaking it back out, we should first look hard at uLMPStaticMaxPctThousandths[] in ShouldLatePrune. This is designed to prune liberally at low depths (specifically to replicate the behavior and pruning rate of the old EFP) and also to prune with high remaining depth but only when pretty confident that the move is indeed dumb and useless. ### 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 The self-play of deeper LMR reductions is finished, was slightly stronger, got an extra ply deeper, and was committed and pushed to head reference on 2026-10-24. Now a data collection run to train a linear regression model predicting FH% for ALL moves given several features is being run. This will produce two models (beta are separate, still) to replace the tables in data.c and give us the ammunition to reduce non fIsLeftoverMove moves.