# EXECUTIVE SUMMARY: We are implementing late move reduction / late move pruning in a chess engine based on a "simple idea": measure the propensity of a given move at a given node in the search tree (search.c/split.c) to fail high using (at least) four signals: - The move's "lateness", i.e. the order it was generated by the move generator code (generate.c). Because this code generates moves in order with the best moves first, the only moves left at the end are either quiet moves or losing captures, usually. Not very interesting ones, usually. To be extra certain that the late moves we are looking to reduce/prune are uninteresting, we also prohibit checking moves, escapes from check, moves on PV nodes, moves with extensions, killer moves, and, in general, do not prune when any of several signals indicate that this "uninteresting quiet move" might be more than meets the eye. - The move's "dumbness", i.e. does it seem to hang material? A move of our queen next to an enemy pawn might be a brilliant sacrifice but most of the time it's a blunder. - The situation at the node (position) at which the move was generated, i.e. is the side to move "winning" or are things "going badly"? Since our search is an alpha..beta search, the gap between a static evaluation of the current node (iEval) and alpha (iAlpha) indicates "how far the side to move would have to improve the score at the current position in order for our move to matter (reach the PV and/or fail high). The bigger this gap, the less likely a move is to fail high because to do so requires a stronger move. - The move's remaining depth. This one is counter-intuitive but if you do the work to compute the probability of a move to fail high then you find that the deeper we search the move tree under that move, the *less* likely the move is to fail high. This is a really interesting phenomenon -- it indicates that scores (and therefore PVs) returned by shallow searches are very noisy but once you search 3-4-5-8-12 ply under the move, the score usually stabilizes and actually diminishes the deeper you look. We call these four signals "axes" and have computed a probability table where, given a score for each of these signals, you can look up the computed probability that the move (and node) exhibiting these signals will fail high. This is the core of our approach. # GOAL: The goal is to reduce the size of the chess search tree that we search (and therefore to reduce our effective branching factor) by reducing the depth we search the subset of moves we believe will not fail high or outright pruning them (i.e. not searching under them at all). Each node saved (via reduction or pruning) is re-allocated to cause the engine to be able to search deeper on (hopefully) more interesting and meaningful nodes/moves elsewhere in the tree. # DANGER: When we reduce the search under a move we introduce error in the score returned. It can bite us in two ways: - The move can fail high even though we did not expect it to. If a move fails high with a reduced depth search, search.c re-searches it at full depth to confirm the fail high so this is not fatal; it just wastes some time and double searches the tree under the move in question. - The move can fail low even though if we would have searched it with full depth it would have failed high. This is a fatal miss; we have lost information here. To help defend against this, we have built a version of the engine that, when it selects a move to reduce, it then proceeds to run the search at full depth anyway and pays attention to how many times a reduced move would have ended up failing high. This is very rare but not impossible. It is, by far the most dangerous thing about reducing moves. All in all, though, reducing the search tree under a weak, uninteresting move is less dangerous than the alternative: pruning the move completely. Pruning means never searching under the move at all -- skipping it completely. Therefore, the threshold to reduce a move is much less than the threshold needed to prune a move outright. When we are making irrecoverable decisions, we must be conservative! # Four-axis joint FH% table -- next steps, 2026-09-28 See `lmr_testing/2026-09-28_four_axis_joint_table_plan.md` for the full plan (axis definitions, table shape/encoding, collection/bake recipe, consumption design). This section used to duplicate that entire file verbatim; removed 2026-09-30 (alongside the late-bucket fix below) so future edits only need to touch one copy. **Correction, 2026-09-30, carried over from the plan doc**: the table's `late` axis shipped-bucket mapping had an off-by-one. Raw `late` (`uLegalMoves` at the point it's sampled) can never be less than 2 -- the collection/consultation gate (`fPruneReduceEligible`, `search.c`) requires `uLegalMoves > 1`, and `_MoveNumBucket`'s own 1-based-index comment already ruled out 0. The old bucketing (`raw - 1`, real floor assumed to be 1) left shipped bucket 0 permanently unreachable -- a full `depth x dumb x gap` slice (1,452 of 21,780 shipped cells) that read the `0xFFFF` sentinel forever, not from insufficient collection but because no sample could ever land there. Fixed by shifting `raw - 2` instead (real floor is 2): raw 2-13 -> shipped 0-11, 14-21 -> 12, 22-31 -> 13, 32+ -> 14. Applied in `search.c`'s `_ShipLateBucket`, `dashboard.py`'s `ship_late_bucket`, and `joint_table_collection/bake_joint.py` (newly written -- imports `dashboard.py`'s bucket functions rather than redefining them). `data.c`'s `g_uJointFHThousandths` was rebaked from the 2026-09-30 collection run with the fix in place: real coverage is 18,775/21,780 (86.2%) populated, not the 17,444/21,780 (80.1%) the old bucketing would have reported on the same data. No data was discarded -- this was a bucketing/reporting bug, not a population/gate mismatch (a separate concern raised and ruled out the same day, see below). # FH%-by-(depth, move, gap) table, EFP==LMP unification, and LMR as a # fallback -- brain dump, 2026-09-26 This is a different, unrelated project from the `README.md`/ `searchsup_GetLMRReduction.c`/`generate_counter_move_block.c` files already in this directory (that's the 2026-08-27 graded-LMR-curve + counter-move sweep, reverted to a clean baseline and stashed for possible later reuse). This write-up is about a same-week but independent effort: building a real, measured `P(fail-high)` surface and using it to drive EFP, and eventually LMR, instead of hand-picked constants or a hand-picked reduction curve. ## The core idea EFP is a pruning method currently in use in the engine; we are considering amending it or replacing it. Historically, EFP (extended futility pruning) was Ernst Heinz's two-tier design: only active within ~2.75 ply of the qsearch horizon, gated by a hand-picked material margin (`VALUE_KNIGHT` at <=1.75 ply, `VALUE_ROOK` at <=2.75 ply), with a per-move `ComputeMoveScore(mv) < uFutilityMargin` test on top. LMP (late move pruning) didn't exist in this codebase at all; Crafty-style engines that have it usually key it on move number/depth alone, blind to how far below alpha the position actually looks. The thesis this session worked from: **EFP and LMP are answering the same underlying question** -- "is it safe to stop searching from here" -- just entering from different angles (EFP: "this move's static score already looks bad, gap-driven"; LMP: "we've tried enough moves, move-count-driven"). Once you have real data on `P(fail-high | depth, move, gap-to-alpha)`, there's no reason to maintain them as two separate hand-tuned mechanisms with two separate blind spots. One table, consulted from either direction, replaces both. See the conversation this file's SessionId corresponds to for the full back-and-forth that arrived at this; the short version is EFP was found to be nearly move-number-blind (a move at position 3 and a move at position 25 with the *same* gap get treated identically by the old margin test, despite real data showing a 6-12x difference in FH% between them at the same depth/gap), and that's what motivated building the joint table instead of patching EFP's old formula. ## The data: what it is, exactly `f(remaining_depth, move_number, gap_to_alpha) -> P(fail-high)`, where: - **remaining_depth**: `uDepth / ONE_PLY`, whole plies, NOT capped at the old EFP's 2-3 ply window -- deliberately extended, because the data shows depth 1-2 is the *riskiest* part of the curve, not the safest (FH% falls ~5x from depth 0-1 to depth 9-12, confirmed independently holding move number fixed, so it's a real depth effect not a move-mix artifact). EFP's old activation window covered exactly the highest-risk region and left the safer, deeper region entirely unpruned. - **move_number**: 1-based ordinal position in the move list. Also measured as move-*fraction* (fraction of the move list exhausted, 20 buckets of 5%) as a parallel table, since "move 15 of 18" and "move 15 of 54" are different signals and raw move count doesn't capture that. The fraction table exists but the raw-move-number one is what's actually wired into the live lookup right now. - **gap_to_alpha**: `iAlpha - iEval`, the NODE's static eval deficit below alpha, in centipawns, bucketed 50cp wide (`FH_GAP_BUCKET_WIDTH`), from -200cp up through +1400cp+ (`FH_GAP_BUCKETS = 32`, chess.h). Deliberately the cheap, already-computed node-level gap (`iEval` is computed for every node regardless of EFP) rather than a per-move gap built on `ComputeMoveScore` -- the costlier per-move version was considered and explicitly deferred, since computing it unconditionally for every leftover move (to avoid the exact censoring bias described below) would add real per-move cost everywhere, not just within EFP's old narrow activation window. Of the three, **gap dominates by far** -- a ~28,000x swing in FH% from the worst to best gap bucket (measured on real data: 28.3% at -200.. -150cp down to 0.001% at +1300..+1350cp), versus roughly 5-12x from depth or move number alone. But move number and depth both carry real, independent signal on top of gap -- confirmed by slicing FH% at a FIXED gap bucket and watching it still vary 4-12x across move number, and still vary ~5x across depth. All three matter; none alone is sufficient. ## The population the data is measured over: "gate-1" This is the single most important methodological point, and got fixed late after an expensive lesson (see "the first real attempt" below). **The table must be trained on exactly the population that will actually reach it in real search, not on "all leftover moves."** Note: a "leftover move" is not a capture, killer or good countermove. They are the quiet moves and losing captures. They are the tier of moves produced by generator.c with a score < GOOD_MOVE. If you train an FH-rate table on a broader population that includes checks, killer-adjacent moves, threat-node members, and normal captures -- categories that a real EFP/LMR gate would ALREADY exempt via cheap, separately-validated exemptions -- the resulting table is a systematic OVERESTIMATE of risk for the population that actually reaches it, because those excluded categories are (by design, since they earned their exemption from evidence) higher-FH-rate populations diluting the measurement. The "gate-1" eligibility checklist, applied both when COLLECTING the data and when CONSULTING the table live, is: - `fIsLeftoverMove` (sub-`GOOD_MOVE` -- not a winning/even capture, killer, killer-mate, or sufficiently-evidenced countermove) - `!IS_CHECKING_MOVE(mv)` - `!IS_ESCAPING_CHECK(mv)` - `iExtend <= 0` (no extension already applied) - `!fThreat` (this node's own null-move probe didn't raise a threat signal) - `(!IS_CAPTURE_OR_PROMOTION(mv)) || (iMoveSee < 0)` -- captures/ promotions excluded UNLESS losing (SEE < 0), which are admitted. This was a deliberate widening from the old EFP's blanket capture exemption: a losing capture is character-wise much closer to a bad leftover move than a good one, and `iMoveSee` is already computed for exactly this class of move at shallow depth (`uDepth < THREE_PLY`), so admitting it costs nothing extra. Caveat: `iMoveSee` defaults to 0 (not a real SEE value, treated as "non-losing") for captures outside that depth range where `GetMoveSee` never actually ran -- a non-issue for the current depth-1-9 beachhead, but worth remembering if the table's depth range is ever pushed further out without also extending where SEE gets computed. The first data-collection pass (before this fix) trained on the unrestricted leftover population and showed real cells with FH% up to 20-30% at aggressive-looking gap/move combos -- looked alarming, until the `fThisMoveEFPCandidate` audit mechanism (below) showed the ACTUAL population reaching a table lookup in real search had real FH% around 0.1-0.15% at a "5.0% predicted" cutoff, a 30-50x gap entirely explained by the population mismatch, not a flaw in the gap/depth/move signal itself. Re-collecting on the gate-1-restricted population removed this bias; the retrained table's own audit numbers then matched its predictions almost exactly (within ~1-2%), confirmed by comparing real outcomes against the volume-weighted average PREDICTED value for exactly the flagged population -- NOT against the raw threshold used to flag it, which is a different, much more permissive number and an easy place to fool yourself (a threshold is a ceiling on what gets flagged, not the typical value of what actually does). ## How the data is collected Three parallel counter tables live in `chess.h`'s `COUNTERS.tree` (mirrored into `g_Options` for suite-level reporting), all gated behind `#ifdef FH_TABLE_COLLECTION` (its own opt-in `gmake` flag, NOT part of the standard `PERF_COUNTERS` profile -- this instrumentation measurably costs NPS when active on every gate-1-eligible move, and there's no reason to pay that cost outside a dedicated collection run): - `u64LeftoverTriesByDepthMove` / `u64LeftoverFHByDepthMove` -- raw move-number x depth, `[65][64]`. - `u64LeftoverTriesByDepthMoveFrac` / `...FHByDepthMoveFrac` -- move FRACTION x depth, `[65][20]`. Watch out: `MOVE_COUNT(ctx, ctx->uPly)` can be 0 when the hash move is tried before this ply's moves are generated (a distinct search stage) -- skip the frac increment entirely in that case, don't clamp/floor it. An earlier version used a `MAXU(1, ...)` floor instead of skipping, which silently dumped every such move into the "95-100% exhausted" bucket regardless of its real position, corrupting it with a huge, spuriously-low-FH% population -- caught by a smoke test where that one bucket had 20-100x the sample count of its neighbors. **Always smoke-test a new counter design on a tiny run before trusting a big collection pass.** - `u64LeftoverTriesByDepthMoveGap` / `...FHByDepthMoveGap` -- the full 3-way table, `[65][64][32]`. This one's cell count is ~65x the other two combined, and printing it in full (even just the compact `FHGAPRAW` reassembly line, not a human-readable duplicate) produces tens of thousands of lines per checkpoint on a real collection run -- budget log volume accordingly (this box's `/tmp` is a 30GB tmpfs; don't let it fill during an unattended run). Reassembly across shards: each shard's raw output is greppable for a `FHRAW`/`FHFRACRAW`/`FHGAPRAW` line format (`TAG depth move [gap_bucket] fh_count tries`), one line per populated cell, printed at the end of a `script` run via `_PrintSuiteReport` (`script.c`). **Periodic checkpointing matters for anything that runs long enough to risk a timeout**: `ScriptCommand`'s position loop re-prints the full (cumulative, not delta) report every `CHECKPOINT_INTERVAL` (currently 5) positions, not just once at the true end -- found the hard way, the first version of this only printed once at the end and a shard that missed its `timeout` budget by 0.05 seconds lost 100% of its data. **When reassembling from checkpointed output, take the LAST checkpoint block in a shard's log, never sum every occurrence** -- each checkpoint is a full cumulative snapshot, not a delta; summing multiple checkpoints double- (or 18x-) counts. A second, unrelated gotcha in the checkpoint counter itself: it used to key off `g_SuiteCounters.uTotal`, which is gated by `WeAreRunningASuite()` -- true only when the current position has a recorded `solution`/`avoid` move. Plain `setboard`/`go` position pools (like `cross_sectional_200k.ep_`, no EPD solution annotations) never increment `uTotal` at all, so the checkpoint silently never fired on that file format despite the code looking correct. Fixed with a separate, unconditionally-incrementing `uPositionsProcessed` counter. **If a checkpoint/report mechanism silently does nothing on a specific file format, suspect a hidden dependency on EPD solution metadata.** Position source used so far: `tests/cross_sectional_200k.ep_` -- per `TESTING_PLAN.md`, this pool's actual documented purpose is `fuzz_test.sh`'s crash-fuzzing corpus, not curated for FH-rate calibration specifically. No generation script for it exists anywhere in the repo (checked); its contents look like ordinary, plausible real-game positions on inspection, but that's "looks fine," not "verified representative" -- worth keeping in mind, and see "the tactical-position gap" below for why this specific pool's composition turned out to matter a lot. ## The upcoming large sharded run Plan: run this collection sharded across many single-threaded (`--cpus 1` -- `split.c`'s parallel helpers are NOT wired into these counters, deliberately, so multi-threaded runs would silently under- count) processes, at real depth (the whole point of collecting deep- remaining-depth cells is that they're thin unless the root search goes substantially PAST the depth being measured -- a node with remaining depth R sits at ply ~(root_depth - R) from the root, and node count grows with ply-from-root, so `sd 6` can never produce a single remaining-depth-10 sample; you need `sd 16+` to make that shell non-trivially populated), for on the order of a week, against a large position sample. **Composition of that sample matters and was the subject of a real, expensive lesson this session (see "the tactical-position gap" below): mostly real-game cross-sectional positions, with a MODEST tactical fraction mixed in -- not a puzzle-heavy pool.** Scott's explicit framing: "we want an engine that plays chess, not solves tricky puzzles." A pool dominated by tactical puzzles would teach the table that leftover moves in general are riskier than they really are (puzzles are adversarially selected to have a non-obvious best move), producing a table that under-prunes in the ordinary quiet middlegame positions that make up most of a real game. Something like a 80/20 or 90/10 real-game/tactical blend is a reasonable starting point to test, not a locked-in ratio. Practical constraints learned from the smaller collection runs already done: - Per-position cost at real depth varies enormously (34s to 300+s observed at `sd 14-16` on different positions, single-threaded) -- don't budget wall-clock by assuming uniform per-position cost. Feeding each shard a generous position POOL (more than it's expected to finish) and bounding by wall-clock `timeout` rather than position count means the run naturally uses whatever time is available without needing to guess position count in advance. - The `/tmp` tmpfs on this box is shared and has been observed at 99% full from OTHER processes mid-session (not our own files) -- don't assume a `du`-based capacity check at the start of a run is still valid hours later. - A stray, unrelated process (an interactive `xboard -fcp ./typhoon -scp crafty` game, apparently left running from earlier work) has shown up sharing the box's cores during a collection run. Worth a `ps aux | grep typhoon` sanity check before assuming all available cores are actually free. ## What the data is FOR: EFP == LMP, LMR as a lower-risk fallback Once gate-1 is passed, the joint table gives a real `P(fail-high)` estimate. Two consumers, deliberately sequenced -- prune first (since it was the smaller, more contained change), reduction later: **EFP/LMP (unified)**: skip the move outright if predicted FH% is below some `epsilon_prune`. Irrecoverable, so `epsilon_prune` needs to be very conservative and, critically, should be picked from a real measured precision/aggressiveness curve (sweep several candidate thresholds through the SAME audit mechanism used to validate the table, plot real FH% and candidate volume against each), not guessed. Runtime-tunable via `set EFPCandidateThresholdTenths ` specifically so this sweep doesn't require a rebuild per value. **LMR (future work, not yet touched this session beyond discussion)**: the SAME table, but as a reduction-amount driver instead of a binary skip -- `reduction_plies ~ f(-ln(predicted FH%))`, monotonically more aggressive the safer the cell looks. The key asymmetry that justifies a MORE permissive threshold for reduction than for pruning: a reduction is recoverable in (at least) the direction we can detect -- the existing "research deeper if history pruning failed" path already re-verifies a reduced move whose shallow search unexpectedly raises alpha. What it does NOT catch is the other direction: a reduced search that fails low, silently accepted, with nobody ever checking whether the FULL-depth search would have found a raise there instead. Built an audit for exactly this (`LMR_AUDIT` build flag, its own opt-in profile since it costs a real extra `Search()` call per audited candidate, not just a counter increment) -- for a move that got reduced and then failed low at the reduced depth, re-run the SAME scout window at full (reduction-undone) depth and check if THAT raises alpha. First real numbers (small sample): miss rate 4-10% at remaining depth 2, dropping under 1% at depth 3-4, then creeping back up to 3-7% at depth 5-6 -- a non-monotonic, "high-low-high" shape that wasn't the expected smooth decay and hasn't been investigated further yet. Both consumers, plus per-move veto signals (below), sit on top of the same gate-1-gated table lookup -- this is the concrete sense in which EFP and LMP "are the same thing now." ## Per-move veto signals: two tried, both hit cliffs, no smooth middle ground The idea: once the table flags a move as a pruning candidate, consult a cheaper-or-not-much-more-expensive per-move signal as a final veto, since the table only knows the aggregate risk for this (depth, move, gap) CELL, not anything about this specific move's own tactical merit. Both signals tried this session are audit-only-first, real-pruning- second, and both turned out to be effectively binary rather than a smooth dial: 1. **`ComputeMoveScore` veto, Heinz-style depth-scaled margin.** Generalized the old two-tier `VALUE_KNIGHT`(1.75ply)/`VALUE_ROOK` (2.75ply) activation margin to a linear scale across the whole depth-1-9 range (`uEFPVetoMarginBase + (depth-1) * uEFPVetoMarginPerPly`, both runtime-tunable). Swept the margin from 0 up to 300/200: below ~(200-300, 100-150) nothing gets rescued at all; at (300, 200) every one of 5 known-loss positions gets rescued, but node count on `ecm_ringers` comes back to within 0.6% of the ORIGINAL unpruned baseline -- i.e. that setting is functionally "never prune," not a real middle ground. **Retired this veto** in favor of (2) below; not worth resurrecting without a fundamentally different margin shape (this was linear-in-depth; something with real curvature might behave differently, untested). 2. **`GetMoveFailHighPercentage` veto** (per-move-IDENTITY history, `dynamic.c`'s `g_FailHighs` table, keyed by `mv.uMove & 0xFFFFF` -- near move-exact, not just piece+destination). Exempt a flagged candidate from pruning if it has real, evidenced history of failing high: `(uFHAttempts >= MinSamples) && (uFHPct > Threshold)`. **Critical, easy-to-miss fact about this table's lifecycle**: every cell resets to `0x00010001` (1 attempt, 1 fail-high = a 100% FH-rate PRIOR), not zero, on `ClearDynamicMoveOrdering()` -- which fires on `setboard`/`new` (`PreGameReset`'s callers), i.e. **once per position in a suite run, but only once per GAME in real play, never between moves.** At the most permissive setting tested (`MinSamples=1, Threshold=0`), the veto protects EVERY move by default until real accumulated failures (only `_RecordMoveFailure` ever touches these cells during a normal search; it increments the attempts denominator without touching the fail-high numerator, diluting the percentage toward 0 -- reaching an integer-truncated 0% takes on the order of 100+ observed failures of that exact move-identity within the current search) prove a specific move safe. This is NOT "smart triage using real evidence" so much as "protect nearly everything by default, until proven otherwise" -- confirmed by measuring the actual prune rate among flagged candidates: **70-80% of everything the table flags gets vetoed and fully searched anyway**, at every depth checked. That's a real, quantified NPS cost for zero node savings on the majority of "candidate" evaluations. Swept `(MinSamples, Threshold)` away from the extreme (1,0) setting -- same cliff pattern as (1): every tested combo away from (1,0) lost 4-5 of 5 known-loss rescues while only partially reducing the waste rate; none beat "just tighten the table threshold with no veto at all" (which alone recovers exactly 1 of 5, `ECM.272`, the one case that was ever a pure threshold-calibration problem rather than a population/data problem). **Open question, not yet resolved**: does this veto's real-game behavior degrade over the COURSE of a single game, given the table never resets mid-game? Live self-play match data shows the candidate's average- search-depth advantage over the unmodified baseline shrinking as the match's game count grows (within one fixed-configuration match: +1.09 ply at 30 games, +0.57 at 60, +0.49 at 300) -- consistent with, though not yet proven to be caused by, staleness accumulating within individual games as they get longer. Needs a direct test: instrument depth-gain (or the candidate/actually-pruned ratio) as a function of MOVE NUMBER WITHIN a game, not just averaged across whole games, using actual game logs from a running match rather than inferring it from the aggregate trend. ## `sd` vs `sn` vs real games -- and getting this backwards cost real time **`sd N` (fixed depth) is structurally the WRONG test for anything that trades tree size for depth.** At equal depth, a leaner-tree change can only ever cost solves relative to baseline -- it never gets credited for the depth it's supposed to buy, because the test held depth fixed instead of letting the efficiency convert into extra plies. This session ran an expensive round of `sd10` suite comparisons that showed what looked like a severe regression (14 net lost solutions across two curated suites) before catching that the test itself was the problem, not (only) the mechanism: re-run at `sn 5000000` (fixed node budget, the fair comparison, since it lets the efficiency actually convert into depth the way it's supposed to), the SAME positions and SAME build showed net +6 solved across all three curated suites with deeper average search depth everywhere. **Any test of a pruning/reduction change needs to specify which of these three it's using and why**: `sd` for "does this change the tree SHAPE at fixed effort" (the right question for judging LMR/EFP's aggressiveness in isolation), `sn` for "does this deliver more accuracy at fixed COST" (the right question for judging whether a leaner-tree change is worth it), real self-play games for "does this actually win more, accounting for everything neither synthetic test can capture" (time-control-driven node budgets that vary naturally by position, and the compounding effect of thousands of small decisions across a whole game rather than a handful of curated positions). Related, and worth its own callout: **curated ECM suites have real, documented noise** (see `tests/ECM.md`'s own "flaky/garbage" catalog -- oscillating PVs, near-0.00 scores, single-line coin-flips). A ±1-2 solve swing on a 90-position suite, or any result on the 11-position `ecm_ringers`, is not strong evidence on its own. Chasing a specific named position's recovery (the 5-position known-loss set used throughout this session's veto sweeps) can actively mislead: the 0.5%-threshold configuration that achieved a PERFECT 5/5 recovery on that named set actually performed WORSE in full-suite aggregate (net +2) than the 1.0%-threshold configuration it was being compared against (net +6) -- optimizing against a small, hand-picked set pulled toward a setting that was worse on the broader, still-imperfect-but-larger sample. The named positions are useful as a fast smoke test for "did this obviously break something we already know about," not as an optimization target. ## The tactical-position gap: why this matters more than it first looked The first live-pruning attempt (table threshold 1.0%, no veto) showed a real, `ECM.md`-confirmed regression: legitimate tactics lost, including `ECM.272` -- a position independently, manually re-verified from scratch this same session (traced the exact sacrifice line, confirmed our own engine solves it cleanly and stably from depth 7, confirmed by an independent Crafty search converging to the same move) as a genuine, non-flaky tactic. Root cause, confirmed by a threshold sweep specific to the 5 known-loss positions: tightening the table threshold all the way to 0.1% (10x tighter than the default that failed) rescued exactly 1 of 5 (`ECM.272`); the other 4 stayed wrong regardless. **That's the tell that it's a population/training-data problem, not a threshold- calibration problem** -- if tightening 10x doesn't move 4 of 5 cases at all, the table's own predicted value for whatever cell is involved is wrong, not just permissively thresholded. `cross_sectional_200k.ep_` (real games only) apparently underrepresents the "quiet-looking but critical" move shape tactical positions are specifically curated to contain, enough that the table confidently mispredicts it. This is the direct motivation for mixing a modest tactical fraction into the next, larger collection pass (see above) -- not to make the engine solve puzzles better, but because the training population needs to actually contain examples of the shape it's being asked to judge. ## Status as of this write-up - Real pruning is OFF pending resolution of the open questions above (in-game staleness, and the not-yet-attempted ECM-augmented retraining). What's live right now (via `EFPCandidateThresholdTenths`/ `EFPFHVetoMinSamples`/`EFPFHVetoThreshold`, all runtime-settable) is the most conservative configuration tested (0.1% table threshold, `(1, 0)` veto), currently running as `candidate` in an ongoing SPRT- style self-play match against unmodified `head` -- last checkpoint: 300 games, score 0.473, llr -0.50 (inconclusive, hasn't crossed either bound; trending toward parity after an unfavorable dip at 60 games that looks in hindsight like small-sample noise, consistent with the "ECM suites are noisy, don't over-read small samples" lesson above). - `FH_TABLE_COLLECTION` build flag exists and works; a real, week-long, larger, tactically-augmented collection run is the planned next major step, informed by everything above. - The `ComputeMoveScore` veto is retired (dead end, see above); the `GetMoveFailHighPercentage` veto's mid-game staleness question is open and needs direct instrumentation, not just inference from match trends. - LMR (reduction) work has not actually started yet beyond the `LMR_AUDIT` miss-rate measurement infrastructure and this discussion of how the risk threshold should differ from pruning's -- next in line once the pruning side's open questions are resolved. --- # Update, 2026-09-27/28: table-driven EFP goes fully live, two real # bugs found and fixed, self-play verdict is "roughly even" Continuation of the above, same overall project. This section covers what changed from "status as of 2026-09-26" (real pruning OFF, conservative audit-only config) through making it genuinely live, finding and fixing two real bugs via self-play (not suites), and the honest current verdict. ## Old EFP fully replaced, not unioned Early in this stretch, discovered the old margin-based EFP block and the new table-driven block were BOTH live simultaneously (unioned via a shared `fThisMoveEFPPruned` flag) -- an artifact of iterating on the veto experiment without reconciling the two blocks. Fixed: old EFP wrapped in `if (FALSE && ...)`, permanently disabled; the table-driven mechanism is now tested as a genuine replacement, not an addition. Also added, matching old EFP's own behavior that the table-driven block had been missing: explicit `NODE_PV != pi->eNodeType` exclusion, and the losing-capture admission switched from a depth-gated local `iMoveSee` (only computed for `uDepth < THREE_PLY`, silently defaulting to "non-losing" outside that range) to the cached `MVF_SEE_KNOWN`/`MVF_SEE_NONNEGATIVE` move-stack flags `generate.c` already sets unconditionally at generation time for every capture/ promotion -- correct at every depth, not just near the horizon. **Also discovered `ComputeMoveScore` is a no-op for quiet/leftover moves** under `MOVE_SCORING_NORMAL` (always returns 0) -- meaning old EFP's real selectivity, historically, came ONLY from its coarse depth/margin gate plus the killer/FH-history exemptions, never from any actual per-move tactical read. Worth remembering next time something is attributed to "EFP's margin test was smart about this move" -- it wasn't; it was smart about depth alone. ## Depth-scaled threshold, not a flat one A flat FH% threshold applied uniformly across the whole prune-eligible depth range is structurally wrong: old EFP only ever operated within ~1-2.75 ply of the horizon, where a wrong prune costs little (a small, bounded subtree). This mechanism's point is to extend pruning deeper, where a wrong prune is far more expensive (an exponentially larger subtree). `_EFPThresholdForDepth()` (search.c) interpolates GEOMETRICALLY (not linearly -- the two endpoints span orders of magnitude) between `uEFPThresholdShallowHundredths` (depth 1, permissive -- old EFP's own real gate tolerated FH% up to the tens of percent at 1-2 ply and still won) and `uEFPThresholdDeepHundredths` (at `uEFPMaxPruneDepth`, near the table's measurement floor). ## `MoveLooksDumb`: from boolean, to magnitude bucket, to the joint table A new 4th signal, alongside depth/move/gap: does this quiet move statically look like it hangs material? Went through several rounds: 1. **First attempt**: three OR'd sub-checks (a "reversal" move, a pawn-attack, an ignored-hanging/trapped-piece read). Isolation testing found only the pawn-attack idea behaves as intuition predicts. The other two were retired for real, understood reasons, not just "didn't help": the reversal check had a genuine indexing bug (compared against the WRONG ply's move, `uPly-2` instead of `uPly-3`) that masked a deeper conceptual problem once fixed -- reversal moves measure BACKWARDS (higher FH%, not lower), because a reversal responds to the opponent's own intervening move and is often a genuine correct reaction, not a wasted tempo. The hanging/trapped-piece check also measured backwards: `cEnprise`/ `cTrapped` are written by the same parent `Eval()` call that produces `iEval` (the gap axis's own input) -- a real confound with gap, not independent signal. 2. **Second attempt** (kept, still live): "does this move hand material away for no reason" -- unconditionally for a strictly cheaper attacker (pawn attacks any non-pawn; minor attacks a rook; minor or rook attacks a queen), and conditionally for a queen-vs-queen "undefended" case discussed and then REMOVED below. Tried and retired: an equal-value-attacker-AND-zero-defenders test for minors/rooks -- structurally broken, because `pos->bbMinorAttacks[uMover]`/`bbRookAttacks[uMover]` (computed PRE-move) already includes the mover's own pre-move reach to its destination square (that's what makes the path legal), so "zero defenders" was almost never true with multiple minors/rooks per side and no cheap way to tell "the piece that just left" from "a different piece of the same type still defending." The QUEEN case sidesteps this because there's normally exactly one queen per side (gated on `FastCountBits(bbPieces[uMover][QUEEN]) == 1`). 3. **Boolean -> magnitude bucket**: "dropping a bishop to a pawn" and "dropping a queen to a pawn" were both just `TRUE` before, throwing away real information (a ~28,000x FH% swing across gap buckets alone says magnitude matters). `MoveLooksDumbLossCp()` now returns an approximate centipawn loss; `MoveLooksDumbLossBucket()` coarsens it into `DUMB_LOSS_BUCKETS` (chess.h, =6) piece-shaped tiers. 4. **A real UnmakeMove-ordering bug**, caught before it shipped: the FH-side counter increment originally called `MoveLooksDumbLossCp` a SECOND time, after `UnmakeMove` had already restored the board and flipped side-to-move -- silently classifying a different position. Produced an impossible >100%-FH cell in testing. Fixed by computing the dumb bucket ONCE (before `UnmakeMove`) and reusing the stored value on both the tries-side and FH-side counters. 5. **Joint (4D) table**: built `(depth, move, gap, dumb)` jointly, not just as two separate marginal cuts, to test whether "dumb" compounds with "gap" rather than being redundant. Confirmed real compounding (e.g. depth 3, gap bucket ~4: dumb=0 -> 0.948% FH, dumb=4 (post-coarsening numbering) -> 0.008% FH, a ~120x reduction at 3.2M samples) -- this justified keeping dumb as a real 4th axis instead of folding it into gap. ### Queen-vs-queen removed outright (2026-09-27, this session's tail) The queen-vs-queen-undefended sub-check (originally bucket 5, ~975cp loss) was the one case that never behaved like the rest. Direct analysis of the 45-minute joint-run data: marginal FH% by dumb bucket was 0.057%/0.072%/0.098%/0.010% for buckets 1-4, but **0.457% for bucket 5 -- higher than "not dumb" (0.335%) baseline**, and this persisted (0.0147% vs. 0.0195% baseline, still elevated relative to bucket 4's 0.0045%) even after controlling for the gap axis. Root cause suspected but NOT confirmed: `_WhoAttacksSquareBB` (see.c) has no pin/legality awareness, so a pinned enemy queen still "shows" as attacking. Rather than ship a signal nobody understands, **the queen-vs-queen check was deleted from `MoveLooksDumbLossCp` entirely** (search.c), not just excluded downstream -- "losing a rook to a pawn is never a good idea" is a claim we can defend; "queen attacked by queen looks safe to prune" turned out not to be, for reasons still unexplained. `MoveLooksDumbLossBucket()` still has 5 tiers (0=not dumb, 1-4 = increasing loss magnitude: ~200/~400-475/~675/~875cp); bucket 5 (975cp) is simply unreachable now. `DUMB_LOSS_BUCKETS` stays at 6 in chess.h so the baked tables (sized `[...][6]`) don't need reshaping -- the top slot is just always zero. **A bug caught during this edit, worth remembering**: the first pass at simplifying `MoveLooksDumbLossBucket()` collapsed the 675cp and 875cp tiers into one bucket, based on an incorrect assumption that removing the queen-vs-queen check also removed the 875cp value. It didn't -- 875cp (a queen attacked by a pawn) is still produced by the generic, unconditional pawn-attack branch at the top of the function, which was never touched. Caught by rereading the actual reachable value set before trusting the "simplification." ## Real pruning: Tier 1 / Tier 2, then unified into one mechanism Two designs were tried, in order: **Tier 1 + Tier 2 (retired)**: Tier 1 = percentage-curve pruning, capped at `uEFPMaxPruneDepth`. Tier 2 = `JointMoveNeverFailsHigh()` (data.c) -- prune unconditionally at ANY remaining depth when the 4D joint table shows a confirmed zero fail-high over a large-enough sample (rule-of-three statistical confidence), EXCLUDING dumb buckets 0 (not dumb -- this table says nothing about ordinary moves) and 5 (queen-vs-queen, even before it was removed from the signal entirely). Audited before trusting: found real misses at depth 3-4 even at `MinSamples=10000` -- one exact miss traced to a genuine, deliberate queen sacrifice (`4rrk1/6p1/p7/1p1Np3/1bq1P3/7Q/PPP5/1K1R2R1 w - - 3 0`, `Qh6`) near the enemy king, not a bug. Added `Tier2MinDepth=5` after confirming clean audits at depth >=5 across all three curated suites. **Unified (current, live)**: per explicit direction ("I think we should not have tier1 and tier2 ... prune everywhere again but use a graduated threshold" -- then, when the deep end of the percentage curve flatlined at its representable floor, "I don't want 0.01%. I want 0 FHs ever seen with >5k positions, >10k positions, etc"): ``` uCandDepth = uDepth / ONE_PLY if (uCandDepth < g_Options.uEFPMaxPruneDepth): prune if PredictedFHPercentHundredths(...) < _EFPThresholdForDepth(uCandDepth) else: prune if ConfirmedZeroFailHigh(uCandDepth, uMoveNum, gap, dumbBucket) ``` `ConfirmedZeroFailHigh()` (data.c) requires a literal zero observed fail-high over a sample count that DOUBLES each ply past `uEFPMaxPruneDepth` (base `g_Options.uZeroConfBaseSamples`, default 5000 at the boundary depth, 10000 the next ply, 20000 the next, ...). **As of 2026-09-27 this reads the same 4D joint table Tier 2 used** (`g_uJointTries`/`g_uJointFH`), not a separate gap-only marginal -- see "the self-play regression and its real cause" below for why that change was necessary. ## The self-play regression and its real cause First live self-play run of the unified design (30 games): **-8=21+1, score=0.383, llr=-0.42** trending firmly toward reject, with `ebf` WORSE for candidate (3.590 vs head 3.522) and candidate searching SHALLOWER (12.87 vs 12.92 avg depth) at similar nps -- exactly backwards from what a working pruning mechanism should show. This was "clearly a bug, not just an aggressive tradeoff" territory, matching Scott's own read: "I think there's a bug in the pruning impl." An advisor consultation (mid-investigation) proposed a structural theory -- pruning removes move-ordering/TT information that's load-bearing elsewhere in the tree, and this cost scales with how deep the prune fires, which old EFP never paid because it stayed within 1-2.75 ply of the horizon. That's a real, general risk (see "on reduction vs. pruning" below), but a direct code audit found something more concrete and immediately actionable: **`ConfirmedZeroFailHigh` was reading `g_uGapRawTries`/`g_uGapRawFH` -- the (depth, move, gap) marginal SUMMED OVER THE DUMB AXIS.** A cell could read "zero FH in 5000+ tries" purely because ordinary, non-dumb moves dominated that cell's volume, while a rare queen-hanging move sharing the exact same (depth, move, gap) cell had never actually been safely tested on its own. Fixed by switching `ConfirmedZeroFailHigh` onto the 4D joint table (adding a `uDumbBucket` parameter, same table `JointMoveNeverFailsHigh` already used), refusing to trust a zero read for the queen-vs-queen bucket outright (matching Tier 2's own caution, at the time this bucket still existed). **Result, same match restarted after the fix (60 games total, 30 new under the fixed binary)**: ebf flipped from "candidate worse" to EXACTLY TIED (3.619 = 3.619), and average depth flipped from "candidate shallower" to "candidate deeper" (13.05 vs 12.81) -- the signature of a real fix, not noise. `g_uGapRawTries`/`FH` and the `GAPRAW_*` macros are now fully dead code, left in place (unused) at the time of this write-up but candidates for deletion. **The `g_uGapRawTries`/`g_uGapRawFH` tables were later deleted entirely** (2026-09-27, same session) once the gap-bucket rework below made keeping them around pointless. ## Quantifying "how much statistical confidence is actually needed" A real question raised directly: given a per-cell "confirmed zero" read only bounds the true rate via rule-of-three (~3/N for N samples), and this gate fires many times per real game, how much does that matter? Worked from real counters, not guesses: - Real `sd10` counters (post-fix): ~303 actual deep-zone (depth >=6) prunes per 31.3M nodes searched, i.e. ~1 per 103,000 nodes. - Scaled to a real blitz budget (1.5M nps x 350s ~= 500M nodes/game): **~4,800 deep-zone prunes per game.** - Rule-of-three bound at the current sample bar (5000 at depth 6, doubling deeper): expected WORST-CASE misses per game ~= 2 (not thousands -- an earlier back-of-envelope estimate using a wrong, much larger node-count assumption was corrected in-session). - To hit a "1 unlucky game in 1000" target at that per-game application volume: `required_N ~= 3 * applications_per_game / target_misses_per_game ~= 3 * 4800 / 0.001 ~= 14.4 million` samples in the specific cell being trusted, not 5,000-40,000. - **Brute-force collection to reach that is not viable**: the 45-minute run put ~5,000 samples into a representative deep cell: getting to 15M is ~3000x more volume, i.e. ~94 days wall-clock even running all 16 shards this box supports in parallel (already at the ceiling -- more shards doesn't help past core count). Confirmed and accepted as a dead end in-session. **2026-10-01 correction: the "1 unlucky game in 1000" target itself was never validated, and the whole framing here was too conservative.** Scott's direct pushback: nobody decided 1/1000 was the right number -- it was picked, not derived -- and 1/100 or even 1/20 is reasonable. Two things compound this: 1. **Not every prune mistake costs the game.** A silent miss (we prune a line that would have fail-highed) only matters if that line actually would have changed the game's outcome -- a strict subset of "a miss occurred." The rule-of-three math above bounds the rate of MISSES, not the rate of GAMES ACTUALLY LOST to a miss, so even the loosened budgets below are still conservative relative to the thing that actually matters (Elo). 2. **The deeper problem: this section was optimizing the wrong quantity.** It treats "minimize miss probability until some arbitrary confidence bar" as the goal in itself. The actual goal is net playing strength: aggregate nodes saved by pruning/reducing get reinvested as extra search depth elsewhere, and depth finds tactics -- that's a real benefit that has to be weighed against the real (but apparently small, see "the self-play regression" and the LMR self-play match above) cost of occasional misses. A `g_uJointFHThousandths`-driven gate exists precisely so this tradeoff can be made *intelligently* (deeper/leaner where the data says it's safe-ish) rather than via a single global, hand-picked confidence bar -- but picking that bar by "make misses astronomically rare" short-circuits the whole exercise and defaults back to maximal conservatism regardless of what the table actually knows. The open comparison against Crafty (much leaner trees, routinely 7-8 plies deeper at equal node budgets) is the concrete signal that this project has been on the over-cautious side of that tradeoff. Recomputed the same formula at looser budgets, same 4,800 applications/game assumption: ``` 1/1000 games -> N ~= 14.4M (0 deep-zone zero-FH cells clear this, as of this run) 1/100 games -> N ~= 1.44M (0 deep-zone zero-FH cells clear this -- largest is 1.34M, close) 1/20 games -> N ~= 288K (16 deep-zone zero-FH cells clear this TODAY, no more collection needed) ``` **Decided direction, not yet executed**: stop treating the rule-of-three sample bar as the primary lever at all. Keep it as a floor against outright garbage (near-zero-sample cells), but the real experiment going forward is to try materially more aggressive settings on both mechanisms -- the 1/20-budget prune bar (288K, usable immediately) and reduce tiers picked to target real extra depth (aim for whole plies, not the +0.3 ply the first p75/p25/p5 self-play run produced) -- and read the result by playing strength and search depth in self-play, not by how rare a miss is in isolation. The right long-term framing: **the actual metric to tune against is net Elo (or a cheap proxy -- depth/nps/ebf movement under a fixed node budget), with the FH% table used to bias WHERE cuts happen, not to justify an arbitrarily tiny global miss rate.** - **What DOES help, cheaply**: coarsening the bucket grid concentrates the same collected volume into fewer, denser cells -- see next section. This buys real headroom without any extra collection time. ## Open gap, 2026-10-01: compounding errors under reinvested depth is unmeasured and unmeasurable by anything built so far Every statistic gathered for this project so far -- raw FH%, the `JOINT_EXTRA_VERIFY` silent-miss rate, the rule-of-three sample bound above -- is a SINGLE-DECISION statistic: it characterizes the chance that one gate, at one node, misjudges. None of it says anything about what happens once that misjudgment is embedded in a continuation that is then searched DEEPER, specifically because the nodes saved by pruning/reducing elsewhere get reinvested as exactly that extra depth. Scott's framing: we have no information about what happens if we prune wrong ~5x/game and reduce wrong such that a reduced-depth search fails low where a full-depth search would have failed high, AND in doing so we bought ourselves ~2 extra plies per move on average -- because searching those 2 extra plies means the SAME gate fires again, 2 more times, deeper under the same kind of position, with more real tree beneath each repetition. **Why this could be qualitatively worse than the per-decision rates suggest, not just "the same risk, more often":** if the 4-axis model's misjudgments were i.i.d. noise, compounding wouldn't be very dangerous -- a short chain of independently low-probability errors is still low-probability, same as any noise process. But this project has already found the model has CORRELATED, structured blind spots, not just noise: the queen-vs-queen dumb-bucket (removed outright, see above -- "safe" reads that weren't, for reasons still not fully understood) and the null-move-bailout signature bug (silent-miss investigation, see above) were both systematic, not random. Reinvesting saved nodes as depth specifically UNDERNEATH the branches just judged "safe" means that if a blind spot like those exists, extra depth doesn't dilute it with more search -- it drills further into exactly the part of the tree most likely to share it. **This cannot be measured by instrumentation built so far, even in principle.** `JOINT_EXTRA_VERIFY`'s silent-miss rate and the rule-of- three bound both describe P(one gate misfires once); there is no way to decompose an aggregate self-play score back into "cost from compounding errors under reinvested depth" vs. "benefit from genuinely sound extra depth" -- they are inseparably confounded in that single number by construction. Per-node instrumentation was always going to be structurally blind to this, no matter how much more data is collected at the per-cell level. **Practical implication for the next (more aggressive) self-play run**: don't just read the final SPRT score. Pull the actual lost/drawn games under the aggressive settings and check whether losses cluster around a recognizable, repeated position type (a live blind spot, same shape as queen-vs-queen) versus being spread uniformly (ordinary noise at a higher-variance operating point). `SILENT_MISS_TRACE`'s existing FEN/move/score logging is most of the needed infrastructure already -- it would need to stay attached through to which game a flagged miss occurred in (not just logged as an isolated per-node event) to make this check possible. Not yet built; flagging as the concrete next instrumentation gap before trusting a bolder self-play result as "just noise" either way. ## Gap-bucket redesign: 32 uniform bins -> 6 piece-value-aligned bins Per direct instruction ("Why do we have 32 gap buckets?! I think they should be like: alpha-100, alpha-300, alpha-500, alpha-900, alpha-1500, worse than that"), `FH_GAP_BUCKETS` (chess.h) went from 32 uniform 50cp-wide bins (`FH_GAP_BUCKET_MIN=-200`, `FH_GAP_BUCKET_WIDTH=50`) down to 6: ``` bucket 0 : gap < 100 bucket 1 : 100 <= gap < 300 bucket 2 : 300 <= gap < 500 bucket 3 : 500 <= gap < 900 bucket 4 : 900 <= gap < 1500 bucket 5 : gap >= 1500 ``` Rationale: the fine 50cp resolution was mostly wasted precision in the near-alpha region (highest FH%, 9%+ -- never going to pass any sane pruning threshold regardless of exactly which of ~6 fine bins it lands in) and starved precision in the tail, which is exactly where a pruning DECISION actually lives. A 32->6 collapse is a ~5.3x reduction in total cell count, i.e. a ~5.3x increase in effective samples per cell for the SAME collection run, for free. **`GapCpToBucket(iGapCp)` (data.c) is now the single source of truth** for this mapping -- previously the -200/50 arithmetic was duplicated inline at SIX separate call sites (two lookup functions, one collection increment... actually three lookup functions and three collection sites) with no shared helper, exactly the kind of duplication that drifts silently. Every lookup (`PredictedFHPercentHundredths`, `JointMoveNeverFailsHigh`, `ConfirmedZeroFailHigh`) and every collection increment (search.c, two sites) now calls this one function. **Both baked tables were rebuilt from scratch** using this new bucketing, sourced from the SAME 45-minute joint-run pickle (`reassembled_joint.pkl`'s `gap` marginal for the percentage table, `joint` dict for the 4D table) -- this also fixes a real, previously flagged inconsistency: the percentage table used to be baked from an EARLIER, smaller, differently-composed collection run (`reassembled_gate1.pkl`) than the joint table, meaning the two halves of the "one seamless mechanism" disagreed about what population they described. Now both come from one dataset. **Verified after rebake** (`sd10`, all three curated suites): solve counts essentially unchanged (one single-position flip on `ecm_confident_quick`, within normal suite noise), and candidate volume at depth 6-9 on that suite jumped from ~132/85/50/1 tries (before coarsening) to **4,177/4,603/3,150/170** (after) -- a ~30-60x increase in samples per surviving cell, exactly the intended effect, with real observed FH still 0.000% at every one of those depths. ## Self-play verdict as of this write-up (2026-09-28) A fresh match (post queen-vs-queen removal + gap-bucket rebake) against `head_reference`, tracked over time: | games | score | llr | ebf (cand vs head) | avg depth (cand vs head) | |---|---|---|---|---| | 100 | 0.520 | +0.10 | 3.641 < 3.654 (better) | 13.21 > 12.93 (better) | | 170 | 0.521 | +0.16 | 3.679 < 3.685 (better) | 12.94 > 12.69 (better) | | 1000 | **0.479** | **-1.34** | 3.617 > 3.600 (worse) | 13.19 > 13.05 (still better) | **Honest read: the early (100-170 game) positive trend did not hold at 1000 games.** llr swung from +0.16 to -1.34 -- more than a third of the way to the H0=-2.94 reject boundary. Average search depth still favors candidate at every checkpoint (the mechanism genuinely is reaching deeper), but that extra depth is not converting into a score edge, and ebf has flipped to favor head at the larger sample. This is roughly consistent with nearly every measurement across this whole project once the concrete bugs (EFP/table union, dumb-axis pooling, queen-vs-queen) were fixed: **"even," not "clearly ahead."** This matches the advisor's structural argument from the mid-session consultation: pruning at depth ranges beyond old EFP's narrow 1-2.75 ply removes move-ordering/TT information that's load-bearing elsewhere in the tree, and that cost doesn't fully disappear just because the individual prune decisions are well-calibrated. Fixing concrete bugs recovers lost ground (30-game catastrophe -> 1000-game parity) but does not, on its own, produce a clear win. **Decision point reached, not yet resolved**: either (a) let the current match run to a real statistical conclusion (llr crossing either bound) before drawing further conclusions from noisy checkpoints, or (b) accept "even" as pruning's ceiling at this depth range and pivot to reduction (see next section) instead of continuing to chase a clear win from harder pruning alone. ## On reduction vs. pruning, as the likely next step Not yet implemented. The core argument for why reduction should not need anywhere near the ~15M-samples-per-cell bar pruning would need at a permissive threshold: **a reduction is self-correcting when wrong.** The standard PVS/LMR re-search machinery already re-verifies a reduced move whose shallow search unexpectedly raises alpha or fails high. A wrong prune is permanently lost; a wrong reduction costs one extra re-search. That changes the risk calculus completely and was always the second half of the original plan ("beat EFP at its own game and then use this table to layer on reduction with a less conservative threshold"). Sketch discussed, not yet built: stack an additive `extra_reduction_ plies` term onto whatever `GetLMRReduction()` already computes for a gate-1-eligible move, driven by `PredictedFHPercentHundredths`: ``` FH% >= 5% : +0 (no extra reduction) 1% <= FH% < 5% : +1 ply 0.1% <= FH% < 1% : +2 plies 0.01% <= FH% < 0.1% : +3 plies confirmed zero, or FH% < 0.01% : +4 plies ``` Clamped so total depth never goes below some floor, same clamping discipline `ComputeMoveExtension`/`GetLMRReduction` already use. Reuse the SAME gate-1-eligible population and the SAME table this whole document is about -- no new data collection needed to start this, only new search.c wiring at the LMR call site. ## How to regenerate the tables (reproducibility notes) Both baked tables (`g_uFHPercentHundredths` and `g_uJointTries`/ `g_uJointFH`, all in data.c) are derived from `.pkl` files produced by reassembling a sharded collection run's raw `FHGAPRAW`/`FHJOINTRAW` output lines (see "how the data is collected" above for the format). **These `.pkl` files and the reassembly/codegen scripts live under `/tmp/typhoon/` (scratch, per this repo's `CLAUDE.md` policy) and are NOT checked into git** -- if they're gone, the tables can only be regenerated by re-running collection from scratch. As of this write-up they still exist at `/tmp/typhoon/fh_joint_run/ reassembled_joint.pkl` (the current, live source for BOTH tables). To regenerate from an existing reassembled pickle after a bucket/table shape change (what was actually done for the 2026-09-27 gap-bucket rework): 1. Load the pickle (`pickle.load`) -- top-level dict with `joint` (key: `(depth, move_idx[0..15], old_gap[0..31], dumb[0..5])` -> `[fh_count, tries]`, note **move_idx here is already 0-based, pre-capped at collection time** -- do NOT subtract 1 again) and `gap` (key: `(depth, move_raw[1..63], old_gap[0..31])` -> `[fh_count, tries]`, note **this one IS 1-based raw move number**, a genuinely different convention between the two dicts that cost a real debugging cycle to catch -- verify which convention a given dict uses before writing a regeneration script, don't assume they match). 2. Re-bucket the gap axis using whatever the CURRENT `GapCpToBucket` boundaries are (old bucket `g`'s underlying range is `[-200+50g, -200+50(g+1))`; map each old bucket to whichever new bucket contains that range -- boundaries were deliberately chosen as multiples of the old 50cp bin width so this mapping is exact except for the old table's final overflow bin, which has no defined upper edge and needs a documented judgment call). 3. Sum `[fh, tries]` for every old cell that maps to the same new `(depth, move, new_gap, dumb)` key. 4. Emit C initializer text matching the existing array declarations exactly (dimension order, `// move N` / `// depth N` comments for readability), then splice it into data.c replacing the old array body between its declaration line and matching closing `};` (brace- depth-tracked, not a fixed line-count assumption -- table bodies are tens of thousands of lines and shift size on every rebuild). 5. Rebuild (`gmake clean && gmake -j5 GENETIC=1 PERF_COUNTERS=1 MP=1 SIXTYFOUR=1`) and re-run the `sd10` three-suite sanity check before trusting the result -- solve counts should be stable (+/-1 on `ecm_confident_quick` is normal suite noise) and the EFP candidate audit block's real-FH% should still read ~0.000% at depth >= `uEFPMaxPruneDepth`. To regenerate from a FRESH collection run instead of an existing pickle: re-run the same sharded, `--cpus 1`, `FH_TABLE_COLLECTION`- gated collection process described earlier in this document (16 shards, wall-clock-bounded via `timeout`, mixed real-game/tactical position pool), reassemble each shard's LAST checkpoint block's `FHGAPRAW`/`FHJOINTRAW` lines into the same pickle shape, then follow steps 2-5 above. --- # Update, 2026-09-28/29: phase 2 (graduated LMR), a real measurement # bug found and fixed, EFP's per-move check confirmed dead, and the # "deep prune" idea Picks up where the previous section left off (reduction as the likely next step after pruning's self-play verdict landed at "even"). This is a companion, largely independent table build -- a fresh 4-axis `(depth, dumb, gap, late)` joint table baked from a new 60-90 minute 16-shard `sd 16` collection run (see `lmr_testing/2026-09-28_four_axis_joint_table_plan.md` for the full collection/bake recipe), `g_uJointFHThousandths[12][11][11][15]` in `data.c`, thousandths-of-a-percent resolution, `0xFFFF` sentinel below a 500-tries-per-cell floor. Same underlying idea as the EFP work above, applied to reduction instead of pruning, and building its own independent measurement infrastructure along the way. ## The mechanism: strictly additive over `GetLMRReduction` Deliberately the smallest first experiment: HEAD's existing flat `-ONE_PLY` reduction (`GetLMRReduction`, searchsup.c) is left completely unchanged and still decides whether a move gets reduced at all. The table is only ever consulted to add MORE plies on top of a move already reduced -- never less, never on a move `GetLMRReduction` wouldn't have touched anyway. `EFP is left untouched too (including its known bug, see below) -- this round is measuring the LMR augmentation in isolation, on purpose. Tier thresholds (tries-weighted percentiles of the table's predicted- FH% distribution, computed against the ACTUAL LMR-eligible population -- `uLegalMoves > 5`, real `uDepth > TWO_PLY`, dumb bucket 1 excluded as ~97% SEE-scored losing captures/underpromotions misfiled onto the quiet-move axis, same lesson as the EFP work's dumb-bucket-1 exclusion above): ``` FH% >= p75 (0.072%) : +0 (no extra reduction, baseline -1 ply only) FH% < p75 : +1 ply FH% < p25 (0.005%) : +2 plies FH% < p5 (0.001%) : +3 plies (max) ``` `JOINT_EXTRA_MIN_REMAINING_DEPTH` floor: currently `ONE_PLY` (the original, most aggressive setting) after a round-trip described below -- briefly raised to `FOUR_PLY` out of caution, then reverted once the apparent danger was shown to be a measurement artifact, not a real safety problem. Main move loop (`search.c`) only, same as the EFP work -- not wired into `split.c`'s `HelpSearch`, for the same reason (per-node snapshot arrays with no path into a helper thread's own context; every comparison protocol in this project already runs `--cpus 1`, so this costs nothing in what can currently be validated). ## The null-move-bailout measurement bug (the big one this round) Built `JOINT_EXTRA_VERIFY` (shadow-search verification: after a real reduced search fails to raise beta, re-run at `uNextDepth + tier*ONE_PLY` and check if THAT would have failed high -- same idea as the EFP-side "audit before trusting" discipline throughout this whole project) and `SILENT_MISS_TRACE` (log FEN/ scores/moves on every detected miss). First numbers were alarming: up to 1-11% silent-miss rates across various configurations -- a "reduced search fails low, hiding a fail-high the real search would have found" rate that would have meant this mechanism was unsafe. **Root cause, found via live gdb stepping through an actual flagged case, not guessed**: `TryNullmovePruning`'s (searchsup.c) intentional Tord Romstad idea -- ```c // This is an idea from Tord Romstad on ccc: if we are below a // history reduced node and we try a nullmove and the nullmove // fails low and the move that refuted the nullmove involves // the same piece moved in the reduced move at ply-1 then bail // out of the reduced depth search now and research at full // depth. ... if ((mv.cFrom == (pi-1)->mv.cTo) && ((pi-1)->iExtensionAmount < 0)) { ... *piNullScore = iAlpha - 1; return TRUE; } ``` deliberately FABRICATES a score of `iAlpha - 1` and returns `TRUE` instead of doing a real re-search, specifically because that fabricated value negates up to exactly `iBeta+1` one level up -- correctly triggering the REAL search's own existing re-search-on-fail-high machinery. This is correct, intentional, and safe in the real search path. It is NOT safe inside our bespoke, one-off shadow-verification `Search()` call, which has no surrounding wrapper to catch that "please re-search me" signal -- our own verification code misread the fabricated fail-high as a genuine one. **Fix**: `NEUTRALIZE_NULL_BAILOUT` (GNUmakefile flag) -- temporarily zero `pi->iExtensionAmount` (save/restore) around just our own shadow `Search()` call, disabling only this one precondition for our own verification, leaving every other occurrence of the real mechanism (anywhere else in the tree, on any other move) completely unaffected. **Result**: true silent-miss rate, measured correctly, is ~0.00056% (~1 in 178,000) even at the most aggressive `ONE_PLY` floor -- the 80-99.8% of "misses" the earlier numbers showed were this measurement artifact, not real danger. This is why the floor was reverted from `FOUR_PLY` back to `ONE_PLY`: the conservative retreat had been a reaction to contaminated data. **Lesson for next time a shadow/audit `Search()` call is built anywhere in this codebase**: any node-local mechanism that communicates results by fabricating a boundary score and relying on a CALLER a level up to interpret it correctly (this null-move bailout, and structurally anything similar) is a landmine for exactly this kind of one-off internal verification call. Check for this class of mechanism specifically before trusting a shadow-search-based audit's raw miss rate. ## Genuine (post-fix) misses: two distinct classes, neither cheaply catchable Investigated the ~handful of genuine misses surviving the fix by hand, one at a time, using a fresh, independent `sd1` vs `sd8`-through-`sd12` comparison per position (NOT the `SILENT_MISS_TRACE` hash-chain `shadowRefutation` guess, which was separately confirmed unreliable -- `GetPonderMove`'s hash-chain walk can read stale/unrelated entries since the hash table is global, not scoped to the specific verification call; one hand-traced "refutation" turned out to be a completely different line than what an independent `sd12` search actually found). Two real, distinct patterns found: 1. **Narrow-window boundary noise, not a real miss.** One traced example showed `realScore`/`shadowScore` swinging wildly (`-500` to `+144`) at the trace's logged `alpha=143 beta=144` -- but independently re-searching the SAME position freely (`sd1` through `sd12`, no artificial window) showed a stable, consistent assessment the whole way (-3.5 to -3.75), no swing at all. The trace's narrow inherited PVS window means `shadowScore >= beta` only proves "clears this specific tight ancestor-relative threshold," not "the position is actually good" -- a real but mundane form of alpha-beta boundary noise, not a blind spot a heuristic could detect or avoid. 2. **Genuine horizon-effect quiet tactics.** A second traced example (`3r1rk1/1pq1bppp/p7/n2R4/P1N3Q1/1P6/1B3PPP/R5K1 b - - 3 0`, after White's `Qd4`) was real: `sd1` likes `Qd4` (+2.06), but `sd8` through `sd12` all agree it loses to `Bb6` (a quiet, non-capturing deflection attacking the queen -- `Qxb6 Qxd7` nets Black the exchange). `Bb6` isn't a capture or check, so it's entirely outside `sd1`'s horizon; this is inherent to any bounded qsearch (captures/checks only), not a specific bug. **Tried and abandoned, per direct experimentation with `QSearchFromCheckNoStandPat`'s (search.c) check-extension gate**: loosened the `+2 uQsearchCheckExtension` conditions three times in a row (narrowed the inner OR-conditions, then removed the `CountKingSafetyDefects(pos, ...) > 4` outer gate entirely), rebuilding and re-running the SAME automated `sd1`/`sd8` batch comparison (224 unique genuine-miss positions) after each change. **All three variants showed no measurable difference from baseline** (median swing 1.35, mean ~2.71-2.73, same top-10 largest-swing list/PVs, ~20.5% sd1==sd8 move-agreement rate, every time) -- this specific lever doesn't touch the class of miss the genuine examples actually show (the `Bb6` example isn't a check, so a check-generation-budget change was never going to catch it). Reverted to the original condition set. Conclusion, by mutual agreement: this is a quirk inherent to low-depth search, not a systematic, cheaply-fixable blind spot -- punt on chasing it further. ## EFP's `ComputeMoveScore` per-move check confirmed dead, quantitatively Separately (but directly relevant to "should the table eventually replace EFP"), traced `ComputeMoveScore`/`DecodeMoveValueEstimate` (searchsup.c) end to end and confirmed by code reading (not just inference) that EFP's per-move sanity check -- ```c (ComputeMoveScore(ctx, mv, (x - 1)) < uFutilityMargin) ``` -- is a tautology given the gates already passed at that point in the `if`: every quiet (non-capture/promotion) move decodes to exactly `0` under `MOVE_SCORING_NORMAL`/`MOVE_SCORING_QSEARCH_*`, and `IS_CAPTURE_OR_PROMOTION(mv)` is already excluded earlier in the same condition -- so this check can never evaluate to anything but `0 < uFutilityMargin`, which is already guaranteed true by the `(uFutilityMargin)` gate a few lines up. It contributes nothing; EFP's real (and only) per-move selectivity has always come from the killer-adjacency and `GetMoveFailHighPercentage` history exemptions below it, confirming and extending the earlier (2026-09-27) finding in this same doc that "EFP's margin test was never smart about the specific move." ## Quantifying what EFP is blind to, using the new table Queried `g_uJointFHThousandths` directly over EFP's actual typical operating envelope (1-2 plies remaining, gap 300-500cp below alpha -- the `VALUE_KNIGHT`-to-`VALUE_ROOK` margin -- leftover moves, move-number bucket ~6-30+): - Overall range across that envelope: ~0.000% to ~0.37%, median ~0.009% (p10-p90: 0.001%-0.046%). - **The `dumb` axis -- EFP is completely blind to this -- swings FH% 15-30x within an otherwise-fixed cell** (e.g. depth=1, gap=4, late=8: `dumb=0` -> 0.032%, `dumb=5` -> 0.002%, `dumb=9` -> 0.001%). - **The `gap` axis -- EFP's only real lever -- only swings FH% ~4x** across its ENTIRE knight-to-rook operating range (gap=3 -> 0.068%, gap=5 -> 0.017%). - The `late` axis (partially, structurally captured by EFP's `uLegalMoves > 1` gate, but not gradedly) also swings ~4-5x (late=5 -> 0.041%, late=14 -> 0.009%). **The axis EFP cannot see (dumb) has a bigger effect than the one axis it does look at (gap).** Direct, quantified evidence that the table carries real information EFP structurally cannot use, independent of the `ComputeMoveScore` bug above. ## The "deep prune" idea: cut hopeless-looking branches much higher in the tree Scott's proposal, motivated by the above: rather than (or in addition to) EFP's high-volume, low-value pruning right at the horizon (1-2 plies remaining, ~30-100 node subtrees, mostly qsearch), use the same table to prune a MUCH smaller number of branches much higher up (6-8 plies remaining) where a real hopeless/late/dumb-material cell reads a genuinely evidence-backed (500+ tries per the shipped floor) exact `0.0000%` observed FH rate. **Order-of-magnitude check, from real measured numbers (self-play `ebf`~3.6, and an observed ~84% qsearch-node share in a real `sd8` trace)**: full-width-nodes-times-qsearch-multiplier estimate puts a 1-2-ply subtree at ~30-100 nodes (matches EFP's actual footprint) vs. ~20,000 (depth 6) to ~250,000 (depth 8) nodes for a deep subtree -- several orders of magnitude more payoff per prune decision. **Confirmed the table actually has real, evidence-backed near-zero cells there**: at depth=6-8, gap=10 (>=901cp behind -- roughly a queen or more), late=14 (very late/leftover), dumb=6-9 (materially hanging), observed FH% reads exactly `0.0000%`, and because every non-sentinel cell already cleared the 500-tries collection floor, this is a real "watched 500+ times, never once failed high" statement, not a guess. The most extreme combination (`dumb=10` AND `gap=10` together) doesn't even have data yet -- too rare to have cleared the floor in the current (60-90 minute) collection run, which is itself part of the argument for a bigger rebuild (see next section). **This is explicitly the highest-conviction next direction discussed this session** -- higher potential payoff than either the LMR augmentation above or an EFP patch, but also acknowledged as the riskiest (an irreversible prune, firing deeper in the tree where a wrong call costs an exponentially bigger subtree than either EFP or this round's LMR work ever risked). ## Agreed design requirements for the deep-prune mechanism, before it's built for real 1. **One unified eligibility gate for ALL pruning/reduction mechanisms** (EFP, this round's LMR augmentation, any future LMP, and the deep prune) -- extend the existing `fPruneReduceEligible` checklist (already shared infrastructure from the earlier EFP work) to explicitly cover: no checks, no escaping check, leftover moves only, not a killer (previous ply or this ply), no reason to extend this node, no reason the LAST move by this side was extended (EFP's existing gate has this piece already), `!fThreat`, not a capture unless losing, `iAlpha < +NMATE`. Rationale, per Scott directly: these preconditions define "unremarkable move," which is also exactly the population the table itself was trained against -- using a different gate at consult time than at collection time is the same population-mismatch mistake the EFP work already paid for once (see "the population the data is measured over" above). 2. **A rebuilt, larger, deeper table before trusting "confirmed zero" for anything irreversible.** Current table: 60-90 minutes, 16 shards, `sd 16`. Planned rebuild: overnight or longer (8-12+ hours/shard), deeper (`sd 18-20`+, to populate depth 10-13 with real signal instead of the coarsened overflow bucket), bigger/more varied position pool specifically to get real sample volume into the deep+hopeless+late cells that are currently unpopulated (e.g. `dumb=10`+`gap=10` combined). Blocked for now: this box doesn't have spare cores to run a 16-shard `--cpus 1` collection run alongside the ongoing 12-worker self-play match (each `match_play.py` worker spawns 2 single-threaded engine processes, so 12 workers is already ~24 concurrent processes) -- waiting for the self-play LLR to resolve (cross either SPRT bound) before starting this. 3. **"Certain" means literally `0.0%` observed, at real sample volume** -- not a low-but-nonzero tier, per Scott's explicit framing. A follow-on, not-yet-designed refinement: how much sample volume is actually enough given how many times per game this gate would fire (see the earlier EFP section's "how much statistical confidence is actually needed" analysis -- same rule-of-three reasoning applies here and needs re-deriving for whatever application rate the deep-prune gate turns out to have). 4. **A prune-specific shadow-verification harness, built BEFORE any real pruning, same discipline as `JOINT_EXTRA_VERIFY` above but answering a different question**: not "would a deeper search of this reduced move have failed high" but "would searching this move to real depth at all have failed high, given we were about to skip it entirely." Must apply the (now understood) `NEUTRALIZE_NULL_ BAILOUT` fix from day one, and must dump FEN + full details to a log on every actual miss found, for direct manual investigation (matching how the two genuine-miss classes above were understood) -- agreed explicitly: any wrong guess gets looked at and reasoned about, not just counted. 5. **A dynamic, per-move-identity component is needed and is currently MISSING from this round's design** -- `GetLMRReduction`'s own existing pattern (require the specific move's own `GetMoveFailHighPercentage` history to independently agree, not just the static table cell) was flagged mid-session as something the deep-prune gate must also have: require both the static table cell to read genuine evidence-backed zero AND this exact move's own accumulated in-search history to show zero fail-highs over some minimum tries, before ever pruning for real. Countermove-conditioned dynamic veto was raised as a further, explicitly UNTESTED idea for later, not part of the first attempt. ## Self-play status as of this write-up (2026-09-29, ongoing) First live test of the LMR-only augmentation (EFP left untouched, including its known `ComputeMoveScore` bug) against `head_reference`, `match_play.py`, `--st 4`, 12 workers, SPRT (`elo0=0, elo1=5`). **Pruning/reduction heuristic settings live for this entire match** (`search.c` constants, unchanged for the whole run -- see "the mechanism: strictly additive over `GetLMRReduction`" and "tier thresholds" sections above for how these were originally derived): ``` JOINT_EXTRA_TIER1_THOUSANDTHS = 72 // ~p75 of LMR-slice FH%: below this, +1 ply JOINT_EXTRA_TIER2_THOUSANDTHS = 5 // ~p25: below this, +2 plies JOINT_EXTRA_TIER3_THOUSANDTHS = 1 // ~p5: below this, +3 plies JOINT_EXTRA_MAX_PLIES = 3 JOINT_EXTRA_MIN_REMAINING_DEPTH = ONE_PLY // most aggressive floor, post round-trip (see above) ``` i.e.: HEAD's own baseline `GetLMRReduction` (`-ONE_PLY`) is left untouched; on top of it, a gate-1-eligible move whose 4D joint-table predicted FH% falls under 0.072% gets +1 extra ply, under 0.005% gets +2, under 0.001% gets +3 (capped by `JOINT_EXTRA_MAX_PLIES` and by the `JOINT_EXTRA_MIN_REMAINING_DEPTH` floor so combined reduction never drops remaining depth below one ply). EFP was deliberately left untouched for this round (see below). | games | score | llr | avg depth (cand vs head) | ebf (cand vs head) | nps (cand vs head) | |---|---|---|---|---|---| | 110 | 0.532 | +0.16 | 12.90 > 12.77 | 3.561 < 3.664 | 1,625,612 > 1,570,311 | | 290 | 0.533 | +0.45 | 13.21 > 12.80 | 3.545 < 3.651 | 1,618,609 > 1,576,194 | | 730 | 0.510 | +0.28 | 13.30 > 12.99 | 3.546 < 3.622 | 1,621,970 > 1,589,133 | | 2212 (final, killed by hand) | 0.502 | -0.19 | 13.28 > 12.99 | 3.543 < 3.615 | 1,604,237 > 1,577,331 | Trending positive early (110-730 games) but the match was killed by hand at 2212 games once it settled back to dead-even: `SPRT=INCONCLUSIVE LLR=-0.189 BOUNDS=[-2.944,2.944] ELO=+1.4+/-14.5 LOWER95=0.4812`. At this sample size SPRT hadn't crossed only because `elo1=5` sits inside the ~12 Elo-wide 95% confidence interval the match had already converged to -- another 75 hours (the printed ETA) would have bought a label, not new information, so continuing to let it run was judged not worth it. **Caveat added 2026-10-01**: this entire match ran under the pre-unification gate (`uLegalMoves > 5`, the population the p75/p25/p5 tier thresholds above were themselves derived against). EFP/LMR were unified onto `fPruneReduceEligible` (`uLegalMoves > 1`) afterward, which widens the live population these thresholds now classify without re-deriving or re-testing them. So this "even, no strength gain" verdict is evidence about the *original* p75/p25/p5 thresholds against the *narrower* pre-unification population -- it says nothing yet about their behavior (or the current hand-adjusted 150/5/1 thresholds, see `search.c`'s `JOINT_EXTRA_TIER*_THOUSANDTHS`) against the current, wider `uLegalMoves > 1` population. Needs its own self-play run before treating this verdict as settled for the current code. **Verdict, Scott's read, 2026-09-30**: the mechanism *works* in the narrow sense that it delivers real, consistent extra depth/speed (+0.3 ply, ~+1.7% nps, leaner `ebf`) for no loss in measured playing strength -- but that 0.3 ply isn't enough on its own to show up as a score edge; the extra depth has to be large enough to let the engine see things HEAD doesn't. Two candidate next steps, not yet decided between: 1. **Find more *sound* pruning/reduction opportunities** via the planned larger/deeper table rebuild (below) -- current table has real low-traffic cells (sparse or unpopulated deep+hopeless+late combinations) that a bigger collection run should fill in, unlocking additional reduction without cost. 2. **Tune the existing curve to be a bit more aggressive as a starting point.** Scott's specific framing: the current tier thresholds (`p75`/`p25`/`p5` -> `+0`/`+1`/`+2`/`+3` plies, see above) aren't hurting playing strength but also aren't buying enough depth/speed to realize a gain -- worth pushing slightly harder on the SAME curve shape before waiting on a full table rebuild. Leaning toward trying (2) as a cheap, fast experiment first (doesn't need the rebuild or its core budget) while (1)'s rebuild runs in parallel once the match's ~24 `match_play.py` engine processes are confirmed dead. **The ~3-4% nps and ~0.4-ply depth advantage are almost certainly the SAME underlying effect measured two ways, not two independent confirmations.** This is a fixed-wall-clock-per-move match (`--st 4`), so depth reached is a direct function of average cost per node. If extra reduction shifts the tree's node mix toward proportionally more (cheap) qsearch nodes and fewer (expensive) full-width nodes -- which the lower `ebf` for candidate at both checkpoints is directly consistent with -- that simultaneously raises the blended nodes/second figure AND lets iterative deepening reach one more ply in the same time budget. Confirmed as a real, structural effect (not process scheduling noise): stable over an hour-plus of real gameplay across ~24 concurrent single-threaded engine processes on a shared, contended box, not a short or single-sample reading. ## Build hygiene note (2026-09-29) Before this self-play run started, did a full audit + clean rebuild to confirm the shipped binary carries none of the investigation-only instrumentation: `JOINT_EXTRA_VERIFY`, `SILENT_MISS_TRACE`, `NEUTRALIZE_NULL_BAILOUT`, `DISABLE_EFP`, `DEPTH_ONLY_EXTRA_REDUCTION` are all `ifdef`-gated behind their own opt-in `GNUmakefile` flags, none part of the standard `GENETIC=1 PERF_COUNTERS=1 MP=1 SIXTYFOUR=1` profile; confirmed absent from a clean build's own `--command ""`-printed `Make profile used:` line. `QSearchFromCheckNo StandPat`'s three rounds of live manual experimentation (see above) were confirmed reverted to their original condition set via `git diff`. No stray debugger hooks (`BREAKPOINT`) remained in `search.c`. Modified files vs. HEAD for this whole effort: `GNUmakefile`, `chess.h`, `data.c`, `root.c`, `script.c`, `search.c`. ## Status / open questions as of this write-up - LMR augmentation: live, in self-play, inconclusive but trending positive (llr +0.45 at 290 games). No further code changes planned until this resolves one way or the other -- can't run the planned table rebuild concurrently with self-play on this box's core count. - EFP: left deliberately untouched (including the confirmed-dead `ComputeMoveScore` check) for this round, on purpose, to keep the LMR-only test interpretable. Two candidate next steps discussed and NOT yet decided between: (a) a conservative "veto EFP's prune when the table thinks this move is surprisingly dangerous" patch (small, reversible, directly fixes the confirmed dead-check bug), or (b) skip straight to the deep-prune mechanism and let IT eventually subsume/retire EFP rather than patching EFP incrementally. Leaning toward (b) as the real long-term direction, but agreed the incremental-experiment discipline that's served this whole project well should still apply -- not committing to skipping (a) outright. - Deep-prune mechanism: designed in discussion (this section), not yet implemented. Blocked on: (1) the overnight/longer table rebuild (itself blocked on self-play finishing, core-count constrained), (2) building the prune-specific shadow-verification harness, (3) the unified-gate refactor, (4) deciding on the dynamic per-move-identity component's exact shape. - Open, explicitly undecided strategic question: does the table eventually fully replace EFP (one mechanism, deep+shallow both handled by table-driven decisions), or do EFP (shallow, high-volume, patched) and a new deep-prune mechanism (deep, low-volume, table-driven) coexist as two mechanisms answering the same underlying question from different depths? Deferred until real numbers exist from both the current self-play match and a future deep-prune experiment.