summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-04 08:17:52 -0700
committerScott Gasch <[email protected]>2026-10-04 08:17:52 -0700
commitbbdf1bda8f410df6aa9be0a38e71b7db04982198 (patch)
treee8315247f700ee94875cd6d2eb5dd81aef71395d
parent9b28080f8b3dca4588cb2aaaf748451f45e3c626 (diff)
Dynamic move-ordering/LMR session: honest FH tracking, decoupled admission/magnitude, bugfixes, tooling
Lands the 2026-09-28..10-03 move-ordering investigation (full history in lmr_testing/*.md). Summary of what ships: LMR/move-ordering redesign (searchsup.c, dynamic.c, data.c, chess.h): - GetLMRReduction's admission decision (do we reduce at all) is now decoupled from magnitude (how much): LMR_STATIC_ADMIT_THOUSANDTHS=500 gates admission via the 4D joint fail-high table (data.c) plus a countermove-match veto; magnitude is a flat Crafty/Ethereal-shaped log(depth)*log(moves) table (g_iLMRQuietReduction), which measured as good or better than the old per-tier joint-table payout while being structurally immune to the per-bucket miscalibration that sank the tiered approach. g_FailHighs honesty fix (real 0/0 init, not a fake prior) underlies the dynamic-danger signal. - Retired NumLeftoverMovesToSelect's depth-indexed selection budget -- evidence showed the leftover pool has real findable signal a bailout was discarding. - ShouldLatePrune (overt late-move pruning) implemented but disabled at its call site -- measured net-negative at the tested threshold; kept for reference and as a base for a future hang-bucket-9-targeted retry. - PRUNE_VERIFY, PRUNE_LIVE, DISABLE_LMR: opt-in diagnostic build flags (GNUmakefile), off in the standard profile. Bug fixes found and fixed while exercising this work: - JOINT_EXTRA_TIER_COUNT 4->6 (chess.h): fixed an out-of-bounds counter write reachable at remaining depth >= 14 (unreachable at sd10, routine in real search/self-play). - Node-type instrumentation (search.c, root.c, searchsup.c): fixed four compounding defects that produced impossible percentages (e.g. 106.2% of PV nodes) -- wrong denominator, qsearch misclassified as main search, untyped early-exit nodes, and fail-low tests using the in-place-raised alpha instead of the initial one. - FH-rate unit unification (search.c, searchsup.c, dynamic.c, chess.h): GetMoveFailHigh* returned per-mille while g_uJointFHThousandths is thousandths-of-a-percent, two different units sharing a "_THOUSANDTHS" suffix. Unified to one scale, renamed to "_PCT_THOUSANDTHS". RateBucket's band edges fixed to match. - QSearch option B (search.c): waives the opponent-stand-pat requirement at NODE_PV only (sound there since the exact score propagates; not sound at null-window nodes). iExtensionAmount unit fix (was 0/2 into a ONE_PLY-scaled field). pi->iEval now published when Eval() runs. - eval_tune/match_play.py local_ebf: was reporting 0.000 in every match since 2026-09-18 (regex only matched the trailing-stats parser, not the live move-scan loop; engines were also spawned with "nopost" so root.c never emitted the lines at all). True per-ply EBF is ~2.0, not the ~3.9 every prior match report implied. - eval_tune/test_vs_head.sh: positional-arg shift ordering bug caused every no-extra-args run to silently rebuild ./typhoon with a non-standard profile and, separately, could feed a flag's value in as the candidate binary path. - PERF_COUNTERS cleanup (eval.c, search.c): moved 3 leaked rdtsc sites to the existing EVAL_TIME guard, deleted two concluded one-off diagnostics (u64HeadStyle*, u64FHJointHist/RateBucket/AttemptsBucket) and their report plumbing, and re-gated a per-move hang-material estimate behind FH_TABLE_COLLECTION instead of running it unconditionally. Infrastructure: - match_play.py time-odds support (--odds/--st-head/--st-candidate), validated against the engine seat rather than color. - Wilson-upper-bound re-bake of the joint table (data.c): cells store a confidence-aware bound instead of a raw point estimate, so a consumer can threshold on the value alone; monotone, so every consumer became strictly more conservative. Verified behavior-neutral for LMR. Known-open items (not resolved here, see lmr_testing/GET_LMR_REDUCTION.md section 10 and 2026-10-03_UNCOMMITTED_TRIAGE.md): the self-play gate on this candidate is underpowered (+5 Elo, 95% CI roughly [-6, +17]); the curated-suite solve-count deltas used earlier in the investigation turned out to be noise-dominated cancellation when diffed per position, not the clean signal they first appeared to be. Co-Authored-By: Claude Sonnet 5 <[email protected]>
-rw-r--r--src/GNUmakefile104
-rw-r--r--src/TODO6
-rwxr-xr-xsrc/chess.h347
-rw-r--r--src/data.c1777
-rw-r--r--src/dynamic.c115
-rw-r--r--src/eval.c16
-rwxr-xr-xsrc/eval_tune/match_play.py224
-rwxr-xr-xsrc/eval_tune/test_vs_head.sh73
-rw-r--r--src/lmr_testing/2026-09-28_four_axis_joint_table_plan.md238
-rw-r--r--src/lmr_testing/2026-10-03_LMR_REDESIGN.md172
-rw-r--r--src/lmr_testing/2026-10-03_UNCOMMITTED_TRIAGE.md137
-rw-r--r--src/lmr_testing/GET_LMR_REDUCTION.md1971
-rw-r--r--src/lmr_testing/SPEC.md1543
-rw-r--r--src/root.c166
-rw-r--r--src/script.c528
-rw-r--r--src/search.c1052
-rw-r--r--src/searchsup.c871
-rw-r--r--src/split.c23
-rw-r--r--src/unix.c93
-rw-r--r--tests/ECM.md3
20 files changed, 9264 insertions, 195 deletions
diff --git a/src/GNUmakefile b/src/GNUmakefile
index 1784551..b9b6d16 100644
--- a/src/GNUmakefile
+++ b/src/GNUmakefile
@@ -172,6 +172,110 @@ ifdef CALIBRATE_QSEARCH_FUTILITY
PROFILE += -DCALIBRATE_QSEARCH_FUTILITY
endif
endif # EVERYTHING
+# 2026-09-28: full-coverage (non-sampled) throwaway shadow re-search,
+# purely to measure the "silent miss" failure mode a reduced move's own
+# real re-search hook can't see: the extra-reduced score is < beta (no
+# real re-search triggered) but removing just the EXTRA plies (i.e. the
+# depth HEAD's own baseline -ONE_PLY reduction would have used) finds
+# a fail-high. Every extra-reduced move gets this check within a build
+# with this flag on -- doubles search cost for that subset, so
+# calibration-only, never part of a normal or self-play build. Opt in
+# explicitly, same as the other CALIBRATE_*/DUMP_* flags above.
+ifdef JOINT_EXTRA_VERIFY
+PROFILE += -DJOINT_EXTRA_VERIFY
+endif
+# 2026-09-28: audit-only EFP disable, same technique this project used
+# during the joint-table collection run's self-censoring fix (force the
+# prune decision FALSE, keep computing/logging everything around it) --
+# isolates the joint-table-driven LMR's own behavior from EFP's
+# single-axis (gap/down only) margin-based pruning, which the
+# JOINT_EXTRA_VERIFY silent-miss data implicates as the dominant source
+# of the measured instability, not LMR's own reduction depth. Opt-in,
+# calibration-only.
+ifdef DISABLE_EFP
+PROFILE += -DDISABLE_EFP
+endif
+# 2026-09-28: Trace() the FEN/move/scores whenever JOINT_EXTRA_VERIFY's
+# shadow search catches a silent miss -- for looking at real, concrete
+# failing cases by hand (the methodology that actually cracked the
+# hanging-material bug earlier this project, rather than continuing to
+# guess which other search mechanism might explain the EFP-tier-window
+# silent-miss spike). Requires JOINT_EXTRA_VERIFY. Calibration-only.
+ifdef SILENT_MISS_TRACE
+PROFILE += -DSILENT_MISS_TRACE
+endif
+# 2026-09-29: alternate, maximally-simple extra-reduction policy for A/B
+# comparison against the axis-gated joint-table version -- skips the
+# dumb/gap/late table lookup entirely and just asks "is there enough
+# pre-reduction depth headroom to spare (>= 6 plies)?" If so, apply the
+# full extra reduction (R=3) uniformly, for ANY depth >= 6 (not just
+# exactly 6 -- 7, 8, 9, ... all get the same treatment). Tests whether
+# the axis-based table is pulling any real weight once a depth floor is
+# already in place, or whether depth alone does just as well.
+ifdef DEPTH_ONLY_EXTRA_REDUCTION
+PROFILE += -DDEPTH_ONLY_EXTRA_REDUCTION
+endif
+# 2026-09-29: neutralize searchsup.c's null-move "history-reduced
+# bailout" branch (Tord Romstad idea) for our own shadow-verification
+# Search() call specifically, by temporarily zeroing our own
+# pi->iExtensionAmount around it -- see search.c's NEUTRALIZE_NULL_
+# BAILOUT comment. Correct in the real search path, that branch
+# fabricates a "please re-search me" placeholder score that our
+# bespoke, wrapper-less shadow call can't interpret and misreads as a
+# genuine fail-high. Build once WITHOUT this flag to measure how much
+# of our silent-miss data is this artifact (see
+# u64JointExtraVerifyNullBailoutSignature), once WITH it for the
+# corrected rate.
+# 2026-10-03: PRUNE_VERIFY -- shadow verification for the DEEP PRUNE gate
+# (ShouldLatePrune), the question JOINT_EXTRA_VERIFY structurally cannot
+# answer: "this move was about to be skipped ENTIRELY -- would searching
+# it have failed high?" AUDIT-ONLY by construction: the gate runs, its
+# verdict is recorded, and the move is then searched anyway, so the
+# measurement needs no bespoke shadow Search() call and has no
+# null-move-bailout exposure. Costs counter increments plus one extra
+# ShouldLatePrune evaluation per eligible move; never part of a normal
+# build. Pair with SILENT_MISS_TRACE to get a PRUNEMISS line (FEN, move,
+# alpha/beta, predicted FH, margin) for every miss found, for direct
+# manual investigation -- SPEC.md's agreed design requirement that any
+# wrong guess gets looked at and reasoned about, not just counted.
+# Requires PERF_COUNTERS.
+ifdef PRUNE_VERIFY
+PROFILE += -DPRUNE_VERIFY
+endif
+
+# 2026-10-03: PRUNE_LIVE=<K> -- actually prune any gate-1-eligible move
+# whose 4D-table predicted FH% (Wilson upper bound, see data.c) is <= K
+# thousandths of a percent. Measurement vehicle for "what does pruning
+# N% of moves do to the branching factor", used with the C/r sweep in
+# /tmp/typhoon/cr_measure. NOT a shipping mechanism: it has none of the
+# agreed safety furniture (no dynamic per-move-identity veto, no
+# ShouldLatePrune move-count floor, no shadow verification) -- those are
+# what a real deep-prune gate needs. Measure with it, do not play with
+# it.
+# 2026-10-03: DISABLE_LMR -- GetLMRReduction returns 0 unconditionally,
+# so no move is ever reduced. Exists for ONE experiment: what is LMR
+# actually worth in this engine? The time-odds calibration established
+# that TIME-bought depth is ~95 Elo/ply here, but nothing has ever
+# measured whether REDUCTION-bought depth converts at that rate. Two
+# hypotheses fit "deeper but no stronger": (1) missed refutations, i.e.
+# the reduction is too aggressive and its errors cancel the gain --
+# remedy is to constrain it; or (2) nominal depth under reduction is a
+# debased currency that never carried a time-ply's worth of information
+# -- in which case constraining it cannot help, since less reduction
+# buys less depth at the same bad rate. Removing LMR entirely and
+# pricing the loss against the 95 Elo/ply yardstick separates them.
+# Measurement only; never a shipping configuration.
+ifdef DISABLE_LMR
+PROFILE += -DDISABLE_LMR
+endif
+
+ifdef PRUNE_LIVE
+PROFILE += -DPRUNE_LIVE=$(PRUNE_LIVE)
+endif
+
+ifdef NEUTRALIZE_NULL_BAILOUT
+PROFILE += -DNEUTRALIZE_NULL_BAILOUT
+endif
# Short commit hash (plus a -dirty suffix if the working tree has
# uncommitted changes) baked into the binary so a --logfile trace can
diff --git a/src/TODO b/src/TODO
index 25b7214..aa53d65 100644
--- a/src/TODO
+++ b/src/TODO
@@ -177,3 +177,9 @@ generating the full list.
structure and using it to extend searches. Just like we already do
with cTrapped and cEnprise -- signals from eval.c to affect search.
+2026-10-01: It seems like a Q vs RR in a pawnful endgame... Q dominates.
+In general, we should work on encouraging the engine to maintain a good
+pawn structure -- read more of that pawn power in chess book. Work on
+the pawn eval specifically. Maybe do eval learning on only the pawn
+hashed eval terms.
+
diff --git a/src/chess.h b/src/chess.h
index ff218a9..f0060e3 100755
--- a/src/chess.h
+++ b/src/chess.h
@@ -941,6 +941,128 @@ MOVE_STACK;
// size the evidence-bucket counter arrays.
#define CM_EVIDENCE_BUCKETS (7)
+// 2026-09-28: EstimateHangingMaterialCp calibration (search.c) -- does
+// "how much material does this move look like it hangs" actually
+// predict FH%? Bucket 0 == not dumb at all; buckets 1..9 are 100cp-
+// wide bins covering 1-900cp; bucket 10 is the 901cp+ overflow (a
+// hung queen tops out around 975). Deliberately even-width, NOT
+// piece-value-shaped -- the previous version of this signal bucketed
+// by which specific piece-pair produced the cp value (e.g. "pawn
+// takes rook" and "rook attacks queen" both landing in one bucket
+// purely because 400 and 475 are both <500), silently pooling
+// unrelated motifs. Even-width bins can't make that mistake; if two
+// different motifs really do carry different risk, they'll only end
+// up in the same bin here when their real cp magnitudes are actually
+// close together.
+#define HANG_CP_BUCKET_WIDTH (100)
+#define HANG_CP_BUCKETS (11)
+
+// 2026-09-28: the "down axis" -- does (iAlpha - iEval), the node's
+// static-eval deficit below alpha, predict FH%? Same even-width
+// bucket shape as HANG_CP_BUCKETS, same reasoning. Bucket 0 == gap
+// <= 0 (this node's own static eval already meets or beats alpha --
+// a fundamentally different, and presumably much safer, regime from
+// "we're statically behind and need this leftover move to catch up",
+// so it gets its own bucket exactly like HANG's "not dumb" does).
+// Buckets 1..9 are 100cp-wide bins covering a positive gap of
+// 1-900cp; bucket 10 is the 901cp+ overflow.
+#define GAP_CP_BUCKET_WIDTH (100)
+#define GAP_CP_BUCKETS (11)
+
+// 2026-09-28: the "late axis" -- this move's own 1-based ordinal
+// position in the list of moves actually tried at this node
+// (uLegalMoves, right after MakeMove succeeds). Direct, unpaired
+// buckets -- move number 1..(MOVE_NUM_BUCKETS-2) each get their own
+// exact bucket, MOVE_NUM_BUCKETS-1 is the "at least that many"
+// overflow. No pairing/fraction-of-list-exhausted cleverness here;
+// the volume from a real collection run supports exact per-move-
+// number resolution directly. Index 0 is unused (move numbers start
+// at 1) -- wastes one slot, keeps uLegalMoves usable as the array
+// index with no off-by-one arithmetic anywhere it's consumed.
+#define MOVE_NUM_BUCKETS (33)
+
+// 2026-09-28: the "depth axis" -- remaining search depth (uDepth /
+// ONE_PLY, whole plies) at THIS node, before this move's own
+// extension/reduction is applied -- same convention as the old EFP
+// code's uCandDepth. Direct, unpaired buckets like MOVE_NUM_BUCKETS;
+// unlike move number, depth legitimately starts at 0 (a heavily-
+// reduced move can arrive at remaining depth 0), so index 0 IS used
+// here, not wasted.
+#define SEARCH_DEPTH_BUCKETS (25)
+
+// 2026-09-28: how many extra whole plies the graduated LMR mechanism
+// stacked on top of GetLMRReduction's own baseline -ONE_PLY. Must stay
+// in lockstep with searchsup.c's JOINT_EXTRA_MAX_PLIES (private to that
+// file, so not literally shared via a #define): this must be
+// JOINT_EXTRA_MAX_PLIES + 1, the +1 being the "tier 0 == no extra" slot.
+//
+// 2026-10-03: was (4) while JOINT_EXTRA_MAX_PLIES had already been
+// raised 3 -> 5 (searchsup.c, the section 8 "Attempt B" payout bump).
+// The lockstep this comment demands was silently broken, so
+// GetLMRReduction's OUT *puExtraTier could hand back 4 or 5 and both
+// consumers -- search.c's INC(u64JointExtraTries[uExtraTier]) and
+// INC(u64JointExtraResearch[uThisMoveJointExtraTier]) -- indexed these
+// 4-element arrays out of bounds, landing on u64JointExtraResearch[0]
+// and on u64LMPPruned respectively. Counters only (nothing reads them
+// to make a search decision, and root.c's memcpys are sizeof-bounded),
+// but it corrupted tier 0's re-search rate -- the calibration signal
+// those counters exist to provide -- and could make u64LMPPruned read
+// nonzero while LMP is disabled at its call site. Invisible to every
+// sd10 suite run (tier 4 needs remaining depth >= ~14, unreachable
+// there) and only live in searches deep enough to reach it, i.e. real
+// self-play. Raised to 6 rather than clamping MAX_PLIES back to 3,
+// which would have been a search-behavior change rather than a fix.
+#define JOINT_EXTRA_TIER_COUNT (6)
+
+// 2026-10-03: PRUNE_VERIFY (GNUmakefile) -- shadow verification for the
+// DEEP PRUNE gate (ShouldLatePrune), answering a question the LMR-side
+// JOINT_EXTRA_VERIFY cannot: "this move was about to be skipped
+// ENTIRELY -- would searching it have failed high?" A reduction leaves a
+// score behind that can be sanity-checked; a prune leaves nothing, so
+// there is no signal at all unless we deliberately decline to prune and
+// watch what the move does.
+//
+// Deliberately AUDIT-ONLY: the gate is evaluated, the verdict is
+// recorded, and then the move is searched normally anyway. That means
+// NO bespoke shadow Search() call is needed -- the move's own ordinary
+// search answers "did it fail high", with the real move loop's
+// re-search wrapper intact. That sidesteps the null-move-bailout
+// fabrication (searchsup.c's Tord Romstad branch) that made
+// JOINT_EXTRA_VERIFY's first numbers 80-99.8% measurement artifact and
+// needed NEUTRALIZE_NULL_BAILOUT to fix. Nothing here fabricates a
+// score, so nothing here can misread one.
+//
+// Known, accepted under-detection: an audited move is still subject to
+// LMR, so a fail-high that only a FULL-depth search would have found
+// can be missed. The existing re-search-on-fail-high path catches the
+// common case, and JOINT_EXTRA_VERIFY measured the residual silent-miss
+// rate at ~0.00056% once measured correctly -- negligible against the
+// prune miss rates this is built to detect.
+//
+// Bands are the table's own predicted-FH value (thousandths of a
+// percent, post-Wilson-re-bake so the value already encodes the cell's
+// sample confidence -- see data.c's header). Indexed by band because
+// the decision this validates is literally "prune if predicted <= K",
+// so band-resolution answers "is the rule sound at threshold K" for
+// every candidate K at once. Depth is carried alongside because the
+// COST of a wrong prune scales with remaining depth even where the rate
+// does not.
+// Shipped depth dimension of g_uJointFHThousandths (data.c declares it
+// as [12][11][11][15]; the 12 is _ShipDepthBucket's range).
+#define SHIP_DEPTH_BUCKETS (12)
+#define PRUNE_VERIFY_BANDS (8)
+// band edges, predicted FH in thousandths of a percent:
+// 0: ==0 1: 1 2: 2 3: 3-5 4: 6-10 5: 11-50 6: 51-500 7: >500
+
+
+// 2026-09-28: JOINT_EXTRA_VERIFY silent-miss calibration, bucketed by
+// how much remaining depth the FULLY-reduced (base + extra) search
+// actually got, in whole plies: 0/1/2/3/4+. Tests the "reducing an
+// already-shallow search into a stunted one is what's actually driving
+// the silent-miss rate, not the axis combination itself" hypothesis --
+// see search.c's JOINT_EXTRA_MIN_REMAINING_DEPTH comment.
+#define POST_REDUCE_DEPTH_BUCKETS (5)
+
// TEST_NULL calibration harness (search.c's null-move precision/recall/
// EV measurement -- see _NullCalibBucketIndex's comment in search.c for
// the full design). Revised 2026-09-24: dropped fThreat/fSkipNull/
@@ -1032,6 +1154,43 @@ typedef struct _COUNTERS
// "surprises" (e.g. an immediate hash-table cutoff) are
// legitimate and expected, not actual instability, so these
// counts are a rough signal, not a bug indicator by themselves.
+ // 2026-10-03: denominators for the "node-type surprises" line
+ // (root.c). Incremented in CommonSearchInit right where
+ // eNodeType is assigned, so they count EVERY node that got a
+ // type -- which includes QSEARCH nodes, because
+ // CommonSearchInit is the single funnel both Search() and
+ // QSearch() pass through. That matters for reading the
+ // percentages: see root.c's comment on why an "ALL node failed
+ // high" rate near 100% is routine rather than alarming.
+ UINT64 u64NodeTypeCount[3];
+ // 2026-10-03: the qsearch half of the same accounting. QSearch's
+ // copy of the surprise counters was removed so the full-width
+ // rates would stop being dominated by stand-pat cutoffs -- but
+ // that broke a real cross-check, because the PVS identity it
+ // should satisfy spans BOTH populations:
+ //
+ // a CUT node failing low returns score <= alpha_C, so its
+ // parent P (an ALL node) sees -score_C >= beta_P and fails
+ // HIGH. P cannot have had an earlier child fail low -- it
+ // would have cut off and never reached C. So #(CUT fails low)
+ // and #(ALL fails high) should run ~1:1.
+ //
+ // With qsearch dropped the identity read 3.6:1 (25,097 ALL-FH
+ // vs 7,009 CUT-FL), because an ALL node at remaining depth 1
+ // takes its fail-high from a QSEARCH child failing low, and
+ // that child was counted nowhere. The same artifact shows in
+ // the raw counts: 36,306 ALL vs 57,813 CUT nodes -- fewer ALL
+ // than CUT, impossible when every ALL node is a CUT node's
+ // child.
+ //
+ // So count qsearch separately rather than not at all. Ignore
+ // these for the full-width rates; add them in to check the
+ // identity. Residual imbalance after that is real signal:
+ // recognizer/EGTB fail-highs and null-move cutoffs resolve a
+ // node with no child searched at all. NODE_TYPE itself is
+ // untouched -- these are counter slots, not a new node type.
+ UINT64 u64QNodeTypeCount[3];
+ UINT64 u64QNodeSurprise[3];
UINT64 u64PVNodeSurprised; // PV node failed high or low
UINT64 u64AllNodeFailedHigh; // ALL node (expected fail-low) failed high
UINT64 u64CutNodeFailedLow; // CUT node (expected fail-high) failed low
@@ -1045,6 +1204,112 @@ typedef struct _COUNTERS
UINT64 u64LeftoverFH; // ...failed high
UINT64 u64CMEvidenceTries[CM_EVIDENCE_BUCKETS];
UINT64 u64CMEvidenceFH[CM_EVIDENCE_BUCKETS];
+ UINT64 u64HangEstimateTries[HANG_CP_BUCKETS];
+ UINT64 u64HangEstimateFH[HANG_CP_BUCKETS];
+ // 2026-09-28: same population as u64LeftoverTries/FH above,
+ // but further restricted by fPruneReduceEligible (search.c) --
+ // the real comparison baseline for the hang-estimate buckets,
+ // which are gated the same way. u64LeftoverTries/FH themselves
+ // stay broad (fIsLeftoverMove only) since they're used
+ // elsewhere with that existing meaning.
+ UINT64 u64EligibleLeftoverTries;
+ UINT64 u64EligibleLeftoverFH;
+ UINT64 u64GapEstimateTries[GAP_CP_BUCKETS];
+ UINT64 u64GapEstimateFH[GAP_CP_BUCKETS];
+ UINT64 u64MoveNumEstimateTries[MOVE_NUM_BUCKETS];
+ UINT64 u64MoveNumEstimateFH[MOVE_NUM_BUCKETS];
+ UINT64 u64DepthEstimateTries[SEARCH_DEPTH_BUCKETS];
+ UINT64 u64DepthEstimateFH[SEARCH_DEPTH_BUCKETS];
+ // 2026-09-28: joint 4D table, same fPruneReduceEligible population,
+ // marginal (uncoarsened) resolution -- coarsened to the shipped
+ // [12][11][11][15] shape at bake time, not at collection time, so
+ // a different final bucket shape never requires re-collecting.
+ UINT64 u64JointTries[SEARCH_DEPTH_BUCKETS][HANG_CP_BUCKETS][GAP_CP_BUCKETS][MOVE_NUM_BUCKETS];
+ UINT64 u64JointFH[SEARCH_DEPTH_BUCKETS][HANG_CP_BUCKETS][GAP_CP_BUCKETS][MOVE_NUM_BUCKETS];
+ // 2026-09-28: phase 2 -- joint-table-driven graduated LMR
+ // (search.c). Index 0 == GetLMRReduction's baseline only (no
+ // extra plies added), 1..3 == that many extra plies the table
+ // added. Tries = how many moves were assigned each tier;
+ // Research = how many of those triggered the existing
+ // re-search-on-fail-high path (search.c:~1739) -- the
+ // calibration signal for whether the table's ordering survives
+ // actual deployment, not just offline collection.
+ UINT64 u64JointExtraTries[JOINT_EXTRA_TIER_COUNT];
+ UINT64 u64JointExtraResearch[JOINT_EXTRA_TIER_COUNT];
+ // 2026-10-02: late-move pruning (ShouldLatePrune, searchsup.c)
+ // -- overt skip, not reduction, for the safest tail of the
+ // leftover pool near the leaves. Counts every move this was
+ // applied to.
+ UINT64 u64LMPPruned;
+#ifdef PRUNE_VERIFY
+ // [predicted-FH band][shipped depth bucket] -- see
+ // PRUNE_VERIFY_BANDS above. Tries = moves the deep-prune gate
+ // said to skip; FailHigh = how many of those then actually
+ // raised beta when searched anyway (a silent miss the gate
+ // would have caused). Margin = how far past beta it cleared,
+ // summed, so narrow-window boundary noise (JOINT_EXTRA_VERIFY's
+ // documented false-positive class: a score clearing a tight
+ // inherited PVS window proves only that, not that the move is
+ // good) can be separated from real misses post-hoc instead of
+ // hand-traced one at a time.
+ UINT64 u64PruneVerifyTries[PRUNE_VERIFY_BANDS][SHIP_DEPTH_BUCKETS];
+ UINT64 u64PruneVerifyFailHigh[PRUNE_VERIFY_BANDS][SHIP_DEPTH_BUCKETS];
+ UINT64 u64PruneVerifyMarginSum[PRUNE_VERIFY_BANDS][SHIP_DEPTH_BUCKETS];
+ // Volume-weighted sum of the PREDICTED rate over exactly the
+ // flagged population. The calibration check is measured-vs-this,
+ // NOT measured-vs-threshold: a threshold is a ceiling on what
+ // gets flagged, not the typical value of what actually does, and
+ // SPEC.md records getting that wrong once already.
+ UINT64 u64PruneVerifyPredSum[PRUNE_VERIFY_BANDS][SHIP_DEPTH_BUCKETS];
+ // Cost/benefit cross-tab for a DYNAMIC veto layered on the
+ // static gate, prunable bands only (predicted <= 10). Indexed
+ // [attempts band][own-FH-rate band] per searchsup.c's
+ // AttemptsBucket/RateBucket. Dyn = every flagged move;
+ // DynMiss = the subset that actually failed high. Together they
+ // give, for any candidate rule "refuse when attempts >= A and
+ // rate > B": how many REAL misses it catches, and how many
+ // sound prunes it costs. The second number is what matters --
+ // the GetMoveFailHighPercentage veto was retired last time
+ // because it vetoed 70-80% of candidates for zero node savings.
+ UINT64 u64PruneVerifyDyn[8][8];
+ UINT64 u64PruneVerifyDynMiss[8][8];
+#endif
+
+ // above -- settles whether elevated per-move-identity rates
+ // (5-100 thousandths) are concentrated at low attempts
+ // (sampling noise around the real 0.400% population average)
+ // or spread across high attempts too (a real, well-evidenced
+ // elevated rate, which the sampling-noise theory would NOT
+ // predict). AttemptsBucket/RateBucket (searchsup.c) define
+ // the 8 bands each axis uses.
+#ifdef JOINT_EXTRA_VERIFY
+ // Full-coverage shadow-search "silent miss" calibration -- see
+ // GNUmakefile's JOINT_EXTRA_VERIFY comment. Tries = extra-
+ // reduced moves whose real score was < beta (no real re-search
+ // fired) and so got a throwaway shadow search at the
+ // extra-plies-removed depth; SilentMiss = how many of those
+ // shadow searches found >= beta anyway -- a fail-high the
+ // extra reduction actually caused us to miss, undetected by
+ // any mechanism a normal (non-JOINT_EXTRA_VERIFY) build has.
+ UINT64 u64JointExtraVerifyTries[JOINT_EXTRA_TIER_COUNT][POST_REDUCE_DEPTH_BUCKETS];
+ UINT64 u64JointExtraVerifySilentMiss[JOINT_EXTRA_TIER_COUNT][POST_REDUCE_DEPTH_BUCKETS];
+ // 2026-09-29: of the confirmed silent misses above, how many
+ // have iShadowScore == iBeta + 1 EXACTLY? That's the
+ // deterministic signature of searchsup.c's null-move
+ // "history-reduced bailout" branch (Tord Romstad idea) firing
+ // at the top of our shadow call -- it fabricates
+ // *piNullScore = iAlpha-1 and returns TRUE instead of actually
+ // re-searching, which negates up to exactly iBeta+1 in our
+ // frame. In REAL (non-shadow) search this correctly triggers
+ // search.c's own re-search-on-fail-high machinery one level
+ // up; our bespoke one-off shadow Search() call has no such
+ // wrapper, so we misread the fabricated "please re-search me"
+ // signal as a genuinely-computed fail-high. Not a perfectly
+ // airtight test (a real score could coincidentally land on
+ // beta+1), but this mechanism produces that exact value every
+ // time it fires, so false positives should be rare.
+ UINT64 u64JointExtraVerifyNullBailoutSignature[JOINT_EXTRA_TIER_COUNT];
+#endif
UINT64 u64NullMoves;
UINT64 u64NullMoveSuccess;
#ifdef TEST_NULL
@@ -1580,7 +1845,6 @@ typedef struct _SEARCHER_THREAD_CONTEXT
MOVE mvNullmoveRefutations[MAX_PLY_PER_SEARCH];
MOVE mvNullmoveQuietRefutations[MAX_PLY_PER_SEARCH];
MOVE mvCounter[COUNTER_MOVE_TABLE_SIZE][2]; // counter-move table
- UCHAR uCounterDepth[COUNTER_MOVE_TABLE_SIZE]; // ply depth slot 0 was set at
// REQUIRES SIG MATCH (sPlyInfo[uPly].u64FullEvalSig, chess.h's
// PLY_INFO) before trusting any of these four -- only written on
// Eval()'s full path (eval.c's _EvalLookForDanger/
@@ -1706,6 +1970,34 @@ typedef struct _GAME_OPTIONS
UINT64 u64LeftoverFH;
UINT64 u64CMEvidenceTries[CM_EVIDENCE_BUCKETS];
UINT64 u64CMEvidenceFH[CM_EVIDENCE_BUCKETS];
+ UINT64 u64HangEstimateTries[HANG_CP_BUCKETS];
+ UINT64 u64HangEstimateFH[HANG_CP_BUCKETS];
+ UINT64 u64EligibleLeftoverTries;
+ UINT64 u64EligibleLeftoverFH;
+ UINT64 u64GapEstimateTries[GAP_CP_BUCKETS];
+ UINT64 u64GapEstimateFH[GAP_CP_BUCKETS];
+ UINT64 u64MoveNumEstimateTries[MOVE_NUM_BUCKETS];
+ UINT64 u64MoveNumEstimateFH[MOVE_NUM_BUCKETS];
+ UINT64 u64DepthEstimateTries[SEARCH_DEPTH_BUCKETS];
+ UINT64 u64DepthEstimateFH[SEARCH_DEPTH_BUCKETS];
+ UINT64 u64JointTries[SEARCH_DEPTH_BUCKETS][HANG_CP_BUCKETS][GAP_CP_BUCKETS][MOVE_NUM_BUCKETS];
+ UINT64 u64JointFH[SEARCH_DEPTH_BUCKETS][HANG_CP_BUCKETS][GAP_CP_BUCKETS][MOVE_NUM_BUCKETS];
+ UINT64 u64JointExtraTries[JOINT_EXTRA_TIER_COUNT];
+ UINT64 u64JointExtraResearch[JOINT_EXTRA_TIER_COUNT];
+ UINT64 u64LMPPruned;
+#ifdef PRUNE_VERIFY
+ UINT64 u64PruneVerifyTries[PRUNE_VERIFY_BANDS][SHIP_DEPTH_BUCKETS];
+ UINT64 u64PruneVerifyFailHigh[PRUNE_VERIFY_BANDS][SHIP_DEPTH_BUCKETS];
+ UINT64 u64PruneVerifyMarginSum[PRUNE_VERIFY_BANDS][SHIP_DEPTH_BUCKETS];
+ UINT64 u64PruneVerifyPredSum[PRUNE_VERIFY_BANDS][SHIP_DEPTH_BUCKETS];
+ UINT64 u64PruneVerifyDyn[8][8];
+ UINT64 u64PruneVerifyDynMiss[8][8];
+#endif
+#ifdef JOINT_EXTRA_VERIFY
+ UINT64 u64JointExtraVerifyTries[JOINT_EXTRA_TIER_COUNT][POST_REDUCE_DEPTH_BUCKETS];
+ UINT64 u64JointExtraVerifySilentMiss[JOINT_EXTRA_TIER_COUNT][POST_REDUCE_DEPTH_BUCKETS];
+ UINT64 u64JointExtraVerifyNullBailoutSignature[JOINT_EXTRA_TIER_COUNT];
+#endif
#ifdef TEST_NULL
UINT64 u64NCAttempts[NC_TOTAL_BUCKETS];
UINT64 u64NCNullFH[NC_TOTAL_BUCKETS];
@@ -2549,8 +2841,6 @@ TestMakeUnmakeMove(void);
#define THIRD_KILLER (0x08000000)
#define FOURTH_KILLER (0x04000000)
#define GOOD_MOVE (0x02000000)
-#define FIRST_COUNTER_MOVE (0x01000000)
-#define SECOND_COUNTER_MOVE (0x00800000)
#define STRIP_OFF_FLAGS (0x007FFFFF)
// Countermove-match tier bonus, placed above GOOD_MOVE (see
@@ -2858,6 +3148,16 @@ extern const FLAG g_BishopRayPositiveDir[4];
extern BITBOARD g_KnightAttacksBB[128];
extern BITBOARD g_KingAttacksBB[128];
+// 2026-09-28: joint 4D FH% table, baked from a 16-shard sd16 collection
+// run (see lmr_testing/2026-09-28_four_axis_joint_table_plan.md and
+// data.c's trailing comment for provenance/coverage). Cell = FH% * 1000
+// (thousandths of a percent), 0xFFFF sentinel = below this run's
+// 500-try sample floor, don't trust yet. [depth][dumb][gap][late],
+// depth outermost for cache locality -- a whole node's move loop shares
+// one depth-slice (11*11*15 = 1,815 cells, ~3.5KB). Not yet consumed by
+// search.c -- phase 2 (LMR replacement) is a separate, later step.
+extern USHORT g_uJointFHThousandths[12][11][11][15];
+
// 2026-09-15: king-ring bitboard split for _EvalKing's linear-SCORE
// rewrite -- front/back mirror EmptyAttackedSquare's old color-dependent
// asymmetry and eval_tune/train_king_safety_model.py's BUCKET_SLOTS
@@ -3376,7 +3676,41 @@ GetLMRReduction(IN SCORE iRoughEval,
IN ULONG uLegalMoves,
IN MOVE mv,
IN ULONG uMoveNum,
- IN INT iExtend);
+ IN INT iExtend,
+ IN BITBOARD bbMinorAttacksSnapshot[2],
+ IN BITBOARD bbRookAttacksSnapshot[2],
+ OUT ULONG *puExtraTier);
+
+FLAG
+ShouldLatePrune(IN SCORE iRoughEval,
+ IN SCORE iAlpha,
+ IN SEARCHER_THREAD_CONTEXT *ctx,
+ IN ULONG uRemainingDepth,
+ IN ULONG uLegalMoves,
+ IN MOVE mv,
+ IN ULONG uMoveNum,
+ IN BITBOARD bbMinorAttacksSnapshot[2],
+ IN BITBOARD bbRookAttacksSnapshot[2],
+ OUT ULONG *puPredictedFH);
+
+SCORE
+EstimateHangingMaterialCp(IN SEARCHER_THREAD_CONTEXT *ctx,
+ IN MOVE mv,
+ IN ULONG uMoveNum,
+ IN BITBOARD bbMinorAttacksSnapshot[2],
+ IN BITBOARD bbRookAttacksSnapshot[2]);
+
+ULONG
+HangCpBucket(SCORE iHangCp);
+
+ULONG
+GapCpBucket(SCORE iGapCp);
+
+ULONG
+MoveNumBucket(ULONG uMoveNum);
+
+ULONG
+DepthBucket(ULONG uCandDepth);
FLAG
WeShouldTryNullmovePruning(IN SEARCHER_THREAD_CONTEXT *ctx,
@@ -4137,7 +4471,10 @@ extern ULONG g_HistoryCounters[14][128];
extern ULONG g_ContinuationHistory[CONTINUATION_TABLE_SIZE];
ULONG
-GetMoveFailHighPercentage(MOVE mv, ULONG *puAttempts);
+GetMoveFailHighPctThousandths(MOVE mv, ULONG *puAttempts);
+
+void
+GetMoveFailHighRawCounts(MOVE mv, ULONG *puFailHighs, ULONG *puAttempts);
void
UpdateDynamicMoveOrdering(SEARCHER_THREAD_CONTEXT *ctx,
diff --git a/src/data.c b/src/data.c
index 71a1531..e1dd2c9 100644
--- a/src/data.c
+++ b/src/data.c
@@ -1202,3 +1202,1780 @@ Return value:
}
}
}
+// Auto-generated by splice_joint.py --pickle pickles/wilson_2026-10-03.pkl (smoothed by smooth_joint.py, --floor 500 --shrink-k 250.0)
+// (56,530 positions, 690,064,327,799 eligible tries at smoothing time).
+// Every cell is populated -- no 0xffff sentinels remain.
+// *** VALUES ARE PESSIMISTIC UPPER BOUNDS, NOT POINT ESTIMATES ***
+// Each cell stores the upper end of the 1.96-sigma Wilson
+// score interval for its observed (fail-highs / tries), not fh/tries.
+// So a cell's value encodes BOTH its rate and the confidence its own
+// sample size supports: a zero-fail-high cell bakes to roughly
+// 384000/tries, i.e. ~762 at 500 tries, ~1 at 384k tries, 0 above
+// ~770k tries. A consumer can therefore threshold on the value ALONE
+// and get an evidence-weighted decision, with no companion sample-count
+// array and no statistics at the call site -- the threshold IS the
+// confidence bar.
+// Rationale (2026-10-03): under point estimates 8,248 of 21,780 cells
+// read exactly 0, most on little evidence, so any "prune where the
+// table says 0" rule was far broader than the data supported. Under
+// Wilson bounds 136 cells read 0. The transform is monotone -- no cell's
+// value is ever lowered -- so every consumer became strictly more
+// conservative at a fixed threshold. NOTE this shifted LMR admission:
+// LMR_STATIC_ADMIT_PCT_THOUSANDTHS was raised 500 -> 1000 to hold the
+// previous admission population roughly constant (21,047 -> 21,017
+// cells). Wilson bounds correct for SAMPLING error only; a structurally
+// wrong signal (see SPEC.md's queen-vs-queen bucket: well-sampled and
+// backwards) is untouched by them, which is what shadow verification is
+// for.
+// 16,539/21,780 cells have tries strictly above the 500-try floor (raw observed, unchanged by smoothing).
+// 5,241/21,780 cells were below the floor (some data, shrunk toward a coarser marginal) or had zero observed tries (pure marginal fallback) -- see joint_table_collection/README.md's "Smoothing sparse/empty cells" section and smooth_joint.py for the method.
+// 0 floor-cleared cells flagged by the 95% Wilson-CI check at smoothing time.
+USHORT g_uJointFHThousandths[12][11][11][15] =
+{
+ { // depth bucket 0
+ { // dumb bucket 0
+ { 17175, 12727, 10446, 9176, 8203, 7345, 6564, 5862, 5229, 4674, 4183, 3759, 2562, 1241, 608 },
+ { 2716, 2237, 1832, 1514, 1271, 1083, 939, 822, 729, 654, 589, 537, 404, 259, 168 },
+ { 151, 160, 159, 152, 139, 127, 117, 108, 100, 94, 88, 84, 72, 60, 55 },
+ { 78, 84, 85, 82, 76, 70, 64, 59, 55, 51, 48, 45, 37, 29, 23 },
+ { 32, 33, 33, 31, 28, 26, 23, 22, 20, 18, 17, 16, 12, 8, 8 },
+ { 21, 20, 19, 17, 14, 13, 12, 10, 9, 8, 8, 7, 5, 2, 1 },
+ { 15, 14, 12, 10, 9, 8, 6, 6, 5, 5, 4, 4, 2, 1, 0 },
+ { 14, 13, 10, 8, 7, 6, 5, 4, 4, 3, 3, 2, 1, 0, 0 },
+ { 11, 11, 8, 7, 6, 5, 4, 3, 3, 3, 2, 2, 1, 0, 0 },
+ { 9, 8, 6, 5, 4, 3, 3, 2, 2, 2, 2, 1, 1, 0, 0 },
+ { 4, 3, 2, 2, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0 },
+ },
+ { // dumb bucket 1
+ { 31371, 10698, 4275, 3621, 3236, 3693, 4059, 4437, 5309, 5879, 6459, 6867, 5286, 5436, 6879 },
+ { 696, 639, 548, 664, 924, 1006, 1093, 1242, 1317, 1471, 1466, 1462, 1581, 2307, 3489 },
+ { 629, 389, 311, 335, 367, 485, 563, 632, 601, 624, 747, 783, 952, 1195, 1712 },
+ { 441, 276, 126, 192, 242, 254, 295, 338, 357, 399, 420, 414, 430, 469, 860 },
+ { 277, 138, 154, 82, 122, 151, 192, 170, 178, 204, 220, 189, 169, 196, 365 },
+ { 201, 73, 52, 72, 111, 76, 99, 126, 113, 105, 95, 108, 75, 66, 155 },
+ { 162, 55, 35, 23, 41, 38, 35, 55, 49, 42, 52, 65, 78, 44, 98 },
+ { 32, 24, 24, 27, 56, 42, 35, 51, 33, 41, 53, 48, 91, 36, 55 },
+ { 60, 17, 28, 25, 45, 30, 18, 37, 35, 28, 28, 20, 42, 29, 45 },
+ { 30, 14, 8, 10, 15, 21, 14, 14, 21, 18, 22, 39, 48, 28, 31 },
+ { 5, 4, 3, 4, 4, 8, 13, 17, 18, 21, 23, 17, 9, 9, 5 },
+ },
+ { // dumb bucket 2
+ { 3556, 2476, 1826, 1425, 1164, 1009, 907, 865, 851, 850, 883, 913, 1136, 1342, 1420 },
+ { 240, 205, 175, 152, 134, 118, 110, 106, 108, 111, 120, 130, 178, 322, 656 },
+ { 45, 40, 39, 36, 33, 33, 33, 35, 40, 44, 53, 63, 102, 202, 417 },
+ { 24, 21, 19, 19, 19, 21, 22, 24, 28, 33, 39, 44, 66, 118, 285 },
+ { 6, 5, 6, 8, 7, 9, 9, 11, 15, 18, 21, 24, 34, 56, 145 },
+ { 5, 4, 4, 4, 4, 5, 4, 5, 5, 6, 8, 8, 16, 21, 61 },
+ { 7, 5, 4, 3, 4, 3, 2, 3, 3, 3, 4, 4, 6, 7, 28 },
+ { 5, 4, 3, 3, 2, 2, 2, 2, 1, 2, 3, 4, 4, 2, 18 },
+ { 5, 3, 3, 2, 1, 1, 2, 2, 1, 2, 2, 4, 4, 1, 9 },
+ { 3, 2, 1, 1, 2, 1, 1, 1, 1, 1, 2, 2, 3, 1, 3 },
+ { 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 2, 2, 2, 1, 0 },
+ },
+ { // dumb bucket 3
+ { 1689, 1120, 802, 626, 500, 390, 319, 269, 236, 211, 184, 169, 153, 164, 127 },
+ { 53, 40, 31, 24, 19, 17, 13, 12, 12, 11, 13, 13, 20, 44, 83 },
+ { 63, 45, 32, 27, 24, 21, 21, 17, 14, 12, 14, 12, 14, 21, 46 },
+ { 48, 40, 35, 30, 27, 25, 25, 21, 17, 14, 13, 9, 7, 7, 13 },
+ { 20, 13, 12, 10, 8, 7, 5, 4, 2, 3, 2, 2, 2, 2, 5 },
+ { 18, 13, 9, 9, 7, 5, 4, 3, 2, 2, 2, 1, 1, 1, 2 },
+ { 8, 6, 5, 5, 3, 3, 3, 2, 1, 2, 1, 1, 1, 1, 1 },
+ { 10, 7, 4, 4, 4, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1 },
+ { 8, 6, 4, 4, 1, 2, 0, 1, 1, 1, 2, 1, 1, 0, 1 },
+ { 3, 2, 3, 2, 1, 1, 1, 1, 2, 1, 1, 1, 1, 0, 0 },
+ { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 },
+ },
+ { // dumb bucket 4
+ { 2814, 2233, 1741, 1387, 1072, 855, 689, 602, 551, 484, 462, 440, 427, 402, 289 },
+ { 76, 56, 45, 42, 35, 31, 25, 23, 28, 30, 40, 47, 77, 124, 179 },
+ { 34, 34, 29, 26, 25, 21, 18, 20, 21, 24, 29, 35, 48, 64, 86 },
+ { 27, 23, 22, 23, 20, 20, 20, 18, 16, 16, 16, 16, 20, 19, 37 },
+ { 16, 12, 10, 7, 7, 7, 6, 6, 5, 4, 4, 5, 8, 6, 17 },
+ { 8, 9, 5, 5, 5, 5, 4, 4, 4, 3, 4, 3, 8, 4, 7 },
+ { 4, 5, 4, 4, 4, 4, 3, 3, 2, 2, 2, 2, 7, 3, 5 },
+ { 5, 4, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 8, 3, 2 },
+ { 8, 3, 4, 3, 3, 2, 2, 2, 1, 2, 3, 2, 7, 3, 1 },
+ { 5, 2, 3, 2, 2, 2, 2, 3, 2, 1, 2, 2, 10, 3, 1 },
+ { 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 6, 3, 1 },
+ },
+ { // dumb bucket 5
+ { 576, 376, 296, 283, 286, 279, 280, 286, 289, 307, 331, 367, 557, 1000, 1131 },
+ { 32, 26, 20, 17, 14, 12, 10, 10, 9, 8, 7, 7, 8, 41, 237 },
+ { 26, 25, 23, 20, 17, 16, 13, 11, 10, 10, 9, 8, 9, 43, 230 },
+ { 21, 18, 19, 15, 13, 11, 10, 10, 10, 9, 8, 8, 9, 67, 333 },
+ { 6, 6, 7, 7, 6, 5, 4, 4, 3, 3, 3, 3, 8, 86, 334 },
+ { 7, 7, 7, 5, 4, 4, 2, 2, 2, 2, 2, 2, 5, 57, 254 },
+ { 4, 3, 3, 3, 1, 2, 1, 1, 2, 2, 2, 2, 3, 33, 157 },
+ { 2, 2, 3, 2, 2, 2, 1, 1, 1, 2, 2, 2, 2, 10, 118 },
+ { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 0, 1, 14, 90 },
+ { 1, 1, 1, 1, 2, 1, 1, 1, 1, 0, 1, 1, 0, 3, 17 },
+ { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 3 },
+ },
+ { // dumb bucket 6
+ { 1800, 2400, 1800, 2400, 3200, 4400, 7200, 10200, 5771, 7790, 5458, 4311, 2061, 947, 327 },
+ { 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 800, 800, 792, 488, 283, 246, 145 },
+ { 1200, 1200, 1200, 1200, 1200, 800, 800, 800, 800, 576, 611, 202, 113, 148, 111 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 777, 393, 403, 150, 65, 93, 83 },
+ { 800, 800, 800, 800, 800, 800, 1200, 800, 656, 369, 233, 268, 44, 46, 47 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 519, 316, 216, 31, 13, 28 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 721, 478, 303, 23, 12, 22 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 556, 12, 17, 15 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 714, 41, 17, 8 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 760, 53, 28, 6 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 565, 353, 211, 36, 21, 1 },
+ },
+ { // dumb bucket 7
+ { 379, 291, 270, 228, 210, 188, 174, 159, 169, 165, 187, 220, 298, 402, 304 },
+ { 134, 104, 79, 71, 60, 54, 48, 47, 41, 41, 38, 35, 29, 37, 92 },
+ { 27, 25, 21, 16, 14, 14, 12, 11, 11, 11, 11, 12, 10, 27, 84 },
+ { 13, 14, 11, 6, 9, 7, 7, 8, 7, 7, 9, 6, 8, 41, 126 },
+ { 4, 7, 5, 5, 3, 5, 3, 5, 3, 4, 4, 3, 7, 40, 82 },
+ { 6, 4, 3, 6, 4, 3, 4, 4, 1, 2, 4, 3, 4, 17, 36 },
+ { 8, 3, 2, 4, 2, 4, 2, 3, 2, 2, 3, 2, 3, 5, 16 },
+ { 14, 8, 6, 6, 5, 5, 5, 5, 7, 5, 4, 6, 1, 3, 9 },
+ { 36, 25, 21, 18, 16, 16, 15, 14, 24, 13, 12, 11, 1, 4, 5 },
+ { 57, 39, 33, 29, 27, 26, 25, 23, 22, 21, 19, 17, 2, 3, 2 },
+ { 45, 31, 26, 23, 21, 20, 19, 17, 15, 14, 11, 9, 3, 6, 2 },
+ },
+ { // dumb bucket 8
+ { 800, 800, 800, 800, 3400, 1400, 800, 1200, 1200, 1200, 1956, 4588, 226, 108, 60 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 657, 459, 314, 24, 66, 73 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 519, 356, 219, 17, 40, 50 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 576, 329, 223, 17, 13, 16 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 707, 425, 289, 11, 12, 7 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 540, 339, 13, 3, 2 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 554, 19, 4, 2 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 16, 6, 1 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 33, 7, 1 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 50, 10, 3 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 589, 24, 27, 6 },
+ },
+ { // dumb bucket 9
+ { 963, 722, 610, 535, 453, 342, 270, 217, 182, 151, 122, 101, 69, 52, 35 },
+ { 150, 106, 95, 86, 75, 66, 56, 49, 42, 38, 33, 31, 21, 16, 27 },
+ { 15, 14, 10, 11, 11, 9, 7, 8, 7, 7, 6, 6, 5, 6, 13 },
+ { 6, 7, 5, 5, 6, 4, 5, 5, 5, 4, 4, 4, 4, 3, 5 },
+ { 1, 1, 1, 1, 2, 1, 1, 1, 2, 1, 1, 1, 1, 1, 2 },
+ { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1 },
+ { 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0 },
+ { 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0 },
+ { 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0 },
+ { 2, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 0, 1, 1 },
+ { 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 1 },
+ },
+ { // dumb bucket 10
+ { 8800, 1988, 1525, 1043, 876, 1214, 2904, 2996, 3719, 6318, 6346, 6880, 8214, 7552, 2551 },
+ { 2800, 2800, 2000, 1800, 1079, 536, 948, 1387, 1442, 1755, 2462, 2882, 2474, 1399, 258 },
+ { 1800, 1400, 1400, 1200, 1277, 535, 233, 699, 559, 1907, 1809, 1253, 869, 597, 221 },
+ { 1200, 1200, 1200, 800, 554, 301, 182, 374, 354, 1066, 1078, 582, 255, 155, 159 },
+ { 1200, 800, 800, 800, 561, 359, 172, 123, 172, 483, 424, 258, 60, 111, 289 },
+ { 1200, 1200, 800, 698, 533, 315, 159, 145, 116, 118, 113, 115, 55, 89, 774 },
+ { 800, 800, 800, 650, 622, 240, 132, 174, 102, 99, 94, 105, 12, 44, 328 },
+ { 800, 800, 800, 783, 426, 248, 144, 101, 111, 105, 110, 120, 15, 53, 247 },
+ { 800, 800, 800, 800, 646, 342, 193, 151, 139, 126, 134, 143, 43, 24, 67 },
+ { 800, 800, 800, 800, 800, 504, 294, 204, 156, 141, 138, 154, 21, 37, 50 },
+ { 800, 800, 603, 256, 144, 93, 72, 54, 44, 37, 32, 33, 6, 10, 88 },
+ },
+ },
+ { // depth bucket 1
+ { // dumb bucket 0
+ { 17504, 13126, 10585, 9043, 7831, 6822, 5955, 5225, 4595, 4062, 3602, 3212, 2124, 962, 432 },
+ { 3084, 2552, 2071, 1687, 1395, 1179, 1015, 888, 786, 704, 634, 577, 423, 234, 123 },
+ { 183, 187, 181, 170, 157, 144, 131, 121, 113, 105, 98, 93, 76, 54, 41 },
+ { 101, 101, 98, 93, 87, 80, 75, 70, 65, 61, 57, 53, 43, 30, 22 },
+ { 56, 52, 48, 45, 42, 39, 37, 34, 32, 30, 28, 26, 20, 13, 11 },
+ { 42, 37, 33, 30, 27, 24, 22, 20, 18, 17, 15, 14, 10, 6, 5 },
+ { 36, 31, 27, 24, 21, 19, 17, 15, 14, 12, 11, 10, 7, 5, 4 },
+ { 34, 30, 25, 22, 19, 17, 15, 13, 12, 11, 10, 9, 6, 4, 4 },
+ { 27, 25, 21, 18, 16, 14, 12, 11, 10, 9, 8, 7, 5, 4, 3 },
+ { 21, 19, 17, 14, 12, 11, 10, 9, 8, 7, 6, 6, 4, 3, 3 },
+ { 10, 9, 7, 6, 5, 4, 4, 3, 3, 3, 2, 2, 2, 1, 2 },
+ },
+ { // dumb bucket 1
+ { 20727, 7505, 3144, 2433, 2116, 2150, 2473, 2870, 3571, 4104, 4326, 4720, 4215, 5025, 6244 },
+ { 530, 430, 425, 515, 654, 791, 961, 1067, 1090, 1175, 1237, 1183, 1375, 2671, 3747 },
+ { 491, 297, 244, 243, 288, 384, 465, 504, 509, 536, 588, 595, 804, 1204, 1652 },
+ { 287, 174, 125, 110, 137, 210, 254, 259, 281, 288, 311, 326, 361, 385, 652 },
+ { 180, 112, 62, 52, 74, 104, 149, 150, 163, 175, 212, 178, 147, 153, 280 },
+ { 110, 44, 50, 40, 78, 61, 82, 88, 103, 95, 90, 64, 61, 55, 123 },
+ { 53, 11, 20, 17, 14, 34, 27, 39, 37, 42, 51, 55, 65, 33, 89 },
+ { 26, 9, 14, 20, 26, 20, 24, 26, 24, 23, 33, 29, 70, 29, 63 },
+ { 19, 14, 14, 10, 18, 15, 16, 18, 16, 19, 20, 21, 38, 25, 53 },
+ { 11, 5, 3, 3, 9, 18, 13, 9, 27, 19, 17, 21, 46, 23, 37 },
+ { 2, 1, 2, 2, 4, 6, 8, 14, 17, 18, 18, 17, 10, 10, 11 },
+ },
+ { // dumb bucket 2
+ { 3542, 2392, 1701, 1290, 1043, 875, 769, 701, 670, 647, 651, 661, 719, 788, 937 },
+ { 325, 276, 232, 195, 168, 148, 133, 122, 117, 112, 111, 112, 122, 236, 559 },
+ { 54, 49, 45, 41, 38, 37, 36, 36, 37, 39, 42, 46, 63, 143, 342 },
+ { 31, 27, 26, 24, 23, 22, 22, 23, 24, 26, 28, 32, 42, 74, 172 },
+ { 13, 11, 11, 10, 11, 11, 11, 12, 13, 15, 15, 17, 24, 44, 97 },
+ { 12, 11, 11, 9, 9, 9, 8, 7, 7, 8, 7, 8, 12, 17, 45 },
+ { 14, 11, 10, 9, 8, 7, 6, 5, 4, 4, 5, 6, 6, 6, 24 },
+ { 16, 11, 8, 7, 6, 6, 5, 5, 3, 3, 3, 4, 4, 3, 17 },
+ { 13, 10, 6, 5, 4, 4, 4, 3, 3, 3, 4, 3, 4, 2, 12 },
+ { 5, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 3, 3, 1, 6 },
+ { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 1, 2 },
+ },
+ { // dumb bucket 3
+ { 1676, 1099, 753, 548, 400, 309, 251, 213, 183, 164, 146, 134, 120, 147, 135 },
+ { 49, 40, 32, 27, 21, 17, 16, 14, 12, 12, 12, 13, 19, 53, 103 },
+ { 93, 72, 59, 46, 36, 31, 26, 23, 21, 18, 17, 16, 16, 29, 53 },
+ { 79, 63, 58, 52, 44, 37, 32, 28, 24, 24, 20, 17, 13, 10, 13 },
+ { 35, 30, 28, 23, 19, 17, 13, 11, 10, 9, 7, 7, 5, 3, 6 },
+ { 32, 27, 23, 19, 16, 12, 10, 8, 7, 6, 5, 4, 4, 2, 4 },
+ { 26, 20, 17, 14, 11, 8, 7, 7, 7, 5, 4, 4, 4, 2, 2 },
+ { 30, 18, 12, 9, 7, 6, 5, 5, 5, 4, 3, 4, 3, 2, 2 },
+ { 16, 11, 8, 6, 5, 5, 4, 4, 3, 3, 3, 3, 2, 1, 1 },
+ { 10, 6, 6, 5, 4, 5, 4, 3, 3, 3, 3, 3, 2, 2, 1 },
+ { 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 },
+ },
+ { // dumb bucket 4
+ { 4219, 2899, 2055, 1444, 1061, 796, 632, 514, 429, 384, 346, 344, 330, 288, 203 },
+ { 120, 88, 70, 56, 43, 36, 31, 30, 28, 27, 28, 35, 53, 88, 142 },
+ { 61, 56, 52, 44, 39, 33, 30, 27, 25, 25, 28, 31, 36, 45, 65 },
+ { 53, 46, 45, 38, 37, 34, 31, 28, 25, 23, 22, 20, 20, 16, 28 },
+ { 20, 21, 17, 13, 11, 11, 10, 10, 9, 8, 7, 6, 8, 5, 14 },
+ { 15, 12, 9, 8, 8, 7, 6, 6, 6, 5, 5, 4, 6, 3, 7 },
+ { 15, 13, 10, 7, 6, 7, 5, 4, 4, 3, 3, 4, 6, 3, 5 },
+ { 12, 11, 7, 5, 5, 4, 3, 3, 3, 2, 2, 3, 6, 3, 4 },
+ { 13, 11, 9, 5, 5, 6, 5, 4, 4, 3, 4, 3, 5, 2, 4 },
+ { 7, 6, 5, 4, 4, 5, 4, 3, 2, 2, 2, 2, 6, 2, 3 },
+ { 4, 3, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 4, 1, 1 },
+ },
+ { // dumb bucket 5
+ { 1511, 901, 570, 378, 295, 243, 230, 236, 249, 277, 295, 330, 480, 739, 882 },
+ { 34, 25, 21, 17, 14, 13, 11, 10, 9, 8, 8, 7, 8, 35, 219 },
+ { 31, 27, 24, 21, 18, 16, 14, 12, 11, 10, 9, 9, 9, 40, 228 },
+ { 34, 28, 23, 20, 19, 15, 14, 13, 11, 10, 10, 10, 11, 72, 332 },
+ { 23, 16, 11, 10, 9, 8, 6, 5, 5, 5, 4, 5, 8, 94, 357 },
+ { 25, 17, 12, 10, 9, 8, 6, 5, 4, 4, 5, 5, 7, 66, 278 },
+ { 22, 16, 10, 9, 6, 5, 5, 4, 4, 2, 2, 3, 5, 31, 138 },
+ { 20, 15, 10, 8, 7, 5, 6, 5, 4, 4, 4, 4, 3, 10, 92 },
+ { 11, 8, 6, 5, 4, 4, 3, 3, 3, 2, 3, 2, 2, 13, 68 },
+ { 11, 7, 6, 8, 4, 4, 3, 4, 3, 2, 3, 2, 2, 4, 22 },
+ { 3, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 5, 9 },
+ },
+ { // dumb bucket 6
+ { 1400, 2600, 2000, 2400, 7800, 7600, 7000, 10621, 6093, 7157, 5631, 4768, 1671, 815, 266 },
+ { 1200, 1200, 1200, 1200, 1200, 800, 800, 800, 1174, 329, 592, 176, 290, 160, 103 },
+ { 800, 800, 800, 800, 800, 800, 800, 798, 397, 199, 291, 175, 87, 101, 70 },
+ { 800, 800, 800, 800, 800, 800, 800, 488, 370, 145, 85, 137, 44, 62, 54 },
+ { 800, 800, 800, 800, 800, 800, 800, 601, 301, 246, 144, 149, 39, 37, 35 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 407, 217, 132, 80, 22, 16, 16 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 510, 301, 184, 111, 18, 10, 14 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 558, 350, 211, 12, 9, 12 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 697, 404, 274, 13, 11, 12 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 784, 475, 303, 25, 12, 7 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 421, 210, 125, 77, 14, 20, 6 },
+ },
+ { // dumb bucket 7
+ { 232, 181, 157, 135, 123, 110, 99, 99, 100, 100, 106, 122, 183, 278, 211 },
+ { 61, 49, 43, 37, 34, 32, 30, 29, 27, 26, 25, 24, 19, 22, 66 },
+ { 19, 17, 15, 14, 12, 11, 11, 10, 9, 9, 8, 8, 8, 17, 66 },
+ { 18, 16, 14, 13, 12, 11, 10, 10, 9, 9, 8, 8, 9, 35, 100 },
+ { 8, 7, 6, 6, 6, 5, 5, 5, 4, 4, 4, 3, 7, 39, 70 },
+ { 7, 7, 4, 4, 3, 3, 4, 4, 3, 4, 3, 2, 4, 14, 25 },
+ { 11, 10, 7, 4, 5, 3, 3, 3, 5, 2, 3, 2, 3, 5, 10 },
+ { 10, 4, 9, 3, 6, 5, 3, 8, 5, 2, 2, 3, 1, 3, 7 },
+ { 40, 20, 9, 12, 11, 7, 12, 11, 9, 6, 9, 7, 1, 3, 5 },
+ { 226, 168, 77, 55, 67, 63, 74, 27, 17, 23, 27, 13, 3, 4, 3 },
+ { 68, 25, 21, 34, 17, 27, 14, 31, 6, 18, 8, 5, 2, 6, 4 },
+ },
+ { // dumb bucket 8
+ { 800, 800, 800, 800, 800, 800, 2400, 1800, 1480, 1128, 957, 958, 318, 113, 46 },
+ { 800, 800, 800, 800, 800, 800, 668, 531, 408, 271, 263, 126, 30, 61, 53 },
+ { 800, 800, 800, 800, 800, 800, 800, 688, 357, 215, 130, 88, 26, 35, 36 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 421, 216, 135, 86, 6, 9, 8 },
+ { 800, 800, 800, 800, 800, 800, 800, 719, 463, 242, 148, 105, 15, 7, 4 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 584, 327, 198, 134, 7, 1, 2 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 408, 271, 205, 4, 3, 2 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 526, 365, 8, 4, 2 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 559, 17, 6, 2 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 22, 5, 3 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 787, 411, 216, 30, 22, 7 },
+ },
+ { // dumb bucket 9
+ { 972, 730, 581, 488, 399, 313, 251, 201, 166, 134, 113, 97, 63, 45, 25 },
+ { 112, 90, 80, 70, 61, 54, 46, 39, 35, 30, 28, 26, 19, 13, 20 },
+ { 18, 16, 15, 14, 12, 12, 11, 10, 9, 8, 7, 7, 5, 5, 9 },
+ { 14, 12, 12, 11, 10, 9, 8, 8, 8, 7, 7, 6, 5, 4, 4 },
+ { 6, 5, 4, 4, 4, 4, 4, 3, 3, 3, 2, 2, 1, 1, 1 },
+ { 2, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 1, 1, 1, 1 },
+ { 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1, 0 },
+ { 2, 2, 2, 1, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1 },
+ { 3, 2, 2, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1 },
+ { 3, 3, 2, 1, 2, 1, 1, 2, 1, 1, 1, 1, 0, 1, 0 },
+ { 4, 3, 2, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1 },
+ },
+ { // dumb bucket 10
+ { 1894, 423, 667, 732, 1002, 1197, 1581, 1891, 2331, 3013, 3256, 4357, 6140, 5499, 1760 },
+ { 2400, 2400, 1299, 618, 700, 277, 622, 714, 1263, 1305, 1613, 1951, 1755, 1089, 279 },
+ { 1400, 1400, 1800, 548, 631, 215, 336, 346, 671, 1058, 1274, 990, 694, 455, 149 },
+ { 1200, 800, 592, 339, 195, 160, 73, 122, 236, 486, 264, 271, 220, 210, 121 },
+ { 800, 800, 476, 297, 204, 122, 65, 44, 121, 193, 186, 77, 37, 69, 167 },
+ { 800, 800, 496, 321, 178, 110, 60, 36, 43, 61, 38, 37, 15, 94, 395 },
+ { 800, 800, 449, 428, 156, 88, 48, 36, 36, 37, 32, 35, 10, 50, 315 },
+ { 800, 787, 504, 307, 156, 92, 53, 37, 40, 38, 40, 42, 8, 60, 218 },
+ { 800, 800, 613, 381, 211, 126, 68, 54, 50, 45, 45, 49, 7, 17, 275 },
+ { 800, 800, 800, 658, 340, 181, 103, 77, 58, 52, 51, 57, 8, 15, 86 },
+ { 800, 800, 232, 93, 51, 37, 26, 21, 15, 13, 11, 11, 1, 7, 69 },
+ },
+ },
+ { // depth bucket 2
+ { // dumb bucket 0
+ { 10614, 8565, 7191, 6262, 5442, 4766, 4160, 3640, 3189, 2803, 2477, 2200, 1442, 640, 280 },
+ { 1298, 1208, 1066, 917, 738, 634, 554, 490, 436, 392, 355, 323, 238, 130, 69 },
+ { 236, 219, 199, 178, 144, 126, 112, 100, 91, 83, 76, 71, 55, 35, 22 },
+ { 138, 117, 100, 88, 71, 63, 56, 51, 46, 42, 39, 37, 29, 19, 14 },
+ { 131, 102, 85, 74, 60, 54, 48, 43, 39, 35, 32, 30, 22, 14, 13 },
+ { 116, 92, 76, 65, 54, 47, 42, 37, 33, 29, 27, 24, 18, 12, 11 },
+ { 120, 98, 83, 73, 60, 52, 47, 41, 37, 33, 30, 27, 20, 13, 13 },
+ { 113, 95, 81, 70, 59, 51, 45, 40, 35, 31, 28, 25, 18, 12, 14 },
+ { 88, 78, 69, 60, 50, 44, 39, 34, 30, 27, 23, 21, 15, 11, 13 },
+ { 75, 65, 57, 50, 42, 36, 32, 28, 25, 22, 20, 18, 12, 9, 10 },
+ { 40, 33, 27, 23, 19, 16, 14, 12, 11, 10, 9, 8, 5, 5, 6 },
+ },
+ { // dumb bucket 1
+ { 11136, 4734, 2086, 1390, 1363, 1445, 1390, 1527, 1832, 1989, 2073, 2097, 2104, 3882, 5640 },
+ { 332, 282, 216, 245, 301, 328, 407, 497, 496, 490, 500, 459, 620, 1990, 2991 },
+ { 223, 123, 113, 106, 129, 206, 207, 222, 208, 250, 284, 274, 375, 797, 1253 },
+ { 140, 93, 50, 71, 104, 106, 95, 118, 139, 135, 146, 140, 202, 320, 664 },
+ { 137, 54, 39, 29, 39, 47, 61, 72, 77, 87, 83, 88, 99, 153, 396 },
+ { 104, 30, 56, 20, 32, 27, 38, 50, 49, 51, 61, 50, 45, 74, 284 },
+ { 55, 13, 10, 16, 11, 27, 20, 41, 37, 29, 42, 54, 41, 51, 261 },
+ { 22, 17, 12, 9, 16, 20, 31, 24, 29, 29, 34, 30, 51, 40, 245 },
+ { 45, 19, 7, 5, 16, 9, 15, 16, 19, 26, 22, 22, 30, 32, 253 },
+ { 50, 25, 12, 5, 6, 10, 13, 19, 14, 9, 15, 17, 28, 27, 181 },
+ { 6, 2, 4, 4, 4, 6, 9, 14, 15, 19, 18, 21, 20, 24, 71 },
+ },
+ { // dumb bucket 2
+ { 2825, 1747, 1184, 890, 693, 574, 490, 440, 407, 385, 369, 359, 340, 414, 694 },
+ { 191, 165, 143, 124, 101, 91, 82, 78, 73, 71, 70, 69, 73, 149, 377 },
+ { 52, 43, 36, 31, 24, 22, 21, 21, 21, 21, 23, 24, 33, 87, 231 },
+ { 41, 30, 23, 19, 14, 12, 12, 11, 11, 12, 12, 13, 19, 45, 123 },
+ { 46, 33, 25, 21, 18, 15, 14, 12, 11, 10, 11, 10, 13, 28, 87 },
+ { 56, 40, 31, 22, 19, 17, 15, 12, 10, 9, 9, 8, 9, 13, 58 },
+ { 50, 35, 29, 24, 20, 20, 15, 12, 9, 8, 8, 7, 6, 7, 48 },
+ { 48, 36, 26, 21, 19, 17, 14, 12, 9, 8, 7, 7, 5, 4, 41 },
+ { 35, 26, 21, 19, 15, 15, 11, 10, 9, 6, 6, 5, 4, 3, 44 },
+ { 18, 14, 11, 11, 11, 10, 8, 9, 7, 6, 5, 4, 3, 2, 30 },
+ { 5, 4, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 1, 1, 9 },
+ },
+ { // dumb bucket 3
+ { 1621, 937, 592, 408, 276, 212, 166, 133, 114, 105, 91, 84, 75, 92, 129 },
+ { 45, 33, 25, 18, 14, 12, 10, 8, 7, 7, 6, 7, 10, 39, 94 },
+ { 70, 49, 37, 31, 22, 18, 15, 13, 11, 9, 8, 8, 8, 18, 35 },
+ { 77, 59, 48, 42, 36, 29, 26, 23, 18, 18, 17, 11, 6, 6, 12 },
+ { 122, 82, 65, 49, 43, 36, 32, 21, 19, 18, 15, 14, 8, 4, 10 },
+ { 114, 82, 59, 52, 36, 31, 24, 17, 15, 15, 14, 15, 7, 4, 10 },
+ { 105, 68, 48, 39, 30, 23, 20, 15, 14, 12, 11, 9, 6, 4, 11 },
+ { 107, 63, 39, 31, 24, 18, 15, 18, 15, 15, 12, 10, 7, 4, 12 },
+ { 68, 43, 31, 19, 18, 16, 13, 12, 11, 10, 9, 7, 5, 4, 10 },
+ { 49, 30, 22, 19, 13, 13, 14, 10, 10, 10, 10, 8, 6, 3, 9 },
+ { 10, 8, 6, 5, 4, 3, 3, 3, 3, 2, 2, 3, 2, 1, 3 },
+ },
+ { // dumb bucket 4
+ { 1805, 1172, 801, 561, 419, 320, 259, 215, 185, 170, 155, 155, 150, 149, 173 },
+ { 37, 35, 28, 23, 20, 16, 13, 13, 13, 11, 14, 17, 25, 55, 108 },
+ { 28, 25, 22, 24, 22, 19, 16, 14, 12, 12, 13, 13, 17, 25, 44 },
+ { 33, 28, 22, 23, 19, 19, 15, 13, 11, 10, 9, 9, 8, 8, 21 },
+ { 47, 36, 31, 29, 25, 21, 18, 14, 13, 12, 9, 8, 7, 4, 15 },
+ { 33, 30, 23, 24, 22, 17, 12, 11, 9, 10, 8, 7, 5, 3, 12 },
+ { 28, 20, 16, 11, 8, 7, 5, 5, 6, 4, 5, 5, 5, 3, 17 },
+ { 22, 18, 15, 11, 8, 8, 6, 7, 4, 4, 3, 3, 4, 3, 17 },
+ { 24, 13, 14, 11, 7, 7, 5, 5, 4, 3, 3, 4, 4, 3, 18 },
+ { 20, 14, 13, 8, 7, 5, 3, 3, 4, 4, 4, 3, 4, 2, 16 },
+ { 9, 6, 4, 3, 2, 3, 1, 1, 1, 1, 1, 1, 3, 1, 6 },
+ },
+ { // dumb bucket 5
+ { 1140, 614, 395, 246, 183, 146, 119, 111, 106, 107, 111, 122, 199, 372, 589 },
+ { 49, 29, 21, 16, 14, 10, 8, 8, 8, 6, 5, 5, 5, 24, 157 },
+ { 38, 26, 21, 17, 13, 11, 9, 8, 7, 6, 6, 5, 4, 27, 174 },
+ { 49, 30, 21, 16, 13, 10, 9, 7, 6, 5, 5, 5, 5, 43, 231 },
+ { 85, 51, 30, 26, 18, 14, 12, 11, 10, 9, 8, 6, 7, 71, 320 },
+ { 121, 69, 39, 29, 26, 20, 14, 12, 11, 9, 10, 9, 8, 52, 243 },
+ { 136, 77, 48, 34, 24, 19, 14, 12, 12, 7, 7, 6, 6, 24, 100 },
+ { 149, 91, 53, 34, 28, 23, 19, 13, 15, 9, 11, 11, 5, 9, 65 },
+ { 97, 52, 36, 27, 18, 16, 11, 8, 8, 5, 6, 4, 4, 15, 47 },
+ { 80, 52, 34, 24, 17, 15, 13, 10, 10, 9, 7, 7, 5, 6, 42 },
+ { 23, 16, 11, 8, 7, 6, 6, 5, 4, 4, 4, 3, 2, 6, 24 },
+ },
+ { // dumb bucket 6
+ { 1200, 1200, 1200, 1800, 2600, 5200, 2600, 2000, 1917, 2526, 1153, 1997, 689, 449, 211 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 649, 382, 112, 101, 72 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 889, 333, 203, 53, 63, 49 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 734, 384, 205, 198, 30, 48, 39 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 774, 367, 212, 187, 31, 31, 35 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 516, 322, 187, 21, 19, 20 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 727, 454, 490, 21, 13, 16 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 496, 14, 19, 39 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 712, 29, 14, 24 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 16, 15, 19 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 494, 275, 178, 21, 21, 21 },
+ },
+ { // dumb bucket 7
+ { 178, 121, 93, 78, 65, 56, 47, 44, 42, 45, 44, 46, 63, 102, 141 },
+ { 41, 27, 22, 19, 17, 15, 14, 14, 13, 12, 12, 11, 9, 12, 52 },
+ { 20, 14, 11, 9, 7, 6, 5, 5, 5, 4, 4, 5, 4, 11, 50 },
+ { 8, 6, 5, 4, 4, 4, 3, 3, 4, 3, 3, 3, 4, 20, 65 },
+ { 7, 4, 4, 3, 3, 2, 2, 3, 3, 2, 2, 3, 4, 29, 60 },
+ { 12, 6, 3, 2, 3, 1, 2, 3, 2, 2, 3, 1, 2, 12, 26 },
+ { 39, 18, 10, 9, 3, 8, 13, 6, 6, 6, 6, 6, 2, 5, 13 },
+ { 58, 32, 22, 11, 13, 8, 17, 13, 15, 14, 5, 4, 4, 5, 12 },
+ { 172, 186, 85, 75, 68, 34, 28, 43, 16, 22, 34, 21, 3, 5, 11 },
+ { 1066, 521, 232, 309, 230, 210, 142, 171, 69, 56, 39, 63, 10, 4, 12 },
+ { 144, 72, 59, 154, 27, 43, 22, 30, 17, 62, 12, 9, 3, 7, 9 },
+ },
+ { // dumb bucket 8
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 963, 336, 259, 121, 51, 43 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 604, 404, 274, 17, 40, 38 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 750, 476, 298, 185, 12, 21, 22 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 737, 433, 260, 165, 12, 10, 7 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 462, 298, 202, 16, 10, 7 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 772, 444, 305, 19, 7, 8 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 795, 547, 15, 8, 8 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 13, 11, 8 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 33, 3, 10 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 78, 11, 11 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 573, 36, 20, 11 },
+ },
+ { // dumb bucket 9
+ { 691, 458, 344, 278, 218, 164, 128, 98, 82, 64, 52, 46, 28, 20, 20 },
+ { 52, 41, 39, 35, 28, 23, 20, 16, 14, 13, 12, 11, 7, 6, 13 },
+ { 22, 17, 13, 12, 7, 7, 6, 5, 4, 4, 3, 3, 2, 2, 5 },
+ { 18, 12, 12, 10, 8, 7, 5, 4, 4, 3, 2, 3, 1, 1, 1 },
+ { 21, 14, 14, 14, 10, 9, 8, 6, 4, 4, 3, 3, 1, 1, 1 },
+ { 20, 16, 14, 15, 9, 9, 7, 7, 4, 4, 3, 3, 1, 1, 1 },
+ { 15, 10, 9, 9, 9, 8, 3, 6, 5, 3, 2, 1, 1, 1, 1 },
+ { 19, 16, 11, 9, 6, 6, 5, 5, 5, 3, 3, 1, 1, 1, 1 },
+ { 23, 15, 10, 6, 7, 7, 5, 5, 3, 3, 2, 2, 1, 1, 1 },
+ { 22, 12, 7, 8, 6, 6, 5, 4, 3, 3, 2, 3, 1, 1, 1 },
+ { 23, 14, 9, 6, 5, 3, 3, 2, 2, 1, 1, 1, 0, 1, 2 },
+ },
+ { // dumb bucket 10
+ { 1800, 422, 746, 470, 485, 304, 420, 473, 751, 509, 480, 746, 603, 477, 403 },
+ { 1200, 1200, 800, 684, 350, 179, 109, 349, 143, 251, 388, 545, 220, 177, 122 },
+ { 1200, 1200, 1200, 800, 526, 295, 197, 361, 348, 524, 609, 356, 330, 226, 107 },
+ { 1200, 800, 800, 693, 388, 200, 144, 285, 212, 139, 231, 287, 191, 160, 148 },
+ { 800, 800, 800, 546, 360, 221, 132, 90, 209, 74, 123, 135, 23, 69, 81 },
+ { 800, 800, 800, 593, 425, 223, 140, 90, 91, 89, 78, 72, 7, 90, 85 },
+ { 800, 800, 800, 563, 310, 188, 115, 84, 84, 78, 69, 74, 9, 70, 184 },
+ { 800, 800, 800, 632, 306, 172, 118, 93, 96, 87, 86, 89, 18, 32, 339 },
+ { 800, 800, 800, 800, 460, 267, 163, 125, 109, 98, 98, 101, 15, 40, 353 },
+ { 800, 800, 800, 800, 800, 406, 263, 183, 130, 117, 108, 122, 17, 55, 186 },
+ { 800, 800, 800, 266, 134, 92, 63, 47, 34, 28, 25, 25, 3, 8, 110 },
+ },
+ },
+ { // depth bucket 3
+ { // dumb bucket 0
+ { 7417, 6283, 5442, 4791, 4039, 3522, 3057, 2656, 2308, 2024, 1781, 1578, 1035, 468, 209 },
+ { 1692, 1556, 1351, 1146, 911, 786, 689, 610, 544, 491, 446, 408, 304, 166, 84 },
+ { 245, 231, 214, 194, 169, 153, 138, 126, 115, 106, 99, 92, 73, 45, 27 },
+ { 135, 110, 92, 80, 69, 62, 55, 51, 47, 43, 40, 38, 30, 19, 14 },
+ { 115, 81, 62, 52, 42, 37, 33, 29, 27, 24, 22, 21, 15, 9, 9 },
+ { 99, 69, 52, 42, 34, 29, 25, 22, 19, 18, 16, 15, 11, 7, 6 },
+ { 105, 75, 58, 46, 36, 30, 26, 23, 20, 18, 16, 15, 11, 7, 8 },
+ { 106, 77, 60, 48, 38, 31, 27, 23, 20, 18, 16, 15, 11, 7, 10 },
+ { 87, 68, 54, 44, 35, 29, 25, 22, 19, 17, 15, 14, 10, 8, 10 },
+ { 76, 59, 46, 37, 30, 25, 21, 19, 16, 14, 13, 11, 8, 6, 9 },
+ { 45, 33, 26, 21, 17, 14, 12, 10, 9, 8, 7, 6, 4, 3, 5 },
+ },
+ { // dumb bucket 1
+ { 5445, 3327, 1867, 1139, 1389, 1498, 1208, 1079, 1065, 973, 940, 1069, 1175, 2829, 4534 },
+ { 304, 196, 105, 124, 171, 280, 254, 304, 286, 304, 317, 281, 459, 1531, 2416 },
+ { 175, 89, 58, 54, 83, 119, 106, 174, 193, 171, 192, 210, 296, 696, 1267 },
+ { 105, 66, 64, 38, 39, 58, 82, 68, 91, 110, 124, 123, 163, 313, 745 },
+ { 185, 36, 30, 30, 37, 60, 33, 48, 59, 50, 66, 76, 73, 144, 420 },
+ { 105, 42, 34, 21, 17, 44, 33, 26, 39, 49, 42, 39, 38, 83, 324 },
+ { 34, 27, 15, 12, 11, 13, 12, 23, 29, 29, 38, 42, 45, 71, 359 },
+ { 29, 24, 18, 17, 16, 21, 17, 17, 32, 34, 48, 53, 54, 52, 350 },
+ { 87, 23, 9, 11, 8, 15, 11, 16, 17, 28, 37, 24, 39, 47, 422 },
+ { 69, 14, 9, 11, 7, 7, 12, 14, 12, 23, 33, 35, 30, 35, 321 },
+ { 6, 4, 4, 6, 5, 6, 7, 17, 13, 22, 32, 28, 51, 38, 138 },
+ },
+ { // dumb bucket 2
+ { 2168, 1299, 875, 645, 488, 405, 342, 302, 279, 257, 250, 239, 223, 292, 541 },
+ { 214, 171, 139, 119, 98, 89, 80, 74, 70, 67, 64, 63, 63, 114, 281 },
+ { 62, 52, 41, 34, 29, 27, 26, 25, 25, 25, 25, 26, 31, 75, 200 },
+ { 62, 32, 25, 20, 15, 14, 13, 11, 12, 11, 12, 13, 16, 33, 100 },
+ { 59, 34, 22, 17, 14, 12, 10, 9, 7, 8, 7, 7, 8, 16, 59 },
+ { 72, 41, 25, 20, 14, 11, 10, 8, 7, 5, 5, 6, 6, 9, 40 },
+ { 72, 45, 30, 22, 15, 13, 10, 8, 7, 5, 5, 4, 4, 5, 41 },
+ { 77, 46, 31, 22, 17, 13, 11, 8, 7, 5, 4, 4, 3, 3, 42 },
+ { 53, 36, 27, 17, 13, 13, 10, 7, 6, 5, 4, 4, 3, 3, 48 },
+ { 24, 20, 14, 11, 9, 7, 8, 6, 5, 4, 3, 3, 3, 2, 36 },
+ { 9, 7, 5, 4, 3, 3, 2, 2, 2, 2, 2, 2, 1, 1, 13 },
+ },
+ { // dumb bucket 3
+ { 1391, 759, 454, 303, 211, 156, 125, 100, 80, 75, 72, 57, 51, 70, 121 },
+ { 60, 35, 28, 21, 16, 11, 10, 9, 8, 6, 6, 7, 8, 35, 109 },
+ { 61, 39, 28, 23, 18, 16, 13, 11, 8, 6, 7, 6, 7, 18, 45 },
+ { 78, 52, 45, 34, 33, 26, 21, 15, 15, 14, 12, 11, 5, 6, 17 },
+ { 112, 67, 51, 41, 30, 27, 18, 16, 14, 9, 11, 7, 6, 3, 11 },
+ { 108, 60, 48, 37, 27, 21, 19, 15, 11, 9, 6, 7, 5, 3, 8 },
+ { 120, 75, 49, 37, 26, 22, 15, 13, 9, 8, 6, 8, 5, 3, 9 },
+ { 129, 75, 54, 39, 22, 19, 12, 10, 11, 9, 8, 8, 4, 3, 11 },
+ { 75, 45, 31, 21, 20, 12, 12, 9, 8, 8, 9, 7, 4, 3, 10 },
+ { 53, 38, 26, 19, 14, 11, 11, 9, 9, 9, 6, 8, 5, 3, 9 },
+ { 16, 12, 8, 6, 6, 5, 4, 3, 3, 3, 3, 2, 2, 1, 4 },
+ },
+ { // dumb bucket 4
+ { 950, 621, 436, 319, 238, 183, 152, 132, 114, 96, 93, 90, 93, 102, 152 },
+ { 62, 47, 37, 27, 19, 18, 18, 16, 18, 16, 18, 17, 20, 47, 98 },
+ { 29, 29, 22, 22, 21, 19, 17, 14, 11, 11, 13, 11, 13, 23, 50 },
+ { 30, 22, 20, 17, 16, 17, 12, 10, 9, 8, 8, 6, 7, 8, 23 },
+ { 38, 23, 17, 16, 14, 12, 10, 8, 8, 5, 6, 6, 5, 4, 14 },
+ { 23, 20, 12, 16, 16, 17, 19, 7, 5, 5, 4, 5, 3, 2, 11 },
+ { 32, 17, 14, 13, 13, 11, 9, 7, 7, 5, 4, 3, 3, 2, 14 },
+ { 26, 19, 11, 12, 11, 8, 5, 7, 5, 4, 5, 3, 3, 2, 20 },
+ { 21, 13, 15, 9, 8, 5, 6, 6, 3, 3, 4, 2, 3, 2, 22 },
+ { 22, 18, 12, 7, 8, 5, 4, 3, 4, 4, 3, 2, 3, 2, 22 },
+ { 9, 6, 4, 3, 2, 2, 1, 2, 2, 1, 2, 2, 2, 1, 8 },
+ },
+ { // dumb bucket 5
+ { 752, 379, 236, 170, 130, 106, 89, 80, 74, 73, 72, 79, 122, 256, 490 },
+ { 56, 33, 25, 19, 15, 13, 11, 9, 8, 8, 7, 7, 6, 21, 128 },
+ { 44, 27, 20, 17, 14, 11, 10, 8, 8, 7, 6, 5, 5, 23, 148 },
+ { 56, 30, 19, 15, 11, 10, 7, 7, 6, 5, 5, 4, 5, 35, 187 },
+ { 85, 47, 25, 17, 13, 10, 8, 8, 6, 5, 4, 5, 6, 59, 244 },
+ { 139, 62, 32, 22, 19, 14, 10, 8, 8, 5, 5, 5, 6, 48, 192 },
+ { 153, 78, 46, 25, 19, 14, 11, 7, 6, 7, 5, 5, 5, 26, 109 },
+ { 202, 107, 56, 36, 25, 19, 14, 10, 11, 12, 8, 7, 4, 11, 57 },
+ { 129, 68, 41, 31, 18, 11, 11, 7, 6, 6, 4, 4, 3, 12, 51 },
+ { 120, 66, 44, 27, 16, 12, 10, 9, 10, 10, 8, 7, 3, 5, 44 },
+ { 39, 24, 16, 10, 8, 6, 6, 6, 5, 4, 4, 3, 2, 5, 36 },
+ },
+ { // dumb bucket 6
+ { 1200, 1200, 1200, 1200, 1200, 1800, 2600, 1400, 1200, 3257, 971, 1525, 448, 364, 181 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 71, 75, 64 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 672, 753, 44, 59, 47 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 768, 406, 252, 21, 42, 38 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 753, 359, 222, 28, 32, 25 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 496, 293, 13, 13, 14 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 797, 468, 6, 9, 19 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 18, 13, 37 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 16, 37, 56 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 35, 13, 47 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 688, 390, 229, 17, 18, 42 },
+ },
+ { // dumb bucket 7
+ { 167, 106, 78, 65, 53, 42, 37, 33, 34, 31, 27, 31, 40, 79, 133 },
+ { 47, 33, 23, 20, 18, 16, 15, 14, 14, 14, 12, 12, 9, 11, 46 },
+ { 24, 16, 13, 11, 9, 7, 7, 7, 6, 5, 5, 5, 5, 10, 47 },
+ { 13, 7, 5, 4, 5, 3, 3, 3, 4, 3, 3, 3, 3, 17, 56 },
+ { 9, 5, 2, 3, 2, 2, 2, 2, 1, 1, 3, 2, 3, 22, 47 },
+ { 11, 6, 3, 2, 2, 1, 1, 1, 1, 1, 1, 2, 2, 10, 22 },
+ { 39, 26, 10, 6, 2, 9, 3, 9, 3, 7, 2, 2, 2, 5, 15 },
+ { 41, 34, 47, 18, 32, 36, 18, 12, 19, 11, 10, 15, 4, 3, 14 },
+ { 225, 78, 134, 117, 138, 95, 69, 35, 49, 44, 26, 33, 2, 4, 19 },
+ { 980, 642, 297, 133, 400, 174, 211, 144, 87, 75, 97, 35, 4, 4, 18 },
+ { 243, 166, 135, 105, 104, 98, 83, 62, 33, 27, 36, 26, 3, 5, 20 },
+ },
+ { // dumb bucket 8
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 725, 501, 66, 29, 46 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 544, 33, 42, 43 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 581, 377, 13, 21, 29 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 515, 334, 11, 9, 12 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 581, 409, 19, 6, 8 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 571, 14, 5, 7 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 13, 6, 12 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 18, 5, 14 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 26, 4, 21 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 33, 5, 25 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 28, 11, 15 },
+ },
+ { // dumb bucket 9
+ { 607, 368, 251, 196, 151, 113, 88, 72, 59, 44, 38, 31, 18, 13, 16 },
+ { 63, 50, 43, 38, 29, 26, 21, 16, 16, 12, 10, 10, 7, 6, 12 },
+ { 27, 20, 18, 14, 12, 11, 9, 7, 5, 5, 5, 4, 3, 2, 5 },
+ { 23, 16, 18, 12, 10, 7, 6, 5, 3, 4, 3, 3, 1, 1, 2 },
+ { 26, 18, 14, 15, 10, 10, 7, 6, 4, 3, 3, 3, 1, 0, 1 },
+ { 28, 16, 17, 16, 13, 10, 9, 6, 4, 3, 2, 2, 1, 0, 1 },
+ { 12, 14, 18, 14, 13, 10, 7, 9, 4, 3, 1, 1, 1, 0, 1 },
+ { 25, 17, 15, 14, 13, 14, 10, 8, 6, 5, 3, 2, 1, 0, 1 },
+ { 23, 20, 14, 12, 12, 11, 8, 5, 5, 3, 3, 2, 1, 0, 1 },
+ { 28, 15, 16, 10, 12, 13, 10, 8, 6, 5, 2, 2, 1, 0, 1 },
+ { 23, 15, 10, 9, 7, 7, 6, 5, 4, 3, 2, 2, 1, 1, 2 },
+ },
+ { // dumb bucket 10
+ { 1200, 938, 452, 605, 396, 273, 301, 585, 396, 366, 451, 432, 368, 347, 317 },
+ { 1200, 1200, 1200, 800, 629, 309, 210, 411, 505, 274, 413, 454, 190, 177, 211 },
+ { 1200, 1200, 1200, 800, 800, 529, 382, 712, 1020, 844, 642, 390, 247, 188, 203 },
+ { 800, 800, 800, 800, 800, 384, 273, 209, 296, 142, 217, 458, 110, 166, 111 },
+ { 800, 800, 800, 800, 495, 323, 252, 182, 151, 136, 120, 108, 28, 73, 96 },
+ { 800, 800, 800, 800, 656, 388, 245, 168, 157, 149, 122, 111, 40, 131, 71 },
+ { 800, 800, 800, 800, 613, 308, 195, 145, 140, 133, 109, 108, 14, 49, 62 },
+ { 800, 800, 800, 800, 552, 321, 210, 163, 173, 145, 155, 142, 19, 48, 111 },
+ { 800, 800, 800, 800, 800, 461, 296, 237, 201, 166, 155, 156, 57, 110, 323 },
+ { 800, 800, 800, 800, 800, 600, 418, 294, 211, 211, 175, 179, 25, 255, 218 },
+ { 800, 800, 800, 428, 235, 166, 109, 83, 54, 45, 39, 38, 4, 19, 97 },
+ },
+ },
+ { // depth bucket 4
+ { // dumb bucket 0
+ { 5151, 4599, 4188, 3776, 3318, 2923, 2542, 2216, 1930, 1687, 1484, 1318, 862, 381, 162 },
+ { 1286, 1292, 1169, 1010, 851, 737, 643, 568, 505, 452, 406, 368, 262, 107, 48 },
+ { 252, 239, 219, 196, 170, 152, 127, 115, 105, 95, 89, 79, 62, 36, 21 },
+ { 136, 111, 93, 80, 66, 58, 53, 47, 43, 39, 37, 34, 27, 17, 12 },
+ { 124, 87, 66, 54, 43, 37, 32, 29, 26, 23, 21, 20, 15, 9, 7 },
+ { 102, 71, 53, 43, 33, 28, 24, 21, 19, 17, 15, 14, 10, 6, 6 },
+ { 103, 73, 56, 44, 34, 29, 24, 21, 19, 17, 15, 14, 10, 6, 7 },
+ { 104, 76, 58, 46, 35, 29, 25, 22, 19, 16, 15, 13, 9, 7, 8 },
+ { 85, 66, 53, 43, 33, 28, 23, 20, 18, 15, 14, 13, 9, 7, 9 },
+ { 81, 62, 47, 38, 30, 24, 20, 17, 15, 14, 12, 11, 7, 5, 8 },
+ { 54, 40, 30, 24, 18, 15, 13, 11, 9, 8, 7, 6, 4, 3, 5 },
+ },
+ { // dumb bucket 1
+ { 3565, 2091, 1397, 841, 864, 912, 909, 796, 699, 653, 691, 723, 840, 2323, 3827 },
+ { 236, 136, 68, 88, 114, 143, 203, 230, 265, 257, 268, 297, 458, 1531, 2414 },
+ { 253, 57, 49, 54, 72, 63, 114, 106, 141, 164, 175, 212, 279, 799, 1498 },
+ { 175, 33, 29, 36, 30, 58, 89, 99, 72, 94, 94, 99, 155, 392, 925 },
+ { 96, 75, 46, 35, 30, 38, 41, 52, 43, 57, 60, 74, 86, 205, 568 },
+ { 69, 31, 27, 19, 23, 42, 23, 32, 26, 42, 37, 52, 44, 130, 464 },
+ { 51, 36, 22, 11, 9, 12, 13, 22, 35, 25, 27, 66, 33, 90, 551 },
+ { 64, 29, 17, 21, 9, 14, 7, 34, 51, 28, 47, 49, 52, 77, 487 },
+ { 119, 34, 20, 13, 10, 12, 11, 12, 12, 35, 22, 32, 34, 67, 621 },
+ { 70, 32, 19, 20, 23, 9, 20, 17, 12, 30, 43, 56, 25, 56, 512 },
+ { 13, 6, 5, 9, 6, 6, 13, 17, 15, 24, 29, 47, 63, 49, 223 },
+ },
+ { // dumb bucket 2
+ { 1883, 1044, 682, 514, 393, 325, 276, 239, 220, 202, 185, 179, 151, 221, 442 },
+ { 186, 155, 114, 100, 85, 73, 64, 60, 56, 53, 50, 49, 48, 89, 246 },
+ { 81, 58, 44, 36, 30, 28, 26, 25, 25, 24, 25, 25, 29, 70, 205 },
+ { 75, 45, 32, 24, 19, 16, 13, 13, 13, 12, 12, 12, 15, 33, 100 },
+ { 88, 46, 32, 21, 17, 14, 13, 10, 9, 9, 7, 8, 9, 17, 60 },
+ { 107, 60, 36, 23, 18, 13, 11, 12, 8, 7, 6, 6, 6, 9, 44 },
+ { 127, 77, 44, 30, 20, 17, 13, 9, 7, 6, 5, 5, 4, 6, 45 },
+ { 119, 72, 43, 29, 21, 18, 12, 10, 8, 6, 5, 5, 3, 4, 52 },
+ { 88, 57, 36, 26, 18, 16, 13, 8, 6, 6, 5, 5, 4, 4, 63 },
+ { 49, 34, 26, 20, 15, 13, 10, 9, 8, 4, 3, 4, 2, 2, 50 },
+ { 13, 9, 7, 5, 5, 4, 4, 3, 3, 2, 2, 1, 1, 1, 22 },
+ },
+ { // dumb bucket 3
+ { 1089, 563, 357, 228, 164, 108, 84, 72, 54, 48, 45, 38, 35, 58, 105 },
+ { 49, 33, 24, 18, 13, 11, 9, 7, 6, 6, 6, 5, 7, 35, 138 },
+ { 66, 42, 29, 23, 16, 14, 11, 9, 8, 7, 6, 6, 6, 18, 52 },
+ { 90, 52, 37, 39, 25, 19, 22, 14, 12, 17, 11, 12, 6, 7, 19 },
+ { 144, 76, 50, 39, 37, 24, 24, 18, 13, 17, 9, 11, 6, 5, 14 },
+ { 109, 67, 46, 42, 26, 21, 20, 17, 11, 10, 10, 10, 6, 3, 9 },
+ { 123, 77, 54, 36, 23, 19, 18, 14, 9, 7, 8, 7, 4, 2, 12 },
+ { 143, 86, 64, 36, 25, 21, 14, 13, 10, 9, 7, 7, 5, 2, 13 },
+ { 95, 56, 31, 23, 16, 14, 14, 11, 6, 8, 6, 7, 4, 4, 16 },
+ { 67, 44, 24, 22, 25, 13, 10, 7, 3, 6, 7, 7, 4, 3, 15 },
+ { 23, 16, 11, 10, 6, 5, 5, 4, 3, 3, 3, 2, 2, 2, 5 },
+ },
+ { // dumb bucket 4
+ { 700, 445, 307, 230, 178, 131, 99, 89, 69, 62, 60, 64, 66, 78, 120 },
+ { 35, 43, 36, 24, 17, 17, 17, 14, 14, 14, 12, 12, 15, 40, 84 },
+ { 31, 26, 23, 29, 21, 17, 14, 11, 11, 9, 10, 11, 11, 23, 51 },
+ { 26, 22, 23, 16, 17, 15, 10, 10, 7, 7, 7, 7, 6, 8, 26 },
+ { 35, 31, 24, 26, 13, 10, 12, 9, 9, 7, 6, 6, 5, 4, 16 },
+ { 38, 17, 13, 10, 7, 7, 9, 6, 8, 6, 4, 4, 3, 3, 13 },
+ { 24, 21, 14, 11, 13, 9, 7, 7, 5, 5, 5, 3, 4, 3, 20 },
+ { 34, 26, 24, 14, 13, 12, 10, 8, 7, 7, 8, 4, 3, 2, 26 },
+ { 36, 24, 16, 11, 10, 7, 8, 6, 4, 4, 4, 7, 3, 2, 30 },
+ { 25, 21, 16, 15, 9, 7, 9, 5, 4, 5, 11, 5, 3, 3, 29 },
+ { 12, 12, 10, 6, 4, 3, 2, 2, 3, 2, 1, 2, 2, 1, 12 },
+ },
+ { // dumb bucket 5
+ { 631, 271, 139, 100, 76, 59, 55, 51, 51, 65, 77, 93, 90, 197, 433 },
+ { 61, 33, 24, 17, 13, 12, 9, 8, 7, 7, 6, 6, 5, 17, 98 },
+ { 52, 25, 23, 15, 13, 10, 8, 8, 6, 6, 5, 5, 5, 20, 120 },
+ { 60, 28, 19, 15, 12, 7, 6, 5, 6, 5, 4, 4, 4, 30, 176 },
+ { 123, 54, 31, 18, 13, 9, 8, 9, 7, 4, 6, 3, 5, 47, 230 },
+ { 195, 71, 40, 24, 21, 13, 9, 8, 5, 6, 7, 6, 6, 43, 181 },
+ { 190, 85, 45, 24, 15, 14, 9, 11, 6, 6, 8, 4, 4, 23, 96 },
+ { 278, 130, 60, 35, 21, 17, 11, 10, 9, 6, 7, 5, 4, 12, 58 },
+ { 190, 101, 57, 29, 18, 12, 10, 7, 7, 5, 4, 3, 2, 6, 48 },
+ { 229, 104, 57, 20, 21, 13, 11, 8, 10, 6, 8, 10, 4, 4, 37 },
+ { 66, 37, 22, 13, 8, 7, 5, 5, 4, 3, 4, 4, 2, 4, 35 },
+ },
+ { // dumb bucket 6
+ { 1200, 1200, 1200, 1200, 1200, 2000, 1200, 1200, 1800, 800, 1400, 1573, 367, 294, 164 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 57, 54, 46 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 50, 49, 40 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 1200, 626, 38, 47, 34 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 923, 24, 20, 23 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 774, 21, 10, 13 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 17, 10, 19 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 43, 14, 41 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 40, 20, 38 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 73, 28, 48 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 627, 14, 17, 50 },
+ },
+ { // dumb bucket 7
+ { 186, 92, 60, 46, 41, 32, 26, 20, 21, 19, 19, 20, 24, 53, 114 },
+ { 43, 21, 17, 16, 12, 11, 11, 9, 9, 8, 7, 7, 6, 8, 33 },
+ { 32, 20, 13, 10, 9, 6, 6, 5, 6, 6, 5, 4, 4, 9, 40 },
+ { 15, 13, 8, 5, 5, 4, 3, 2, 2, 2, 3, 3, 3, 15, 50 },
+ { 11, 5, 4, 4, 3, 2, 2, 2, 2, 2, 1, 1, 2, 18, 40 },
+ { 8, 11, 2, 2, 2, 1, 2, 3, 2, 3, 1, 2, 1, 8, 21 },
+ { 72, 33, 18, 11, 9, 5, 11, 11, 4, 7, 6, 7, 2, 5, 15 },
+ { 143, 65, 74, 51, 28, 59, 25, 23, 40, 39, 21, 46, 3, 3, 16 },
+ { 708, 387, 175, 151, 132, 124, 114, 105, 96, 128, 74, 62, 10, 4, 16 },
+ { 970, 763, 682, 420, 338, 318, 193, 168, 151, 136, 118, 92, 5, 4, 22 },
+ { 623, 371, 206, 190, 163, 228, 129, 115, 93, 73, 56, 39, 3, 3, 24 },
+ },
+ { // dumb bucket 8
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 71, 21, 41 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 17, 35, 35 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 17, 19, 28 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 18, 10, 11 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 19, 9, 7 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 41, 9, 15 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 57, 3, 15 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 44, 17, 18 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 62, 7, 32 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 76, 17, 31 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 33, 12, 23 },
+ },
+ { // dumb bucket 9
+ { 615, 302, 210, 172, 104, 78, 60, 45, 39, 28, 25, 20, 11, 8, 13 },
+ { 46, 39, 33, 27, 21, 18, 15, 14, 11, 10, 8, 6, 5, 4, 10 },
+ { 38, 26, 14, 12, 13, 10, 8, 7, 5, 6, 6, 4, 3, 2, 6 },
+ { 26, 19, 20, 17, 12, 11, 6, 5, 5, 4, 3, 3, 1, 1, 2 },
+ { 40, 27, 25, 19, 13, 12, 9, 8, 5, 4, 3, 3, 1, 0, 1 },
+ { 30, 21, 22, 21, 13, 12, 11, 9, 6, 3, 3, 2, 1, 0, 1 },
+ { 44, 22, 23, 20, 17, 13, 11, 8, 7, 4, 3, 3, 1, 0, 1 },
+ { 56, 34, 23, 28, 17, 19, 17, 12, 7, 6, 3, 4, 1, 0, 1 },
+ { 59, 36, 25, 29, 21, 17, 14, 9, 7, 5, 3, 3, 2, 1, 1 },
+ { 41, 33, 23, 16, 17, 18, 17, 14, 12, 7, 6, 5, 2, 1, 1 },
+ { 29, 20, 15, 12, 8, 11, 8, 8, 7, 5, 4, 4, 1, 1, 2 },
+ },
+ { // dumb bucket 10
+ { 1200, 800, 398, 505, 352, 220, 399, 303, 178, 219, 331, 356, 224, 241, 186 },
+ { 800, 800, 800, 800, 800, 685, 481, 367, 250, 437, 400, 453, 135, 108, 52 },
+ { 1200, 1200, 1200, 800, 800, 800, 800, 1095, 634, 451, 662, 900, 249, 175, 139 },
+ { 800, 800, 800, 800, 800, 800, 611, 465, 454, 378, 704, 256, 88, 73, 46 },
+ { 800, 800, 800, 800, 800, 800, 528, 436, 396, 324, 271, 255, 92, 94, 88 },
+ { 800, 800, 800, 800, 800, 800, 583, 418, 401, 415, 324, 280, 31, 262, 156 },
+ { 800, 800, 800, 800, 800, 632, 385, 340, 343, 362, 305, 284, 62, 117, 93 },
+ { 800, 800, 800, 800, 800, 787, 520, 401, 415, 408, 364, 373, 48, 220, 132 },
+ { 800, 800, 800, 800, 800, 800, 673, 525, 542, 426, 400, 391, 53, 80, 627 },
+ { 800, 800, 800, 800, 800, 800, 800, 726, 510, 454, 423, 413, 55, 459, 154 },
+ { 800, 800, 800, 800, 681, 442, 289, 195, 126, 114, 90, 90, 11, 30, 138 },
+ },
+ },
+ { // depth bucket 5
+ { // dumb bucket 0
+ { 3966, 3723, 3466, 3123, 2675, 2340, 2030, 1761, 1530, 1341, 1175, 1044, 687, 306, 144 },
+ { 1075, 1079, 976, 839, 679, 578, 500, 437, 385, 340, 304, 273, 190, 91, 39 },
+ { 233, 221, 203, 180, 154, 128, 114, 103, 93, 85, 78, 70, 53, 29, 16 },
+ { 129, 102, 83, 71, 59, 52, 46, 41, 38, 34, 32, 29, 22, 13, 9 },
+ { 109, 75, 57, 46, 37, 32, 27, 25, 22, 19, 18, 16, 12, 7, 6 },
+ { 89, 62, 47, 38, 30, 25, 22, 20, 17, 15, 13, 13, 9, 5, 4 },
+ { 93, 66, 52, 42, 33, 27, 23, 20, 18, 16, 15, 12, 9, 5, 6 },
+ { 94, 68, 55, 43, 34, 29, 24, 20, 18, 16, 14, 12, 9, 5, 8 },
+ { 78, 62, 50, 41, 33, 27, 23, 19, 16, 15, 13, 12, 9, 6, 10 },
+ { 76, 57, 45, 36, 29, 25, 20, 17, 14, 13, 10, 10, 6, 5, 8 },
+ { 57, 41, 32, 25, 19, 16, 13, 11, 8, 7, 6, 5, 4, 3, 4 },
+ },
+ { // dumb bucket 1
+ { 2260, 1158, 752, 562, 584, 585, 673, 599, 796, 797, 570, 615, 704, 1925, 3032 },
+ { 273, 114, 101, 95, 136, 68, 224, 185, 180, 169, 209, 203, 348, 1215, 1872 },
+ { 178, 78, 43, 31, 89, 212, 36, 111, 98, 119, 119, 212, 215, 695, 1211 },
+ { 206, 63, 38, 69, 71, 74, 50, 56, 50, 61, 93, 113, 142, 332, 833 },
+ { 214, 52, 47, 34, 19, 30, 23, 20, 44, 34, 73, 58, 62, 181, 535 },
+ { 144, 63, 36, 46, 36, 38, 34, 28, 27, 65, 28, 29, 43, 137, 474 },
+ { 115, 78, 31, 22, 18, 36, 14, 13, 19, 23, 54, 57, 30, 114, 591 },
+ { 142, 61, 36, 25, 19, 28, 15, 13, 32, 23, 47, 42, 35, 74, 605 },
+ { 170, 71, 42, 28, 21, 26, 16, 22, 26, 61, 23, 34, 20, 75, 818 },
+ { 148, 68, 40, 28, 22, 18, 17, 40, 26, 27, 32, 44, 20, 50, 602 },
+ { 30, 13, 18, 12, 13, 8, 17, 14, 13, 26, 30, 37, 80, 70, 293 },
+ },
+ { // dumb bucket 2
+ { 1306, 755, 517, 390, 297, 243, 219, 184, 165, 142, 136, 134, 126, 188, 363 },
+ { 143, 113, 92, 80, 67, 59, 57, 51, 46, 43, 41, 41, 38, 68, 181 },
+ { 74, 55, 43, 34, 26, 27, 24, 23, 21, 21, 22, 21, 25, 59, 168 },
+ { 80, 41, 28, 23, 19, 16, 13, 12, 13, 12, 11, 12, 13, 27, 88 },
+ { 87, 48, 29, 20, 16, 16, 10, 10, 8, 7, 6, 6, 7, 15, 57 },
+ { 118, 60, 36, 26, 19, 17, 10, 10, 7, 8, 7, 5, 5, 8, 48 },
+ { 154, 91, 57, 34, 18, 18, 16, 13, 9, 6, 7, 5, 4, 6, 53 },
+ { 159, 87, 48, 38, 27, 19, 15, 12, 10, 5, 6, 5, 3, 4, 63 },
+ { 83, 67, 47, 33, 24, 23, 18, 11, 10, 6, 5, 6, 4, 5, 85 },
+ { 57, 45, 25, 25, 23, 14, 11, 14, 10, 6, 4, 3, 2, 3, 69 },
+ { 19, 13, 9, 7, 6, 5, 4, 4, 4, 3, 2, 2, 1, 1, 26 },
+ },
+ { // dumb bucket 3
+ { 827, 415, 266, 164, 113, 81, 58, 56, 44, 36, 37, 34, 28, 52, 100 },
+ { 36, 36, 22, 18, 15, 12, 12, 5, 6, 5, 4, 5, 6, 32, 104 },
+ { 57, 42, 27, 21, 16, 13, 13, 10, 9, 7, 9, 6, 5, 20, 62 },
+ { 89, 60, 46, 35, 28, 25, 22, 15, 12, 17, 12, 8, 5, 6, 25 },
+ { 144, 78, 45, 37, 43, 28, 18, 21, 11, 13, 9, 11, 5, 4, 18 },
+ { 117, 73, 31, 48, 34, 24, 28, 22, 14, 25, 8, 15, 4, 3, 12 },
+ { 129, 80, 51, 45, 26, 16, 19, 18, 14, 14, 12, 10, 5, 2, 14 },
+ { 127, 85, 47, 43, 27, 20, 16, 12, 10, 8, 5, 6, 5, 3, 13 },
+ { 114, 48, 45, 41, 29, 16, 9, 8, 12, 9, 10, 7, 4, 4, 24 },
+ { 79, 44, 31, 24, 14, 11, 7, 9, 9, 9, 8, 6, 5, 4, 13 },
+ { 23, 19, 11, 9, 7, 6, 4, 4, 3, 3, 2, 2, 2, 1, 7 },
+ },
+ { // dumb bucket 4
+ { 493, 351, 230, 160, 125, 96, 73, 59, 54, 45, 44, 50, 55, 65, 113 },
+ { 47, 35, 30, 23, 17, 16, 12, 12, 13, 13, 10, 12, 13, 31, 74 },
+ { 19, 27, 29, 27, 14, 20, 15, 10, 9, 8, 9, 9, 9, 20, 49 },
+ { 25, 22, 15, 12, 11, 16, 16, 10, 9, 11, 8, 9, 5, 7, 26 },
+ { 52, 23, 16, 14, 17, 11, 14, 14, 8, 12, 5, 6, 4, 3, 16 },
+ { 24, 16, 12, 8, 6, 9, 7, 5, 5, 6, 4, 4, 3, 2, 14 },
+ { 24, 13, 13, 9, 12, 10, 7, 5, 6, 7, 7, 9, 3, 2, 20 },
+ { 22, 13, 22, 13, 12, 10, 8, 11, 4, 6, 5, 4, 2, 2, 27 },
+ { 39, 20, 13, 8, 12, 9, 6, 5, 3, 4, 6, 6, 3, 2, 36 },
+ { 33, 31, 26, 14, 15, 5, 5, 9, 6, 7, 5, 4, 4, 2, 29 },
+ { 18, 25, 16, 10, 6, 5, 2, 3, 3, 2, 2, 2, 2, 1, 11 },
+ },
+ { // dumb bucket 5
+ { 473, 207, 111, 73, 61, 48, 37, 33, 32, 28, 30, 37, 56, 158, 360 },
+ { 47, 31, 18, 13, 13, 11, 7, 8, 7, 6, 6, 5, 5, 13, 78 },
+ { 48, 23, 20, 15, 11, 7, 8, 6, 4, 5, 4, 2, 3, 17, 101 },
+ { 49, 31, 20, 15, 14, 9, 9, 6, 4, 6, 3, 4, 4, 25, 147 },
+ { 124, 56, 40, 17, 12, 9, 6, 6, 5, 5, 4, 4, 3, 42, 183 },
+ { 130, 68, 38, 20, 25, 14, 8, 3, 5, 7, 6, 5, 3, 40, 154 },
+ { 205, 88, 53, 26, 26, 12, 12, 7, 7, 11, 6, 6, 3, 23, 100 },
+ { 247, 143, 43, 53, 16, 20, 14, 14, 7, 9, 8, 7, 4, 11, 65 },
+ { 178, 104, 52, 36, 18, 13, 9, 8, 6, 6, 5, 5, 2, 7, 68 },
+ { 244, 102, 53, 36, 27, 15, 7, 7, 8, 10, 13, 10, 4, 4, 54 },
+ { 85, 52, 28, 16, 11, 8, 5, 5, 5, 3, 4, 3, 3, 3, 36 },
+ },
+ { // dumb bucket 6
+ { 1200, 1200, 1200, 1200, 1200, 1200, 3600, 1200, 1200, 800, 800, 800, 256, 227, 131 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 52, 50, 40 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 67, 45, 36 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 33, 30, 33 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 24, 28, 20 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 30, 9, 13 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 38, 7, 24 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 67, 17, 64 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 94, 27, 63 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 161, 30, 61 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 29, 33, 63 },
+ },
+ { // dumb bucket 7
+ { 154, 83, 56, 43, 39, 25, 22, 23, 21, 19, 16, 22, 21, 47, 100 },
+ { 34, 20, 14, 12, 10, 9, 7, 7, 7, 6, 7, 7, 4, 6, 26 },
+ { 19, 14, 18, 9, 7, 6, 5, 6, 5, 5, 5, 4, 3, 7, 35 },
+ { 13, 16, 6, 5, 4, 3, 3, 3, 3, 2, 3, 3, 2, 14, 45 },
+ { 10, 9, 4, 4, 5, 2, 1, 2, 1, 2, 4, 2, 2, 20, 39 },
+ { 18, 8, 7, 6, 3, 3, 2, 2, 2, 2, 2, 9, 3, 10, 25 },
+ { 165, 48, 19, 26, 13, 20, 16, 10, 10, 10, 10, 9, 2, 8, 22 },
+ { 396, 323, 117, 91, 74, 66, 59, 59, 103, 57, 55, 78, 4, 4, 17 },
+ { 800, 501, 362, 396, 312, 309, 284, 266, 255, 214, 176, 149, 10, 3, 28 },
+ { 800, 1642, 564, 543, 609, 555, 471, 440, 394, 325, 279, 204, 8, 3, 27 },
+ { 1800, 647, 801, 488, 632, 398, 334, 292, 253, 193, 149, 99, 4, 5, 29 },
+ },
+ { // dumb bucket 8
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 70, 33, 33 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 33, 44, 36 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 34, 24, 29 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 38, 7, 13 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 38, 5, 6 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 50, 4, 15 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 69, 4, 19 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 98, 17, 21 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 137, 11, 35 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 166, 15, 35 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 50, 12, 21 },
+ },
+ { // dumb bucket 9
+ { 372, 220, 167, 123, 90, 68, 58, 42, 37, 26, 21, 19, 11, 8, 12 },
+ { 41, 33, 26, 28, 17, 15, 13, 12, 11, 7, 8, 8, 5, 3, 8 },
+ { 31, 23, 16, 15, 11, 11, 8, 9, 6, 4, 5, 4, 2, 2, 5 },
+ { 23, 25, 18, 17, 12, 8, 6, 6, 3, 2, 2, 3, 1, 1, 2 },
+ { 41, 20, 24, 22, 13, 8, 7, 8, 6, 3, 3, 3, 1, 0, 1 },
+ { 36, 31, 20, 20, 16, 18, 12, 10, 7, 4, 3, 3, 1, 0, 1 },
+ { 38, 39, 23, 22, 25, 15, 16, 10, 9, 5, 5, 3, 1, 0, 1 },
+ { 83, 41, 38, 35, 32, 29, 22, 24, 10, 13, 4, 4, 1, 0, 1 },
+ { 49, 25, 28, 42, 44, 38, 25, 14, 9, 10, 7, 5, 1, 1, 1 },
+ { 61, 48, 39, 48, 60, 60, 46, 34, 21, 8, 7, 4, 1, 1, 1 },
+ { 46, 30, 20, 22, 15, 18, 18, 13, 7, 10, 6, 4, 1, 1, 3 },
+ },
+ { // dumb bucket 10
+ { 1200, 800, 531, 445, 301, 205, 549, 221, 305, 370, 378, 188, 221, 174, 223 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 538, 631, 489, 427, 85, 82, 53 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 1200, 1163, 149, 135, 135 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 789, 703, 607, 109, 52, 150 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 661, 602, 755, 88, 81, 185 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 673, 576, 108, 150, 119 },
+ { 800, 800, 800, 800, 800, 800, 800, 759, 800, 800, 783, 636, 80, 142, 413 },
+ { 800, 800, 800, 800, 800, 800, 800, 794, 800, 800, 800, 800, 106, 280, 388 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 122, 428, 472 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 762, 123, 146, 398 },
+ { 800, 800, 800, 800, 800, 800, 756, 523, 302, 265, 203, 209, 25, 26, 193 },
+ },
+ },
+ { // depth bucket 6
+ { // dumb bucket 0
+ { 2753, 2925, 2922, 2709, 2389, 2104, 1838, 1591, 1389, 1212, 1074, 951, 628, 277, 92 },
+ { 1044, 1097, 1023, 888, 714, 605, 515, 448, 391, 345, 306, 275, 192, 78, 31 },
+ { 238, 229, 208, 184, 157, 129, 115, 102, 90, 82, 75, 67, 51, 28, 14 },
+ { 129, 104, 87, 74, 61, 53, 46, 42, 38, 34, 31, 29, 22, 12, 8 },
+ { 109, 77, 58, 47, 38, 33, 28, 25, 23, 20, 19, 17, 12, 7, 5 },
+ { 85, 60, 47, 38, 29, 25, 23, 19, 17, 16, 14, 12, 9, 5, 4 },
+ { 86, 63, 49, 39, 31, 26, 22, 19, 16, 15, 13, 12, 9, 5, 4 },
+ { 86, 61, 49, 39, 30, 24, 21, 18, 15, 14, 12, 11, 7, 4, 5 },
+ { 65, 52, 44, 36, 28, 25, 21, 17, 15, 13, 12, 11, 7, 4, 5 },
+ { 64, 51, 40, 32, 25, 21, 17, 16, 13, 10, 9, 8, 5, 4, 5 },
+ { 56, 40, 31, 25, 19, 15, 13, 10, 8, 7, 6, 5, 3, 2, 3 },
+ },
+ { // dumb bucket 1
+ { 1182, 951, 544, 423, 501, 452, 554, 462, 594, 651, 420, 483, 580, 1636, 2693 },
+ { 358, 157, 90, 92, 155, 67, 242, 137, 137, 196, 172, 184, 280, 1250, 2048 },
+ { 353, 148, 124, 58, 48, 76, 90, 109, 74, 101, 170, 134, 180, 668, 1292 },
+ { 298, 129, 76, 55, 47, 74, 120, 53, 104, 94, 78, 117, 144, 353, 892 },
+ { 243, 105, 63, 45, 39, 53, 56, 42, 80, 63, 76, 97, 64, 220, 646 },
+ { 325, 136, 75, 53, 40, 34, 31, 26, 38, 25, 44, 26, 41, 165, 477 },
+ { 256, 109, 66, 46, 38, 33, 30, 41, 41, 29, 45, 32, 34, 149, 634 },
+ { 331, 131, 73, 51, 39, 33, 48, 29, 77, 49, 52, 65, 34, 72, 433 },
+ { 403, 168, 93, 62, 45, 38, 52, 33, 52, 54, 37, 63, 24, 74, 639 },
+ { 325, 145, 88, 61, 47, 40, 37, 54, 38, 41, 41, 70, 30, 50, 522 },
+ { 77, 31, 26, 11, 9, 7, 11, 10, 30, 32, 28, 96, 85, 82, 315 },
+ },
+ { // dumb bucket 2
+ { 1155, 675, 459, 357, 260, 211, 177, 139, 132, 114, 106, 107, 96, 141, 324 },
+ { 141, 116, 97, 84, 70, 58, 53, 50, 44, 43, 39, 38, 35, 67, 207 },
+ { 86, 63, 47, 35, 30, 26, 28, 25, 21, 23, 25, 24, 23, 55, 180 },
+ { 63, 41, 32, 26, 23, 19, 16, 14, 13, 13, 13, 13, 14, 27, 97 },
+ { 83, 41, 34, 21, 17, 15, 13, 13, 9, 9, 8, 9, 8, 15, 66 },
+ { 110, 63, 33, 32, 18, 16, 14, 9, 9, 8, 8, 7, 7, 9, 53 },
+ { 175, 70, 44, 32, 22, 19, 13, 9, 8, 6, 4, 6, 5, 6, 52 },
+ { 144, 88, 54, 39, 26, 16, 18, 12, 7, 8, 5, 8, 4, 5, 53 },
+ { 115, 69, 52, 35, 31, 25, 13, 12, 6, 4, 3, 4, 7, 6, 81 },
+ { 58, 41, 42, 27, 17, 11, 8, 13, 14, 10, 5, 5, 2, 3, 58 },
+ { 22, 18, 11, 10, 7, 5, 5, 4, 3, 2, 2, 2, 1, 2, 23 },
+ },
+ { // dumb bucket 3
+ { 599, 344, 225, 120, 82, 72, 60, 48, 30, 26, 24, 26, 19, 36, 103 },
+ { 40, 30, 22, 20, 11, 10, 6, 6, 8, 8, 5, 4, 6, 27, 107 },
+ { 49, 44, 19, 21, 12, 11, 9, 8, 8, 5, 6, 5, 4, 21, 83 },
+ { 92, 59, 46, 24, 23, 19, 10, 10, 14, 17, 14, 9, 7, 7, 51 },
+ { 117, 75, 38, 42, 35, 34, 24, 14, 11, 7, 13, 11, 6, 3, 26 },
+ { 110, 62, 42, 33, 18, 24, 21, 18, 12, 19, 5, 6, 5, 2, 14 },
+ { 83, 92, 45, 29, 27, 19, 54, 9, 10, 6, 7, 7, 2, 2, 8 },
+ { 111, 83, 44, 44, 16, 20, 16, 14, 9, 9, 10, 5, 3, 3, 14 },
+ { 42, 69, 30, 29, 17, 13, 9, 6, 13, 14, 12, 5, 5, 4, 27 },
+ { 92, 38, 22, 27, 11, 9, 11, 12, 11, 12, 12, 9, 2, 3, 22 },
+ { 24, 16, 10, 10, 6, 7, 5, 4, 4, 4, 3, 5, 3, 2, 5 },
+ },
+ { // dumb bucket 4
+ { 446, 242, 215, 145, 96, 78, 62, 46, 36, 30, 33, 37, 44, 51, 101 },
+ { 38, 44, 26, 33, 23, 14, 12, 13, 12, 9, 9, 12, 10, 30, 80 },
+ { 38, 19, 35, 22, 20, 10, 12, 11, 10, 9, 7, 7, 9, 19, 52 },
+ { 17, 15, 22, 14, 12, 12, 11, 11, 5, 5, 9, 4, 4, 7, 27 },
+ { 35, 23, 15, 9, 20, 7, 7, 11, 9, 17, 7, 5, 4, 4, 17 },
+ { 25, 26, 19, 14, 6, 5, 22, 7, 5, 5, 6, 8, 3, 2, 15 },
+ { 13, 16, 11, 12, 6, 12, 11, 6, 8, 7, 6, 3, 2, 2, 23 },
+ { 87, 29, 15, 12, 9, 8, 11, 5, 8, 7, 6, 4, 3, 2, 27 },
+ { 57, 39, 26, 8, 11, 10, 6, 5, 5, 6, 4, 2, 3, 2, 29 },
+ { 99, 52, 31, 22, 21, 5, 4, 12, 6, 3, 9, 3, 3, 2, 28 },
+ { 12, 15, 13, 10, 5, 3, 2, 2, 3, 2, 3, 3, 2, 1, 13 },
+ },
+ { // dumb bucket 5
+ { 348, 166, 91, 61, 47, 36, 28, 23, 24, 16, 26, 23, 41, 129, 316 },
+ { 42, 26, 20, 11, 13, 9, 9, 6, 5, 6, 4, 4, 3, 14, 94 },
+ { 46, 32, 28, 17, 10, 5, 8, 5, 6, 5, 4, 5, 4, 17, 111 },
+ { 62, 26, 15, 24, 11, 7, 7, 7, 7, 7, 4, 4, 3, 21, 123 },
+ { 132, 58, 26, 28, 15, 9, 8, 6, 8, 6, 4, 4, 3, 39, 162 },
+ { 153, 46, 30, 30, 17, 12, 5, 6, 3, 4, 4, 8, 4, 48, 155 },
+ { 151, 83, 57, 35, 24, 22, 16, 14, 6, 8, 9, 4, 3, 22, 120 },
+ { 227, 83, 44, 37, 22, 13, 9, 11, 19, 11, 14, 5, 3, 15, 56 },
+ { 197, 83, 49, 25, 9, 21, 14, 10, 11, 8, 8, 12, 2, 8, 48 },
+ { 158, 60, 65, 30, 17, 22, 15, 16, 15, 6, 22, 17, 5, 5, 46 },
+ { 96, 43, 24, 17, 14, 9, 6, 6, 4, 4, 2, 3, 2, 4, 46 },
+ },
+ { // dumb bucket 6
+ { 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 800, 800, 183, 183, 140 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 195, 52, 41 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 54, 42, 42 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 1200, 40, 34, 34 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 37, 17, 27 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 55, 18, 16 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 102, 8, 32 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 161, 13, 38 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 210, 20, 64 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 239, 29, 125 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 75, 14, 81 },
+ },
+ { // dumb bucket 7
+ { 132, 88, 42, 38, 33, 22, 17, 18, 20, 18, 18, 15, 14, 36, 91 },
+ { 43, 20, 16, 15, 10, 14, 12, 9, 8, 6, 7, 7, 4, 6, 30 },
+ { 31, 21, 11, 11, 7, 10, 5, 6, 7, 5, 5, 6, 3, 7, 35 },
+ { 30, 23, 15, 7, 6, 7, 9, 4, 6, 3, 6, 6, 2, 12, 44 },
+ { 30, 12, 8, 5, 3, 6, 7, 3, 2, 4, 4, 4, 3, 14, 38 },
+ { 51, 22, 20, 10, 8, 8, 7, 7, 7, 7, 7, 6, 6, 9, 24 },
+ { 224, 224, 61, 69, 38, 34, 32, 31, 30, 29, 29, 28, 3, 7, 29 },
+ { 800, 627, 376, 523, 395, 221, 206, 192, 178, 262, 157, 166, 17, 4, 17 },
+ { 800, 800, 800, 800, 800, 748, 714, 645, 710, 551, 495, 364, 16, 4, 20 },
+ { 800, 800, 1200, 800, 800, 800, 800, 800, 1200, 800, 707, 547, 19, 3, 22 },
+ { 800, 1200, 800, 800, 800, 800, 800, 800, 729, 546, 369, 272, 9, 6, 25 },
+ },
+ { // dumb bucket 8
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 137, 66, 43 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 81, 35, 35 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 85, 23, 30 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 100, 26, 10 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 97, 5, 8 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 124, 6, 13 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 188, 16, 46 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 253, 16, 35 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 356, 26, 30 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 403, 35, 77 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 123, 12, 36 },
+ },
+ { // dumb bucket 9
+ { 408, 220, 140, 89, 74, 59, 43, 36, 28, 23, 19, 14, 8, 6, 10 },
+ { 48, 33, 39, 26, 21, 15, 14, 9, 10, 9, 6, 6, 3, 4, 8 },
+ { 64, 39, 23, 17, 15, 12, 14, 13, 5, 5, 5, 6, 3, 2, 5 },
+ { 49, 12, 21, 19, 14, 19, 5, 3, 5, 3, 2, 3, 1, 1, 2 },
+ { 43, 19, 17, 19, 8, 7, 4, 9, 5, 2, 1, 3, 1, 0, 1 },
+ { 34, 22, 22, 18, 13, 11, 12, 4, 8, 3, 1, 2, 1, 0, 1 },
+ { 50, 27, 30, 20, 17, 13, 5, 8, 12, 6, 4, 4, 1, 0, 1 },
+ { 71, 54, 36, 46, 43, 44, 21, 24, 11, 4, 8, 6, 1, 0, 2 },
+ { 65, 62, 58, 39, 55, 28, 30, 22, 13, 14, 19, 15, 1, 0, 2 },
+ { 48, 46, 67, 83, 64, 54, 57, 31, 22, 20, 21, 10, 2, 1, 1 },
+ { 70, 57, 33, 35, 29, 27, 20, 14, 14, 7, 6, 2, 1, 1, 3 },
+ },
+ { // dumb bucket 10
+ { 800, 800, 800, 679, 479, 371, 459, 268, 396, 362, 201, 266, 171, 231, 103 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 1200, 126, 84, 256 },
+ { 1200, 1200, 1200, 800, 800, 800, 800, 800, 800, 800, 1200, 800, 314, 249, 456 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 200, 76, 340 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 1200, 129, 231, 247 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 193, 162, 339 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 196, 192, 613 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 260, 357, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 292, 422, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 275, 307, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 716, 684, 438, 490, 62, 118, 202 },
+ },
+ },
+ { // depth bucket 7
+ { // dumb bucket 0
+ { 1583, 1810, 1904, 1833, 1655, 1482, 1306, 1145, 1002, 883, 777, 693, 463, 208, 78 },
+ { 860, 946, 907, 799, 661, 560, 480, 412, 359, 317, 281, 252, 172, 63, 22 },
+ { 221, 214, 198, 176, 150, 123, 108, 95, 82, 75, 69, 64, 45, 23, 11 },
+ { 118, 100, 85, 69, 56, 50, 44, 38, 35, 32, 27, 26, 19, 11, 7 },
+ { 95, 65, 52, 42, 35, 30, 25, 22, 20, 19, 17, 16, 11, 5, 4 },
+ { 72, 53, 40, 34, 27, 23, 20, 17, 15, 14, 12, 11, 8, 4, 3 },
+ { 72, 52, 41, 35, 28, 23, 20, 18, 16, 14, 13, 11, 8, 4, 3 },
+ { 67, 49, 41, 32, 27, 21, 19, 17, 14, 12, 12, 10, 6, 4, 3 },
+ { 52, 45, 39, 30, 25, 20, 17, 16, 13, 11, 11, 9, 7, 4, 4 },
+ { 56, 42, 33, 26, 22, 19, 16, 13, 10, 11, 8, 8, 5, 3, 4 },
+ { 51, 37, 28, 23, 17, 14, 12, 9, 7, 6, 5, 5, 3, 2, 3 },
+ },
+ { // dumb bucket 1
+ { 788, 465, 311, 285, 313, 321, 762, 277, 303, 298, 206, 316, 419, 1350, 2263 },
+ { 1400, 344, 198, 199, 196, 243, 361, 158, 245, 308, 131, 186, 246, 1071, 1710 },
+ { 710, 314, 170, 120, 234, 93, 132, 199, 385, 95, 182, 156, 161, 644, 1223 },
+ { 684, 301, 168, 121, 101, 92, 88, 81, 109, 65, 128, 113, 127, 384, 921 },
+ { 575, 233, 202, 96, 80, 71, 115, 87, 86, 118, 161, 46, 68, 262, 694 },
+ { 753, 302, 165, 113, 86, 69, 64, 121, 81, 80, 311, 56, 50, 221, 492 },
+ { 494, 224, 132, 92, 75, 66, 63, 59, 62, 65, 118, 106, 35, 122, 830 },
+ { 675, 265, 151, 106, 78, 66, 62, 56, 64, 100, 71, 77, 29, 58, 542 },
+ { 800, 351, 194, 131, 95, 82, 76, 71, 77, 80, 125, 82, 35, 72, 761 },
+ { 800, 375, 209, 142, 102, 85, 77, 72, 74, 80, 145, 83, 36, 74, 666 },
+ { 211, 79, 42, 27, 20, 16, 20, 30, 42, 42, 48, 80, 93, 109, 427 },
+ },
+ { // dumb bucket 2
+ { 751, 457, 326, 262, 200, 181, 145, 126, 111, 106, 97, 91, 81, 120, 271 },
+ { 123, 85, 92, 74, 62, 61, 50, 47, 39, 38, 41, 33, 28, 51, 161 },
+ { 76, 63, 38, 34, 28, 29, 25, 20, 24, 25, 24, 20, 23, 48, 155 },
+ { 69, 47, 35, 33, 25, 20, 20, 18, 15, 11, 14, 12, 13, 24, 90 },
+ { 72, 52, 32, 30, 21, 19, 15, 15, 12, 8, 9, 7, 8, 13, 65 },
+ { 92, 30, 44, 31, 22, 16, 11, 11, 7, 8, 5, 10, 6, 9, 59 },
+ { 143, 74, 50, 38, 27, 17, 26, 10, 10, 9, 5, 11, 5, 5, 53 },
+ { 146, 107, 59, 34, 37, 14, 17, 12, 8, 6, 9, 9, 4, 4, 56 },
+ { 151, 60, 79, 32, 23, 22, 21, 18, 7, 9, 8, 5, 7, 7, 57 },
+ { 77, 57, 34, 31, 19, 17, 8, 10, 7, 6, 3, 6, 3, 4, 58 },
+ { 15, 19, 9, 7, 6, 6, 4, 4, 5, 4, 5, 4, 2, 2, 26 },
+ },
+ { // dumb bucket 3
+ { 384, 245, 148, 109, 86, 52, 39, 41, 24, 22, 37, 27, 20, 31, 80 },
+ { 33, 31, 18, 15, 13, 8, 10, 6, 6, 7, 3, 5, 8, 26, 104 },
+ { 87, 36, 28, 16, 19, 13, 7, 10, 13, 11, 10, 10, 4, 26, 80 },
+ { 87, 66, 47, 46, 28, 17, 21, 12, 12, 17, 11, 16, 6, 9, 40 },
+ { 242, 82, 38, 42, 13, 21, 30, 22, 17, 12, 11, 8, 4, 6, 17 },
+ { 66, 28, 53, 40, 25, 25, 36, 12, 24, 38, 14, 13, 4, 8, 10 },
+ { 72, 82, 46, 23, 31, 44, 22, 13, 28, 13, 14, 14, 6, 4, 14 },
+ { 115, 66, 70, 51, 43, 19, 15, 20, 20, 16, 14, 10, 3, 2, 18 },
+ { 95, 67, 37, 17, 33, 42, 23, 14, 14, 10, 11, 11, 4, 5, 33 },
+ { 79, 39, 22, 21, 25, 24, 19, 11, 17, 12, 37, 19, 5, 3, 10 },
+ { 18, 16, 10, 10, 9, 5, 4, 4, 2, 3, 3, 5, 2, 2, 6 },
+ },
+ { // dumb bucket 4
+ { 307, 218, 121, 87, 63, 52, 47, 41, 28, 24, 33, 33, 36, 45, 88 },
+ { 57, 58, 45, 35, 19, 23, 11, 17, 12, 6, 11, 10, 9, 22, 69 },
+ { 42, 58, 13, 29, 14, 15, 15, 7, 19, 7, 4, 7, 7, 18, 51 },
+ { 22, 34, 17, 25, 12, 12, 22, 14, 18, 12, 11, 9, 5, 7, 27 },
+ { 40, 35, 14, 15, 13, 10, 6, 5, 10, 6, 5, 4, 3, 3, 19 },
+ { 26, 20, 16, 11, 10, 5, 14, 13, 6, 9, 8, 9, 3, 3, 18 },
+ { 33, 16, 17, 16, 13, 10, 11, 10, 8, 7, 23, 6, 2, 4, 20 },
+ { 81, 19, 13, 16, 14, 28, 7, 10, 9, 6, 8, 5, 3, 2, 30 },
+ { 58, 31, 22, 29, 15, 20, 14, 18, 7, 6, 5, 5, 1, 4, 29 },
+ { 118, 38, 32, 47, 21, 27, 14, 13, 8, 13, 16, 10, 4, 2, 20 },
+ { 11, 17, 19, 10, 7, 4, 2, 2, 3, 3, 2, 3, 2, 1, 16 },
+ },
+ { // dumb bucket 5
+ { 289, 127, 65, 40, 29, 31, 33, 25, 19, 24, 18, 20, 29, 95, 252 },
+ { 38, 20, 13, 15, 14, 7, 7, 8, 6, 4, 4, 6, 4, 10, 69 },
+ { 42, 24, 16, 13, 9, 7, 6, 9, 8, 6, 5, 7, 3, 14, 80 },
+ { 101, 25, 35, 11, 13, 10, 10, 6, 8, 4, 9, 4, 3, 17, 117 },
+ { 88, 50, 23, 27, 9, 17, 10, 15, 16, 10, 7, 4, 5, 33, 126 },
+ { 116, 60, 34, 33, 18, 17, 16, 11, 10, 6, 6, 6, 4, 37, 135 },
+ { 232, 103, 54, 26, 26, 10, 16, 9, 13, 8, 8, 13, 6, 25, 92 },
+ { 237, 81, 81, 40, 21, 20, 20, 11, 18, 10, 10, 18, 8, 15, 80 },
+ { 125, 119, 47, 41, 36, 36, 27, 27, 18, 10, 19, 17, 3, 6, 55 },
+ { 240, 95, 48, 38, 36, 35, 15, 15, 36, 15, 38, 39, 4, 5, 37 },
+ { 95, 43, 21, 15, 12, 4, 7, 5, 2, 6, 8, 5, 1, 4, 52 },
+ },
+ { // dumb bucket 6
+ { 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 800, 800, 275, 162, 126 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 185, 84, 30 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 116, 31, 31 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 91, 30, 43 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 91, 19, 42 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 136, 30, 25 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 235, 31, 14 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 370, 29, 54 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 566, 49, 80 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 567, 68, 59 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 169, 22, 59 },
+ },
+ { // dumb bucket 7
+ { 116, 45, 46, 20, 30, 26, 30, 27, 13, 21, 22, 13, 13, 26, 69 },
+ { 41, 25, 14, 15, 8, 13, 6, 9, 7, 8, 7, 5, 3, 4, 20 },
+ { 35, 13, 21, 8, 15, 9, 5, 9, 6, 7, 5, 8, 3, 5, 29 },
+ { 43, 27, 7, 5, 7, 6, 5, 8, 5, 5, 5, 3, 3, 12, 37 },
+ { 45, 19, 17, 20, 7, 7, 6, 9, 6, 6, 6, 6, 2, 21, 41 },
+ { 127, 56, 32, 24, 20, 18, 17, 17, 16, 16, 16, 15, 3, 14, 23 },
+ { 543, 257, 153, 112, 97, 81, 75, 72, 71, 67, 69, 70, 11, 12, 21 },
+ { 800, 800, 800, 800, 588, 548, 458, 461, 448, 668, 398, 388, 25, 9, 14 },
+ { 800, 800, 800, 800, 1400, 800, 800, 800, 800, 800, 800, 800, 39, 14, 20 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 45, 12, 9 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 703, 14, 6, 41 },
+ },
+ { // dumb bucket 8
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 174, 79, 30 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 171, 44, 28 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 189, 27, 30 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 226, 20, 28 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 238, 10, 12 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 301, 14, 9 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 398, 42, 13 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 571, 37, 21 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 60, 74 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 83, 77 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 296, 29, 56 },
+ },
+ { // dumb bucket 9
+ { 218, 160, 89, 57, 65, 34, 37, 30, 15, 30, 14, 10, 6, 5, 8 },
+ { 25, 38, 25, 25, 22, 10, 16, 13, 8, 8, 3, 5, 3, 3, 6 },
+ { 119, 36, 22, 20, 24, 10, 17, 8, 7, 6, 8, 4, 2, 2, 5 },
+ { 73, 61, 23, 10, 13, 9, 6, 10, 4, 2, 5, 4, 2, 1, 2 },
+ { 59, 12, 15, 30, 20, 22, 16, 6, 6, 6, 2, 2, 2, 0, 2 },
+ { 33, 38, 21, 16, 20, 12, 8, 4, 6, 5, 4, 2, 1, 0, 1 },
+ { 31, 25, 20, 28, 25, 12, 5, 8, 6, 4, 4, 3, 1, 0, 1 },
+ { 62, 63, 55, 25, 54, 31, 13, 28, 14, 17, 8, 5, 1, 0, 2 },
+ { 138, 47, 61, 96, 79, 60, 46, 21, 15, 25, 12, 8, 1, 1, 4 },
+ { 148, 58, 92, 59, 96, 77, 78, 58, 37, 40, 16, 15, 1, 1, 1 },
+ { 69, 64, 35, 40, 30, 36, 39, 31, 24, 17, 11, 8, 1, 1, 3 },
+ },
+ { // dumb bucket 10
+ { 800, 800, 800, 800, 800, 798, 992, 540, 842, 535, 664, 334, 136, 184, 188 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 431, 179, 241 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 527, 423, 594 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 271, 252, 822 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 325, 558, 352 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 472, 409, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 432, 445, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 686, 759, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 735, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 630, 731, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 143, 139, 422 },
+ },
+ },
+ { // depth bucket 8
+ { // dumb bucket 0
+ { 1306, 1583, 1693, 1642, 1480, 1313, 1158, 1017, 895, 783, 683, 609, 410, 181, 60 },
+ { 764, 874, 867, 775, 648, 545, 465, 402, 349, 303, 269, 239, 161, 60, 22 },
+ { 214, 207, 187, 168, 145, 115, 101, 87, 77, 67, 59, 55, 40, 20, 9 },
+ { 121, 95, 81, 70, 55, 47, 40, 38, 31, 29, 24, 24, 16, 9, 6 },
+ { 87, 64, 50, 41, 33, 28, 24, 21, 19, 17, 16, 15, 9, 5, 3 },
+ { 63, 49, 38, 31, 24, 19, 19, 16, 14, 13, 11, 11, 7, 3, 2 },
+ { 58, 45, 39, 32, 24, 23, 17, 15, 15, 13, 13, 11, 8, 3, 3 },
+ { 56, 41, 34, 28, 24, 21, 18, 16, 14, 11, 11, 11, 5, 3, 3 },
+ { 39, 37, 33, 29, 25, 16, 17, 17, 15, 11, 11, 8, 5, 3, 2 },
+ { 38, 34, 28, 23, 19, 18, 15, 12, 10, 9, 8, 5, 3, 2, 3 },
+ { 43, 32, 25, 20, 15, 13, 10, 7, 6, 5, 5, 4, 2, 1, 2 },
+ },
+ { // dumb bucket 1
+ { 938, 576, 482, 246, 255, 355, 758, 267, 170, 293, 230, 190, 439, 1296, 2082 },
+ { 1800, 726, 631, 281, 231, 204, 265, 254, 166, 205, 140, 173, 252, 968, 1486 },
+ { 1400, 684, 372, 259, 216, 285, 263, 277, 144, 224, 206, 178, 179, 552, 1097 },
+ { 1200, 606, 328, 239, 201, 181, 176, 164, 267, 209, 126, 201, 128, 408, 889 },
+ { 1200, 601, 326, 217, 175, 150, 141, 125, 126, 124, 119, 112, 82, 195, 517 },
+ { 1200, 623, 355, 238, 172, 140, 129, 112, 122, 123, 121, 196, 62, 139, 496 },
+ { 800, 510, 293, 204, 162, 144, 141, 131, 136, 143, 146, 156, 24, 92, 436 },
+ { 800, 571, 312, 216, 158, 129, 129, 115, 203, 137, 148, 157, 47, 56, 392 },
+ { 800, 800, 541, 327, 239, 193, 175, 161, 170, 259, 180, 178, 41, 53, 448 },
+ { 800, 800, 528, 334, 226, 181, 162, 155, 154, 170, 176, 276, 45, 32, 361 },
+ { 634, 214, 108, 67, 46, 35, 30, 26, 26, 28, 44, 80, 91, 65, 299 },
+ },
+ { // dumb bucket 2
+ { 583, 355, 301, 228, 196, 152, 137, 103, 101, 88, 74, 81, 71, 96, 230 },
+ { 114, 107, 88, 80, 65, 55, 50, 41, 39, 35, 34, 33, 26, 46, 145 },
+ { 96, 81, 49, 45, 28, 25, 21, 21, 20, 20, 17, 14, 20, 41, 139 },
+ { 62, 41, 20, 18, 25, 23, 21, 15, 12, 15, 9, 13, 13, 21, 86 },
+ { 66, 29, 23, 25, 13, 20, 11, 10, 12, 11, 16, 8, 10, 13, 66 },
+ { 50, 47, 40, 17, 30, 11, 14, 15, 8, 6, 6, 4, 6, 9, 54 },
+ { 91, 48, 28, 37, 46, 28, 19, 10, 5, 9, 7, 5, 4, 6, 47 },
+ { 68, 83, 72, 32, 20, 20, 17, 17, 24, 6, 8, 5, 4, 5, 46 },
+ { 93, 134, 45, 26, 11, 8, 13, 9, 12, 4, 7, 6, 6, 4, 53 },
+ { 92, 82, 51, 21, 31, 17, 8, 7, 7, 10, 6, 4, 3, 2, 52 },
+ { 13, 17, 10, 7, 5, 7, 6, 5, 3, 4, 3, 4, 2, 2, 25 },
+ },
+ { // dumb bucket 3
+ { 209, 172, 171, 72, 65, 39, 34, 38, 22, 12, 13, 20, 16, 38, 104 },
+ { 24, 18, 28, 23, 17, 12, 6, 17, 7, 3, 8, 9, 4, 26, 146 },
+ { 73, 42, 36, 28, 17, 11, 12, 24, 11, 19, 8, 16, 4, 25, 91 },
+ { 137, 63, 51, 22, 34, 19, 72, 19, 17, 26, 12, 24, 5, 10, 56 },
+ { 144, 82, 47, 82, 15, 48, 44, 46, 21, 22, 39, 16, 6, 12, 37 },
+ { 96, 44, 106, 16, 15, 34, 22, 23, 16, 16, 17, 18, 5, 3, 18 },
+ { 112, 47, 75, 58, 42, 18, 31, 18, 32, 19, 19, 21, 8, 4, 9 },
+ { 67, 88, 48, 61, 27, 32, 32, 19, 19, 29, 30, 21, 3, 5, 12 },
+ { 65, 28, 23, 21, 70, 20, 20, 20, 31, 21, 22, 23, 4, 4, 13 },
+ { 94, 35, 42, 25, 36, 24, 24, 24, 25, 25, 27, 29, 5, 6, 8 },
+ { 31, 12, 10, 10, 7, 6, 7, 3, 8, 4, 4, 4, 1, 3, 17 },
+ },
+ { // dumb bucket 4
+ { 261, 151, 118, 123, 75, 44, 40, 27, 32, 22, 27, 29, 30, 41, 77 },
+ { 46, 65, 55, 29, 30, 10, 16, 29, 19, 5, 8, 13, 9, 20, 68 },
+ { 123, 26, 66, 27, 10, 12, 21, 9, 9, 16, 13, 7, 9, 15, 42 },
+ { 51, 39, 26, 29, 16, 32, 12, 7, 10, 14, 12, 7, 6, 6, 27 },
+ { 51, 26, 51, 13, 11, 38, 8, 11, 10, 14, 6, 9, 3, 5, 22 },
+ { 60, 30, 20, 23, 23, 26, 15, 16, 25, 8, 7, 6, 4, 2, 14 },
+ { 72, 36, 25, 20, 17, 15, 13, 12, 11, 15, 9, 12, 3, 2, 19 },
+ { 81, 62, 49, 23, 20, 17, 16, 14, 20, 12, 16, 10, 3, 3, 22 },
+ { 92, 68, 49, 27, 23, 30, 18, 16, 22, 13, 12, 10, 2, 3, 18 },
+ { 246, 185, 60, 32, 26, 23, 20, 28, 16, 15, 14, 13, 2, 3, 8 },
+ { 43, 9, 12, 23, 8, 4, 6, 7, 4, 4, 3, 9, 1, 1, 11 },
+ },
+ { // dumb bucket 5
+ { 277, 115, 55, 51, 15, 19, 22, 23, 16, 8, 17, 24, 29, 93, 197 },
+ { 39, 21, 18, 21, 10, 8, 7, 7, 4, 5, 7, 6, 4, 8, 64 },
+ { 47, 22, 15, 25, 11, 7, 14, 6, 5, 5, 5, 6, 4, 13, 77 },
+ { 108, 34, 18, 13, 13, 12, 9, 6, 9, 6, 12, 6, 4, 13, 101 },
+ { 71, 96, 53, 34, 21, 11, 10, 21, 9, 9, 9, 9, 2, 27, 113 },
+ { 208, 45, 33, 42, 24, 15, 21, 13, 13, 13, 12, 12, 5, 52, 96 },
+ { 209, 120, 52, 38, 35, 21, 46, 29, 19, 18, 18, 18, 10, 29, 76 },
+ { 173, 95, 49, 87, 41, 57, 24, 58, 23, 22, 23, 23, 6, 21, 78 },
+ { 115, 62, 55, 48, 39, 25, 24, 23, 24, 24, 24, 25, 4, 9, 56 },
+ { 192, 79, 64, 66, 52, 34, 33, 33, 33, 33, 34, 63, 11, 16, 143 },
+ { 82, 34, 25, 25, 8, 10, 8, 7, 5, 5, 6, 6, 5, 2, 42 },
+ },
+ { // dumb bucket 6
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 275, 184, 101 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 421, 32, 40 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 272, 111, 26 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 217, 32, 35 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 212, 23, 25 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 274, 19, 18 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 481, 39, 38 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 60, 119 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 101, 109 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 143, 51 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 409, 49, 130 },
+ },
+ { // dumb bucket 7
+ { 66, 56, 38, 29, 18, 16, 36, 24, 14, 15, 10, 14, 11, 20, 64 },
+ { 32, 18, 11, 13, 15, 16, 12, 6, 7, 7, 9, 5, 3, 4, 20 },
+ { 51, 32, 15, 9, 16, 8, 7, 6, 9, 9, 7, 6, 3, 4, 27 },
+ { 102, 25, 15, 17, 9, 8, 8, 7, 7, 11, 10, 23, 6, 6, 31 },
+ { 108, 45, 27, 20, 17, 15, 14, 14, 13, 13, 13, 12, 3, 16, 35 },
+ { 334, 139, 82, 56, 47, 43, 41, 38, 38, 38, 38, 36, 7, 14, 25 },
+ { 1200, 597, 393, 283, 249, 224, 190, 182, 177, 177, 170, 180, 19, 9, 19 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 104, 22, 15 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 85, 14, 9 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 99, 17, 19 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 36, 8, 34 },
+ },
+ { // dumb bucket 8
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 309, 61, 31 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 384, 53, 33 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 448, 46, 16 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 494, 29, 26 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 528, 21, 13 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 667, 31, 14 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 54, 23 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 86, 66 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 140, 46 },
+ { 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 800, 180, 377 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 669, 64, 30 },
+ },
+ { // dumb bucket 9
+ { 358, 112, 99, 55, 77, 38, 31, 20, 28, 23, 16, 15, 7, 5, 7 },
+ { 37, 22, 24, 15, 11, 40, 13, 7, 21, 11, 6, 8, 2, 3, 5 },
+ { 53, 22, 29, 45, 11, 16, 13, 13, 12, 18, 10, 14, 4, 3, 4 },
+ { 80, 32, 38, 14, 11, 7, 18, 10, 7, 8, 8, 4, 1, 1, 2 },
+ { 39, 56, 9, 23, 9, 13, 8, 4, 10, 12, 5, 3, 2, 0, 1 },
+ { 42, 19, 20, 19, 11, 23, 13, 13, 5, 8, 8, 4, 2, 0, 1 },
+ { 70, 32, 20, 33, 19, 11, 10, 10, 13, 19, 8, 8, 3, 1, 1 },
+ { 146, 64, 41, 37, 28, 25, 35, 26, 23, 12, 12, 11, 1, 1, 1 },
+ { 120, 86, 70, 97, 63, 70, 22, 21, 33, 18, 17, 16, 9, 1, 3 },
+ { 151, 159, 156, 91, 78, 46, 90, 42, 46, 25, 23, 23, 2, 1, 5 },
+ { 58, 70, 31, 54, 33, 34, 18, 18, 17, 7, 12, 12, 1, 1, 3 },
+ },
+ { // dumb bucket 10
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 634, 150, 274, 433 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 370, 325, 581 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 673, 966, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 826, 499, 800 },
+ { 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 762, 1359, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 2000 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 385, 385, 800 },
+ },
+ },
+ { // depth bucket 9
+ { // dumb bucket 0
+ { 1155, 1389, 1445, 1349, 1205, 1071, 912, 802, 706, 614, 548, 474, 319, 129, 47 },
+ { 681, 782, 793, 705, 585, 490, 411, 348, 306, 263, 230, 206, 116, 45, 16 },
+ { 201, 197, 186, 164, 140, 112, 97, 78, 71, 64, 53, 48, 32, 15, 8 },
+ { 107, 90, 74, 63, 51, 43, 39, 33, 27, 26, 22, 23, 14, 6, 4 },
+ { 83, 58, 42, 36, 27, 24, 20, 17, 17, 16, 14, 12, 8, 4, 3 },
+ { 57, 46, 32, 25, 22, 17, 15, 13, 11, 11, 9, 11, 6, 3, 3 },
+ { 62, 46, 30, 28, 22, 21, 17, 16, 15, 11, 11, 8, 6, 4, 2 },
+ { 56, 38, 34, 26, 20, 16, 14, 13, 11, 12, 10, 9, 6, 3, 2 },
+ { 37, 34, 29, 27, 23, 14, 12, 14, 9, 11, 9, 6, 6, 3, 4 },
+ { 35, 28, 25, 23, 18, 16, 12, 11, 8, 8, 7, 7, 4, 2, 3 },
+ { 41, 29, 23, 19, 14, 11, 10, 7, 7, 6, 6, 4, 2, 2, 2 },
+ },
+ { // dumb bucket 1
+ { 976, 640, 430, 323, 276, 326, 274, 133, 160, 219, 263, 249, 309, 1194, 1670 },
+ { 1800, 1400, 1800, 597, 508, 445, 375, 325, 450, 336, 452, 352, 246, 870, 1338 },
+ { 1400, 1400, 1200, 641, 537, 460, 431, 370, 359, 529, 410, 250, 149, 489, 936 },
+ { 1200, 1200, 1200, 534, 457, 382, 356, 319, 313, 285, 265, 362, 162, 260, 756 },
+ { 1200, 1200, 771, 511, 358, 331, 288, 253, 380, 262, 240, 364, 52, 185, 494 },
+ { 1200, 800, 774, 505, 378, 301, 287, 255, 270, 274, 257, 273, 99, 142, 394 },
+ { 1200, 800, 633, 423, 333, 296, 291, 286, 294, 307, 319, 307, 47, 64, 644 },
+ { 800, 800, 677, 481, 320, 264, 280, 246, 296, 311, 316, 518, 31, 51, 379 },
+ { 1200, 800, 800, 800, 641, 459, 415, 331, 338, 366, 362, 368, 49, 80, 440 },
+ { 800, 800, 800, 800, 550, 399, 345, 317, 309, 335, 350, 347, 37, 68, 444 },
+ { 800, 538, 269, 163, 109, 81, 66, 54, 53, 85, 60, 102, 83, 74, 256 },
+ },
+ { // dumb bucket 2
+ { 459, 287, 234, 199, 122, 127, 112, 122, 86, 82, 65, 69, 62, 89, 188 },
+ { 101, 87, 73, 68, 62, 56, 37, 41, 35, 32, 30, 27, 21, 36, 113 },
+ { 88, 63, 52, 35, 25, 23, 21, 27, 27, 22, 23, 19, 17, 37, 105 },
+ { 47, 37, 13, 44, 41, 21, 22, 11, 17, 10, 10, 21, 12, 19, 74 },
+ { 104, 98, 30, 30, 33, 24, 13, 9, 8, 13, 15, 7, 7, 10, 65 },
+ { 44, 158, 38, 24, 24, 45, 13, 12, 10, 22, 10, 15, 7, 5, 62 },
+ { 134, 51, 21, 44, 24, 36, 35, 11, 21, 12, 11, 14, 4, 7, 57 },
+ { 71, 82, 48, 38, 25, 25, 16, 13, 14, 23, 7, 12, 6, 3, 65 },
+ { 53, 47, 44, 36, 44, 18, 45, 32, 15, 14, 9, 9, 5, 3, 48 },
+ { 93, 30, 36, 47, 57, 43, 11, 24, 17, 14, 9, 9, 6, 3, 51 },
+ { 12, 13, 8, 12, 6, 4, 6, 6, 2, 4, 4, 4, 3, 2, 24 },
+ },
+ { // dumb bucket 3
+ { 298, 100, 146, 109, 48, 28, 38, 37, 24, 32, 34, 21, 18, 43, 71 },
+ { 44, 50, 24, 20, 13, 17, 26, 6, 7, 11, 8, 9, 12, 18, 75 },
+ { 134, 76, 24, 34, 17, 16, 23, 24, 25, 18, 19, 32, 7, 43, 77 },
+ { 135, 117, 58, 50, 26, 24, 36, 25, 38, 40, 27, 54, 8, 8, 52 },
+ { 170, 130, 63, 37, 35, 50, 32, 32, 47, 32, 33, 36, 6, 13, 13 },
+ { 144, 57, 70, 79, 116, 32, 33, 33, 51, 35, 55, 39, 17, 9, 30 },
+ { 102, 108, 47, 72, 40, 68, 37, 37, 38, 39, 40, 41, 10, 6, 29 },
+ { 320, 174, 100, 66, 38, 58, 38, 40, 40, 41, 42, 44, 7, 7, 17 },
+ { 100, 92, 75, 44, 43, 42, 42, 43, 44, 47, 48, 51, 8, 13, 24 },
+ { 128, 78, 92, 55, 78, 51, 50, 49, 51, 55, 56, 62, 10, 14, 16 },
+ { 17, 25, 13, 16, 10, 10, 7, 13, 17, 8, 8, 9, 2, 2, 6 },
+ },
+ { // dumb bucket 4
+ { 308, 180, 134, 98, 69, 44, 36, 34, 23, 20, 19, 30, 30, 35, 65 },
+ { 119, 91, 54, 46, 36, 31, 32, 22, 19, 12, 6, 13, 7, 19, 60 },
+ { 131, 59, 37, 41, 38, 18, 24, 31, 21, 16, 10, 21, 9, 16, 43 },
+ { 121, 87, 58, 28, 24, 31, 18, 16, 14, 20, 12, 11, 5, 5, 25 },
+ { 161, 106, 39, 30, 25, 32, 19, 29, 23, 14, 19, 11, 4, 6, 17 },
+ { 139, 66, 67, 36, 44, 119, 23, 21, 19, 37, 15, 13, 6, 3, 13 },
+ { 175, 83, 59, 47, 39, 34, 30, 41, 24, 22, 20, 74, 5, 3, 16 },
+ { 263, 90, 63, 49, 44, 39, 34, 56, 29, 26, 23, 21, 3, 2, 23 },
+ { 373, 110, 75, 91, 52, 44, 41, 37, 32, 29, 62, 23, 2, 3, 19 },
+ { 275, 132, 93, 72, 62, 54, 46, 40, 37, 34, 29, 28, 2, 2, 13 },
+ { 39, 52, 15, 12, 15, 9, 8, 7, 6, 12, 9, 4, 2, 1, 9 },
+ },
+ { // dumb bucket 5
+ { 119, 76, 66, 40, 22, 13, 24, 11, 10, 11, 14, 9, 18, 66, 157 },
+ { 28, 35, 30, 13, 21, 10, 8, 11, 5, 7, 7, 8, 4, 8, 57 },
+ { 76, 51, 26, 18, 16, 14, 9, 8, 14, 12, 8, 11, 2, 10, 81 },
+ { 66, 53, 24, 43, 16, 15, 31, 13, 13, 12, 12, 12, 3, 17, 94 },
+ { 318, 131, 55, 30, 38, 36, 38, 21, 20, 19, 19, 19, 3, 32, 125 },
+ { 179, 104, 85, 68, 112, 32, 30, 29, 28, 40, 26, 26, 7, 49, 117 },
+ { 417, 139, 67, 56, 51, 48, 66, 42, 39, 38, 38, 40, 10, 16, 88 },
+ { 233, 96, 74, 62, 101, 55, 53, 52, 51, 50, 51, 50, 8, 21, 63 },
+ { 226, 134, 68, 61, 89, 53, 52, 52, 51, 51, 96, 57, 15, 20, 130 },
+ { 176, 118, 158, 123, 75, 73, 126, 71, 74, 72, 76, 79, 14, 24, 155 },
+ { 93, 57, 46, 10, 15, 23, 15, 10, 11, 12, 13, 14, 6, 5, 36 },
+ },
+ { // dumb bucket 6
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 356, 96, 125 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 63, 16 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 585, 90, 27 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 456, 59, 32 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 469, 27, 14 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 622, 70, 12 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 129, 100 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 143, 146 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 221, 70 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 593, 118 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 110, 95 },
+ },
+ { // dumb bucket 7
+ { 160, 146, 35, 32, 21, 19, 25, 21, 16, 22, 19, 21, 8, 11, 37 },
+ { 53, 32, 22, 13, 11, 12, 11, 7, 8, 4, 10, 15, 2, 4, 17 },
+ { 83, 32, 19, 24, 20, 10, 14, 13, 12, 8, 12, 12, 3, 5, 21 },
+ { 143, 57, 35, 44, 21, 19, 17, 16, 16, 15, 15, 25, 3, 6, 32 },
+ { 271, 110, 62, 45, 38, 50, 32, 31, 30, 29, 28, 28, 5, 14, 50 },
+ { 737, 315, 196, 133, 104, 93, 86, 84, 83, 81, 83, 77, 14, 8, 26 },
+ { 800, 800, 800, 760, 524, 476, 416, 384, 370, 356, 371, 401, 39, 28, 20 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 137, 20, 8 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 204, 30, 42 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 243, 58, 24 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 88, 12, 14 },
+ },
+ { // dumb bucket 8
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 630, 75, 54 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 778, 54, 26 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 57, 59 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 60, 36 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 46, 67 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 65, 17 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 121, 33 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 187, 59 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 290, 100 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 389, 164 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 136, 56 },
+ },
+ { // dumb bucket 9
+ { 228, 88, 62, 33, 46, 50, 26, 14, 25, 19, 33, 12, 5, 5, 5 },
+ { 94, 51, 59, 44, 25, 19, 13, 21, 6, 17, 12, 8, 3, 3, 4 },
+ { 115, 47, 60, 46, 15, 33, 12, 11, 10, 9, 13, 8, 2, 2, 2 },
+ { 120, 47, 28, 30, 49, 14, 13, 12, 11, 10, 9, 9, 1, 1, 1 },
+ { 88, 62, 32, 35, 31, 17, 10, 9, 9, 12, 11, 10, 1, 0, 1 },
+ { 97, 95, 25, 18, 23, 14, 13, 12, 17, 11, 10, 9, 1, 0, 1 },
+ { 269, 72, 42, 33, 42, 38, 52, 32, 20, 18, 17, 16, 3, 1, 2 },
+ { 213, 178, 63, 48, 40, 38, 33, 31, 29, 27, 25, 24, 2, 1, 2 },
+ { 274, 192, 89, 63, 55, 49, 46, 44, 41, 39, 37, 35, 3, 2, 2 },
+ { 370, 163, 110, 84, 72, 65, 63, 59, 56, 54, 51, 48, 5, 3, 11 },
+ { 154, 78, 52, 35, 66, 27, 26, 29, 29, 24, 15, 15, 2, 1, 5 },
+ },
+ { // dumb bucket 10
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 148, 203, 352 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 800, 1200, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 640, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ },
+ },
+ { // depth bucket 10
+ { // dumb bucket 0
+ { 1051, 1212, 1240, 1139, 983, 855, 730, 631, 554, 486, 422, 372, 248, 92, 32 },
+ { 597, 696, 728, 655, 561, 465, 401, 334, 289, 243, 217, 196, 107, 37, 12 },
+ { 195, 180, 175, 157, 127, 105, 90, 80, 73, 55, 50, 46, 30, 14, 6 },
+ { 104, 90, 74, 59, 49, 41, 33, 32, 29, 22, 21, 17, 12, 6, 3 },
+ { 68, 57, 46, 37, 26, 23, 21, 20, 16, 13, 13, 10, 7, 3, 2 },
+ { 54, 47, 33, 23, 20, 17, 15, 13, 13, 9, 9, 9, 5, 2, 1 },
+ { 65, 44, 32, 24, 21, 17, 17, 14, 15, 13, 11, 7, 5, 2, 2 },
+ { 48, 39, 26, 24, 19, 15, 13, 13, 8, 10, 13, 6, 5, 3, 1 },
+ { 32, 28, 26, 19, 17, 17, 13, 11, 10, 12, 8, 8, 4, 2, 3 },
+ { 40, 29, 23, 21, 19, 14, 12, 12, 11, 7, 6, 7, 4, 2, 2 },
+ { 32, 24, 18, 17, 14, 10, 9, 6, 6, 5, 4, 3, 2, 1, 1 },
+ },
+ { // dumb bucket 1
+ { 1683, 798, 1208, 676, 287, 461, 496, 350, 230, 127, 174, 216, 246, 875, 1192 },
+ { 1800, 1400, 1400, 1200, 1033, 654, 575, 669, 334, 242, 190, 154, 201, 738, 1027 },
+ { 1200, 1200, 1200, 735, 658, 547, 538, 482, 444, 419, 570, 331, 119, 339, 612 },
+ { 1400, 1200, 1200, 1200, 653, 709, 645, 363, 333, 398, 354, 326, 155, 266, 662 },
+ { 1200, 1200, 1200, 690, 434, 344, 349, 506, 367, 393, 355, 291, 39, 120, 492 },
+ { 1200, 800, 800, 734, 524, 398, 399, 345, 383, 360, 341, 358, 45, 173, 283 },
+ { 800, 800, 800, 677, 459, 364, 552, 345, 362, 373, 406, 374, 62, 99, 316 },
+ { 800, 800, 800, 662, 483, 396, 460, 379, 406, 451, 414, 813, 97, 56, 325 },
+ { 800, 800, 800, 800, 800, 702, 688, 483, 500, 544, 486, 476, 45, 79, 253 },
+ { 800, 800, 800, 800, 760, 615, 485, 472, 419, 448, 483, 478, 81, 90, 353 },
+ { 800, 800, 488, 266, 172, 123, 98, 79, 74, 78, 83, 91, 56, 97, 270 },
+ },
+ { // dumb bucket 2
+ { 293, 238, 200, 144, 132, 106, 85, 85, 64, 52, 62, 45, 42, 59, 129 },
+ { 65, 71, 64, 60, 51, 50, 38, 31, 30, 23, 25, 24, 18, 23, 83 },
+ { 114, 82, 59, 38, 29, 40, 15, 20, 17, 29, 19, 19, 16, 24, 82 },
+ { 80, 30, 26, 27, 9, 16, 10, 13, 21, 7, 6, 16, 8, 12, 74 },
+ { 86, 84, 63, 15, 23, 26, 11, 14, 11, 6, 18, 15, 5, 7, 55 },
+ { 46, 63, 12, 17, 26, 16, 12, 14, 6, 18, 12, 9, 8, 5, 59 },
+ { 95, 73, 19, 33, 12, 17, 15, 28, 13, 8, 28, 13, 4, 4, 59 },
+ { 70, 101, 38, 30, 21, 12, 28, 10, 15, 16, 9, 20, 6, 6, 36 },
+ { 356, 72, 41, 37, 32, 27, 14, 13, 13, 12, 12, 12, 3, 6, 32 },
+ { 197, 74, 28, 38, 41, 16, 32, 13, 19, 12, 17, 11, 4, 4, 26 },
+ { 21, 5, 6, 7, 10, 7, 7, 3, 3, 3, 2, 2, 4, 3, 18 },
+ },
+ { // dumb bucket 3
+ { 158, 67, 133, 63, 41, 32, 37, 12, 35, 29, 28, 20, 17, 37, 51 },
+ { 46, 40, 13, 11, 14, 16, 8, 25, 9, 9, 10, 18, 4, 20, 73 },
+ { 132, 85, 52, 28, 25, 40, 22, 22, 23, 24, 27, 29, 5, 43, 98 },
+ { 257, 99, 79, 69, 35, 32, 32, 32, 32, 34, 35, 39, 12, 10, 23 },
+ { 146, 331, 57, 72, 47, 76, 42, 40, 39, 41, 41, 68, 7, 14, 27 },
+ { 211, 116, 84, 46, 41, 73, 41, 63, 41, 43, 69, 106, 11, 14, 36 },
+ { 139, 81, 104, 50, 49, 47, 46, 46, 46, 68, 73, 52, 12, 11, 25 },
+ { 147, 83, 58, 47, 47, 47, 48, 49, 48, 49, 53, 54, 9, 9, 15 },
+ { 157, 89, 67, 61, 59, 187, 59, 58, 59, 61, 63, 68, 11, 11, 20 },
+ { 205, 119, 89, 77, 72, 68, 68, 66, 65, 71, 77, 82, 14, 12, 30 },
+ { 41, 28, 17, 13, 9, 13, 9, 9, 9, 9, 10, 16, 2, 3, 4 },
+ },
+ { // dumb bucket 4
+ { 428, 157, 149, 98, 83, 34, 34, 15, 30, 17, 43, 27, 17, 24, 45 },
+ { 193, 121, 67, 43, 38, 39, 21, 40, 16, 16, 29, 16, 14, 14, 44 },
+ { 218, 134, 79, 58, 53, 26, 32, 19, 25, 15, 23, 20, 7, 8, 36 },
+ { 171, 80, 90, 39, 33, 29, 25, 38, 29, 17, 16, 14, 3, 5, 17 },
+ { 147, 92, 54, 42, 53, 30, 47, 34, 20, 27, 16, 15, 2, 5, 17 },
+ { 226, 149, 93, 77, 43, 37, 31, 27, 24, 22, 20, 17, 5, 2, 17 },
+ { 271, 131, 82, 63, 54, 47, 42, 57, 73, 30, 26, 23, 5, 4, 34 },
+ { 268, 131, 87, 68, 62, 52, 47, 44, 39, 53, 31, 28, 6, 3, 20 },
+ { 310, 148, 106, 86, 72, 60, 82, 48, 43, 38, 33, 30, 3, 3, 14 },
+ { 494, 199, 128, 141, 77, 71, 65, 57, 52, 45, 41, 37, 3, 7, 26 },
+ { 54, 28, 19, 15, 13, 11, 9, 8, 8, 7, 6, 8, 2, 1, 7 },
+ },
+ { // dumb bucket 5
+ { 175, 142, 43, 56, 20, 31, 16, 15, 12, 7, 7, 16, 12, 47, 107 },
+ { 45, 21, 24, 23, 13, 12, 7, 7, 10, 11, 9, 6, 4, 5, 40 },
+ { 70, 58, 22, 30, 15, 53, 21, 17, 11, 10, 10, 10, 5, 6, 48 },
+ { 168, 83, 37, 29, 24, 31, 19, 18, 17, 17, 17, 17, 4, 10, 105 },
+ { 246, 132, 78, 73, 37, 32, 29, 27, 27, 26, 25, 25, 4, 15, 105 },
+ { 278, 100, 70, 55, 71, 44, 39, 36, 36, 36, 34, 34, 6, 29, 188 },
+ { 257, 139, 100, 79, 180, 63, 59, 56, 55, 52, 89, 53, 9, 15, 124 },
+ { 381, 138, 106, 130, 85, 74, 105, 69, 70, 69, 69, 67, 12, 35, 80 },
+ { 223, 128, 91, 80, 73, 71, 69, 69, 67, 67, 70, 78, 14, 42, 80 },
+ { 254, 166, 127, 110, 104, 150, 101, 99, 98, 99, 104, 106, 19, 82, 68 },
+ { 41, 17, 45, 23, 23, 20, 13, 13, 22, 16, 17, 19, 6, 7, 35 },
+ },
+ { // dumb bucket 6
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 753, 79, 82 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 72, 18 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 765, 48, 18 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 618, 48, 46 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 621, 34, 12 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 781, 88, 15 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 188, 33 },
+ { 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 800, 198, 276 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 320, 76 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 457, 144 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 146, 70 },
+ },
+ { // dumb bucket 7
+ { 172, 83, 40, 28, 38, 20, 21, 11, 16, 25, 29, 22, 6, 15, 37 },
+ { 150, 27, 14, 23, 12, 11, 10, 9, 6, 13, 13, 8, 2, 3, 11 },
+ { 133, 72, 26, 19, 15, 23, 12, 11, 16, 15, 10, 10, 2, 4, 20 },
+ { 234, 87, 47, 34, 28, 24, 22, 21, 66, 20, 33, 19, 3, 7, 20 },
+ { 396, 153, 81, 60, 75, 45, 43, 39, 39, 37, 37, 35, 4, 9, 34 },
+ { 800, 541, 319, 197, 164, 137, 128, 119, 119, 110, 117, 117, 13, 6, 15 },
+ { 800, 800, 800, 800, 800, 800, 771, 662, 636, 623, 704, 597, 59, 30, 15 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 225, 27, 14 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 295, 43, 25 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 346, 56, 20 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 134, 27, 13 },
+ },
+ { // dumb bucket 8
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 759, 82, 37 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 69, 28 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 73, 25 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 83, 64 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 61, 36 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 90, 22 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 158, 106 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 274, 81 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 435, 127 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 523, 193 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 163, 66 },
+ },
+ { // dumb bucket 9
+ { 421, 82, 71, 114, 59, 35, 30, 17, 47, 22, 20, 15, 2, 3, 6 },
+ { 161, 51, 39, 18, 63, 20, 10, 9, 13, 8, 7, 7, 3, 2, 4 },
+ { 188, 98, 53, 60, 19, 17, 16, 21, 13, 12, 11, 11, 3, 2, 3 },
+ { 190, 72, 39, 48, 22, 28, 25, 15, 14, 19, 30, 11, 1, 2, 1 },
+ { 125, 79, 44, 21, 25, 15, 20, 18, 11, 10, 10, 9, 1, 0, 1 },
+ { 141, 57, 33, 55, 51, 33, 17, 24, 44, 14, 13, 12, 4, 1, 1 },
+ { 230, 96, 90, 44, 37, 50, 43, 48, 40, 24, 23, 21, 2, 1, 2 },
+ { 309, 126, 75, 59, 76, 44, 131, 70, 38, 35, 34, 31, 3, 2, 2 },
+ { 332, 169, 110, 118, 68, 64, 58, 57, 51, 49, 47, 43, 4, 3, 3 },
+ { 426, 216, 139, 105, 89, 85, 82, 78, 71, 68, 65, 65, 6, 4, 6 },
+ { 162, 143, 59, 46, 26, 35, 89, 70, 52, 29, 19, 18, 2, 1, 2 },
+ },
+ { // dumb bucket 10
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 140, 235, 1107 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ },
+ },
+ { // depth bucket 11
+ { // dumb bucket 0
+ { 723, 778, 937, 833, 772, 658, 553, 482, 365, 386, 289, 259, 181, 74, 26 },
+ { 515, 533, 561, 558, 475, 438, 318, 318, 273, 179, 223, 106, 78, 27, 12 },
+ { 245, 204, 246, 158, 153, 124, 76, 108, 91, 93, 73, 58, 27, 10, 7 },
+ { 217, 136, 141, 99, 88, 57, 53, 36, 36, 47, 32, 27, 18, 4, 6 },
+ { 131, 66, 75, 73, 55, 38, 49, 38, 28, 22, 13, 22, 6, 4, 6 },
+ { 62, 47, 44, 35, 39, 19, 44, 17, 26, 26, 35, 18, 6, 2, 4 },
+ { 117, 139, 46, 53, 30, 25, 38, 43, 26, 27, 27, 28, 8, 7, 7 },
+ { 129, 46, 58, 67, 34, 44, 30, 35, 20, 47, 32, 22, 5, 6, 14 },
+ { 63, 82, 50, 38, 43, 25, 25, 25, 26, 26, 40, 27, 7, 5, 19 },
+ { 72, 43, 58, 45, 29, 29, 30, 30, 31, 32, 32, 33, 7, 6, 19 },
+ { 30, 37, 30, 30, 12, 20, 14, 8, 7, 9, 9, 11, 2, 2, 4 },
+ },
+ { // dumb bucket 1
+ { 1200, 1200, 1200, 1800, 1400, 2000, 1200, 1200, 1200, 1200, 1200, 1200, 159, 880, 833 },
+ { 1400, 1400, 1400, 1400, 1400, 1400, 1400, 1400, 1400, 1400, 1400, 1400, 1200, 1629, 535 },
+ { 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 800, 537, 829 },
+ { 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 634, 1158 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 532, 508 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 765, 596 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 671 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 488, 205, 396 },
+ },
+ { // dumb bucket 2
+ { 800, 917, 275, 335, 339, 382, 179, 164, 102, 141, 138, 138, 39, 47, 120 },
+ { 1200, 369, 292, 199, 168, 175, 158, 83, 79, 110, 109, 103, 14, 9, 71 },
+ { 800, 885, 298, 234, 329, 175, 257, 163, 165, 147, 145, 142, 26, 39, 79 },
+ { 800, 1158, 408, 322, 258, 247, 213, 200, 190, 173, 173, 173, 21, 35, 167 },
+ { 800, 1200, 925, 399, 335, 283, 256, 223, 207, 196, 186, 266, 21, 22, 144 },
+ { 800, 800, 623, 511, 431, 375, 331, 281, 294, 257, 390, 242, 29, 22, 67 },
+ { 800, 800, 800, 741, 567, 470, 429, 407, 421, 371, 380, 349, 71, 33, 59 },
+ { 800, 800, 800, 800, 691, 539, 509, 476, 455, 485, 446, 420, 47, 38, 240 },
+ { 800, 800, 800, 800, 800, 760, 672, 633, 615, 565, 544, 585, 65, 51, 66 },
+ { 800, 800, 800, 800, 800, 800, 778, 780, 705, 605, 614, 576, 70, 51, 87 },
+ { 633, 288, 192, 235, 130, 109, 94, 93, 90, 135, 94, 91, 15, 8, 49 },
+ },
+ { // dumb bucket 3
+ { 800, 1200, 800, 1032, 518, 582, 528, 595, 534, 478, 474, 511, 134, 155, 174 },
+ { 800, 800, 760, 731, 565, 503, 442, 491, 489, 486, 551, 872, 198, 191, 366 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 437, 296, 375 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 342, 404, 446 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 320, 324, 887 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 383, 347, 429 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 302, 341, 602 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 344, 552, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 350, 658, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 760, 768, 800 },
+ { 800, 800, 562, 466, 409, 442, 409, 372, 368, 365, 377, 456, 84, 125, 259 },
+ },
+ { // dumb bucket 4
+ { 800, 800, 800, 800, 1200, 1200, 742, 640, 706, 431, 336, 265, 28, 44, 42 },
+ { 800, 800, 800, 800, 800, 800, 800, 660, 576, 480, 436, 393, 56, 24, 32 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 756, 686, 595, 59, 69, 171 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 696, 701, 70, 50, 49 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 787, 655, 66, 38, 33 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 73, 45, 42 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 96, 70, 75 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 138, 86, 96 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 131, 102, 136 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 193, 118, 175 },
+ { 800, 800, 800, 800, 721, 717, 553, 438, 365, 318, 299, 255, 26, 15, 28 },
+ },
+ { // dumb bucket 5
+ { 800, 800, 800, 703, 563, 473, 399, 389, 339, 310, 279, 289, 136, 83, 162 },
+ { 800, 800, 795, 571, 481, 456, 411, 384, 364, 359, 357, 345, 45, 75, 88 },
+ { 800, 800, 800, 800, 726, 681, 607, 572, 553, 555, 549, 546, 72, 82, 148 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 131, 171, 374 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 222, 277, 244 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 332, 428, 387 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 528, 800, 692 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 653, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 629, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 778, 786, 800, 745, 800, 749, 800, 800, 800, 800, 250, 567, 562 },
+ },
+ { // dumb bucket 6
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 579 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 363 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 483 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 709 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 576 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ },
+ { // dumb bucket 7
+ { 800, 800, 800, 800, 800, 645, 518, 499, 472, 423, 431, 407, 44, 37, 69 },
+ { 800, 800, 800, 563, 468, 396, 333, 323, 294, 275, 278, 269, 29, 39, 72 },
+ { 800, 800, 800, 800, 800, 649, 578, 527, 537, 506, 493, 448, 54, 47, 56 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 115, 94, 94 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 291, 174, 102 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 513, 157 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 304 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 355 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 442 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 339 },
+ },
+ { // dumb bucket 8
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 515 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 423 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 563 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 774 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ },
+ { // dumb bucket 9
+ { 800, 800, 800, 800, 800, 742, 688, 535, 532, 475, 620, 359, 34, 19, 26 },
+ { 800, 800, 800, 800, 622, 567, 517, 543, 473, 396, 371, 308, 45, 16, 13 },
+ { 800, 800, 800, 800, 800, 758, 765, 570, 566, 571, 510, 447, 47, 31, 29 },
+ { 800, 800, 800, 800, 800, 800, 800, 795, 759, 623, 614, 551, 52, 31, 31 },
+ { 800, 800, 1400, 800, 800, 698, 651, 673, 552, 503, 457, 396, 40, 23, 21 },
+ { 800, 800, 800, 800, 800, 800, 800, 756, 684, 792, 628, 604, 56, 34, 28 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 92, 60, 55 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 120, 80, 71 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 189, 125, 115 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 292, 170, 179 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 102, 80, 79 },
+ },
+ { // dumb bucket 10
+ { 4600, 4600, 4600, 4600, 4600, 4600, 4600, 4600, 4600, 4600, 4600, 4600, 4400, 4200, 4400 },
+ { 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000 },
+ { 1400, 1400, 1400, 1400, 1400, 1400, 1400, 1400, 1400, 1400, 1400, 1400, 1400, 1400, 1400 },
+ { 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ { 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 },
+ },
+ },
+};
+
+
+
+
+
+
diff --git a/src/dynamic.c b/src/dynamic.c
index 687fa74..2451fa6 100644
--- a/src/dynamic.c
+++ b/src/dynamic.c
@@ -176,14 +176,19 @@ Return value:
**/
{
- ULONG u;
-
memset(g_HistoryCounters, 0, sizeof(g_HistoryCounters));
memset(g_ContinuationHistory, 0, sizeof(g_ContinuationHistory));
- for (u = 0; u < FH_STATS_TABLE_SIZE; u++)
- {
- g_FailHighs[u].uWholeThing = 0x00010001;
- }
+ // 2026-10-02: was 0x00010001 (1 FH / 1 attempt, i.e. a faked 100%
+ // rate) so that GetMoveFailHighPercentage's callers had something
+ // non-zero to gate on even with no real evidence. That made the
+ // table dishonest -- a move's reported percentage didn't track its
+ // actual observed rate until enough real attempts diluted the
+ // fake prior back out. Zeroing it means GetMoveFailHighPercentage
+ // returns a genuine 0/0 until the first real observation; callers
+ // that need to withhold judgement on a cold move must check
+ // *puAttempts themselves rather than relying on an inflated
+ // starting percentage (see GetLMRReduction, searchsup.c).
+ memset(g_FailHighs, 0, sizeof(g_FailHighs));
}
@@ -346,14 +351,14 @@ Routine description:
generalizes across any branch where the opponent plays the same
move again, unlike killers.
- Depth-gated: a shallow fail-high (low uRemainingDepth, i.e. close
- to the QSearch handoff) is much lower-confidence evidence than one
- found with many plies still remaining, but unlike killers (which
- are ply-scoped and self-correct locally), this table is global --
- keyed only by move identity, with nothing to stop a later, shallow
- write from clobbering an earlier, deep one just because it's more
- recent. Only overwrite when the new evidence is at least as deep
- as what's already there.
+ 2026-10-02: corrected -- this used to claim depth-gated writes
+ ("only overwrite when the new evidence is at least as deep as
+ what's already there"), but that was already stale: an A/B test
+ (see the inline comment below) measured depth-gating as *worse*
+ (lower hit rate, no EBF gain) than unconditional recency-based
+ overwrite, and the code was changed accordingly without this
+ routine description being updated to match. Always overwrites on
+ recency now; there is no depth gate.
Parameters:
@@ -369,7 +374,6 @@ Return value:
{
MOVE mvLast;
ULONG u;
- ULONG uPly;
ASSERT(!IS_CAPTURE_OR_PROMOTION(mv));
ASSERT(mv.uMove);
@@ -405,15 +409,12 @@ Return value:
// measured *worse* hit rate with no EBF gain -- this table is
// global, not ply-scoped, so a frozen "deepest wins" entry can
// go stale while later, more locally-relevant evidence gets
- // rejected. Always overwrite (recency); track depth instead to
- // scale the read-time bonus.
- uPly = uRemainingDepth / ONE_PLY;
+ // rejected. Always overwrite (recency).
if (!IS_SAME_MOVE(mv, ctx->mvCounter[u][0]))
{
ctx->mvCounter[u][1] = ctx->mvCounter[u][0];
ctx->mvCounter[u][0] = mv;
}
- ctx->uCounterDepth[u] = (UCHAR)MINU(uPly, 255);
}
@@ -994,25 +995,52 @@ Return value:
ULONG
-GetMoveFailHighPercentage(IN MOVE mv, OUT ULONG *puAttempts)
+GetMoveFailHighPctThousandths(IN MOVE mv, OUT ULONG *puAttempts)
/**
Routine description:
- Lookup a move in the fail high percentage history table and return
- its approximate fail high percentage.
+ Lookup a move identity (see MOVE_TO_FH_INDEX -- (cFrom, cTo, pMoved),
+ NOT a specific position) in the fail-high history table and return
+ its observed fail-high rate in THOUSANDTHS OF A PERCENT (0-100000),
+ the same unit g_uJointFHThousandths (data.c) uses, so a caller can
+ compare this move's own dynamic rate directly against the static
+ table's bucket-specific prediction for the situation it's in right
+ now.
+
+ 2026-10-03: this routine used to return per-mille (0-1000) while
+ claiming, in this very comment, to share the static table's unit --
+ which is thousandths of a PERCENT, 100x finer. Every caller was
+ individually correct (each compared against a constant calibrated
+ in per-mille), but the two families were impossible to tell apart by
+ name: ShouldLatePrune held LMP_DYNAMIC_MAX_THOUSANDTHS (1, meaning
+ 0.1%) and LMP_STATIC_MAX_THOUSANDTHS (1, meaning 0.001%) side by
+ side. Converted to the static table's unit so the comparison this
+ comment always advertised is now literally valid, and every
+ threshold constant renamed to ..._PCT_THOUSANDTHS.
+ Returns a genuine 0/0 -- 0, *puAttempts 0 -- for a move
+ identity with no observations since the last ClearDynamicMoveOrdering;
+ callers that need to withhold judgement on a cold move must check
+ *puAttempts themselves, not assume a low return value means "safe."
+
+ Renamed from GetMoveFailHighPercentage (whole-percent resolution)
+ 2026-10-02 -- whole-percent granularity can't distinguish 0% from
+ the sub-1%-of-the-time baseline rate (data.c:1209's "Overall
+ eligible FH%: 0.400%"), which matters once this is compared against
+ the joint table's own thousandths-resolution predictions rather
+ than a hand-picked whole-percent threshold.
Parameters:
MOVE mv
ULONG *puAttempts : optional (may be NULL) -- receives the number of
- observations the percentage is based on, so callers can weight
- by sample confidence instead of trusting a percentage computed
- from as few as one attempt.
+ observations the rate is based on, so callers can weight by
+ sample confidence instead of trusting a rate computed from as
+ few as one attempt.
Return value:
- ULONG
+ ULONG : fail-high rate in thousandths of a percent (0-100000)
**/
{
@@ -1026,11 +1054,44 @@ Return value:
{
return(0);
}
- n *= 100;
+ n *= 100000;
return(n / d);
}
+void
+GetMoveFailHighRawCounts(IN MOVE mv, OUT ULONG *puFailHighs, OUT ULONG *puAttempts)
+/**
+
+Routine description:
+
+ 2026-10-02, one-off diagnostic support: raw (unrounded) FailHighs/
+ Attempts counts behind GetMoveFailHighPctThousandths, needed to
+ reconstruct HEAD's old (pre-honest-init) equivalent percentage
+ exactly -- (rawFH+1)*100/(rawAttempts+1) -- without the rounding
+ GetMoveFailHighPctThousandths' integer division already introduces.
+ Not meant for any other caller; remove once the HEAD-vs-candidate
+ reduction-rate comparison this supports is done.
+
+Parameters:
+
+ MOVE mv
+ ULONG *puFailHighs
+ ULONG *puAttempts
+
+Return value:
+
+ void
+
+**/
+{
+ ULONG u = MOVE_TO_FH_INDEX(mv);
+
+ *puFailHighs = g_FailHighs[u].u16FailHighs;
+ *puAttempts = g_FailHighs[u].u16Attempts;
+}
+
+
void
CleanupDynamicMoveOrdering(void)
/**
diff --git a/src/eval.c b/src/eval.c
index 3af0d92..9e50f0a 100644
--- a/src/eval.c
+++ b/src/eval.c
@@ -2852,7 +2852,17 @@ Return value:
g_u64SigSeeds[KingPosition(pos, BLACK)][KING][BLACK];
ULONG uSlot = (ULONG)u64Key & (KING_SAFETY_HASH_TABLE_SIZE - 1);
KING_SAFETY_HASH_ENTRY *pEntry = &ctx->rgKingSafetyHash[uSlot];
-#ifdef PERF_COUNTERS
+#ifdef EVAL_TIME
+ // 2026-10-03: moved from bare PERF_COUNTERS to EVAL_TIME, where the
+ // other 32 cycle-counting sites in this file already live. These
+ // three were the only rdtsc calls leaking into the standard build
+ // profile -- two per king-safety hash probe, in one of the hottest
+ // paths in the engine, and rdtsc is ~20-30 cycles and partially
+ // serializing, so it defeats out-of-order execution exactly where
+ // that hurts most. EVAL_TIME exists for precisely this measurement
+ // ("make a version that counts cycles spent in eval", GNUmakefile),
+ // so nothing is lost -- the capability just stops being paid for in
+ // every match and every suite run.
UINT64 u64Start = SystemReadTimeStampCounter();
#endif
@@ -2860,7 +2870,7 @@ Return value:
if (pEntry->u64Key == u64Key)
{
INC(ctx->sCounters.kingsafetyhash.u64Hits);
-#ifdef PERF_COUNTERS
+#ifdef EVAL_TIME
ctx->sCounters.kingsafetyhash.u64CyclesHit +=
(SystemReadTimeStampCounter() - u64Start);
#endif
@@ -2869,7 +2879,7 @@ Return value:
pEntry->u64Key = u64Key;
pEntry->iFileStormDefects[WHITE] = _ComputeFileStormDefects(pos, pHash, WHITE);
pEntry->iFileStormDefects[BLACK] = _ComputeFileStormDefects(pos, pHash, BLACK);
-#ifdef PERF_COUNTERS
+#ifdef EVAL_TIME
ctx->sCounters.kingsafetyhash.u64CyclesMiss +=
(SystemReadTimeStampCounter() - u64Start);
#endif
diff --git a/src/eval_tune/match_play.py b/src/eval_tune/match_play.py
index d8a8e7b..0985801 100755
--- a/src/eval_tune/match_play.py
+++ b/src/eval_tune/match_play.py
@@ -48,6 +48,7 @@ import select
import sys
import time
from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED
+from datetime import date
from pathlib import Path
import chess
@@ -220,11 +221,26 @@ def sample_openings(pgn_path, n, min_ply=8, max_ply=20, seed=0,
def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen,
- depth=None, time_control_sec=None, max_plies=200):
+ depth=None, white_time_sec=None, black_time_sec=None,
+ max_plies=200, post=False):
"""Returns (result, moves, white_stats, black_stats) where result is
1.0/0.5/0.0 from White's perspective, moves is the list of UCI moves
actually played (for PGN reconstruction), and white_stats/black_stats
- are EngineStats instances covering that side's moves in this game."""
+ are EngineStats instances covering that side's moves in this game.
+
+ 2026-10-03: the time control is now PER SIDE (white_time_sec /
+ black_time_sec) rather than one value shared by both engines, so a
+ time-odds match is expressible -- notably HEAD vs HEAD at 2:1, which
+ measures what one extra ply of depth is actually worth in Elo *in
+ this engine*, the calibration every depth-buying search change
+ (LMR/EFP/LMP) is implicitly betting on. Pass the same value twice for
+ an ordinary even match. `depth` (--sd) stays shared: fixed-depth odds
+ would be a different experiment and there's no caller for it.
+
+ NOTE for callers: these two parameters are keyed by SEAT (white/
+ black), but in a time-odds match the odds must follow the ENGINE,
+ which swaps seats every other game by design. main()'s run() does
+ that mapping via candidate_is_white -- see the warning there."""
moves = []
white_stats = EngineStats()
black_stats = EngineStats()
@@ -250,15 +266,41 @@ def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen,
with Engine(white_engine_path, f"{scratch_dir}/w", engine_args) as we, \
Engine(black_engine_path, f"{scratch_dir}/b", engine_args) as be:
# Disable book and pondering. Set time control. Resign lost positions.
- tc_cmd = f"sd {depth}" if depth is not None else f"st {time_control_sec}"
- for command in ( "book name /none",
- "set ThinkOnOpponentsTime false",
- "set ResignThreshold -1000",
- tc_cmd):
+ # 2026-10-03: Engine.__init__ (tune_eval_dna.py:120) sends
+ # "nopost" unconditionally, and root.c:248 gates the
+ # per-iteration PV lines on g_Options.fShouldPost -- so those
+ # lines are never emitted and local_ebf (the TRUE per-ply
+ # branching ratio, as opposed to the cumulative
+ # nodes**(1/depth) `ebf` that conflates aspiration/root-PV
+ # re-search cost with real tree growth) could never be computed.
+ # It reported 0.000 for every engine in every match since it was
+ # added. "post" re-enables those lines. Opt-in via --post rather
+ # than default-on: it adds roughly one stdout line per iteration
+ # per move, which every engine process must then be read through,
+ # and the default behavior of a harness in active use should not
+ # change silently underneath a running experiment.
+ startup = ["book name /none",
+ "set ThinkOnOpponentsTime false",
+ "set ResignThreshold -1000"]
+ if post:
+ startup.append("post")
+ for command in startup:
_dbg("W", f"send: {command}")
we._send(command)
_dbg("B", f"send: {command}")
be._send(command)
+ # Time control is the one setting that can legitimately differ
+ # between the two engines (time odds), so it is sent separately
+ # rather than as part of the shared command list above.
+ if depth is not None:
+ white_tc = black_tc = f"sd {depth}"
+ else:
+ white_tc = f"st {white_time_sec}"
+ black_tc = f"st {black_time_sec}"
+ _dbg("W", f"send: {white_tc}")
+ we._send(white_tc)
+ _dbg("B", f"send: {black_tc}")
+ be._send(black_tc)
board = chess.Board(fen)
for ply_idx in range(max_plies):
@@ -273,12 +315,49 @@ def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen,
mover._send(f"setboard {board.fen()}")
mover._send("go")
move_uci = None
+ # 2026-10-03 BUGFIX: the per-iteration PV lines _ITER_RE
+ # matches are printed DURING the search, i.e. BEFORE the
+ # "move" line -- so they arrive in THIS loop, not in the
+ # trailing stats block below. They used to be parsed only
+ # down there, which meant this loop read and discarded every
+ # one of them and iter_ratio_count was always 0, so
+ # avg_local_ebf silently reported 0.000 for every engine in
+ # every match since it was added (2026-09-18). The whole
+ # point of local_ebf is that it is a TRUE per-ply branching
+ # ratio, unlike the cumulative nodes**(1/depth) `ebf` which
+ # conflates aspiration-window/root-PV-change re-search cost
+ # with real tree growth -- so the one metric that could
+ # settle "is our branching factor actually ~3.9 or ~2.0"
+ # was dead on arrival. Accumulate here as well.
+ last_iter_depth = last_iter_nodes = None
+ iter_log_ratio_sum = 0.0
+ iter_ratio_count = 0
+
+ def _note_iter(line):
+ nonlocal last_iter_depth, last_iter_nodes
+ nonlocal iter_log_ratio_sum, iter_ratio_count
+ im = _ITER_RE.match(line)
+ if not im:
+ return
+ iter_depth = int(im.group(1))
+ iter_nodes = int(im.group(2))
+ if (last_iter_depth is not None and
+ last_iter_nodes is not None and
+ iter_nodes > last_iter_nodes and
+ iter_depth > last_iter_depth):
+ iter_log_ratio_sum += (math.log(iter_nodes / last_iter_nodes)
+ / (iter_depth - last_iter_depth))
+ iter_ratio_count += 1
+ last_iter_depth = iter_depth
+ last_iter_nodes = iter_nodes
+
for _ in range(400):
line = mover.stdout.readline()
if not line:
_dbg(tag, "EOF while scanning for move line")
break
_dbg(tag, f"read(move-scan): {line!r}")
+ _note_iter(line)
if line.startswith("tellics resign"):
# Engine resigned instead of moving -- root.c prints
# this and returns without ever printing "move ...",
@@ -310,9 +389,8 @@ def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen,
# together at the end (arithmetic mean of ratios would let
# one huge outlier iteration dominate; geometric mean is
# the correct way to average ratios/growth-factors).
- last_iter_depth = last_iter_nodes = None
- iter_log_ratio_sum = 0.0
- iter_ratio_count = 0
+ # (state hoisted above the move-scan loop -- see the
+ # 2026-10-03 bugfix comment there)
for _ in range(_STATS_TRAILING_LINES):
line = _readline_or_none(mover.stdout, _STATS_IDLE_TIMEOUT_SEC)
if line is None:
@@ -339,22 +417,7 @@ def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen,
if fm:
ply_fmb = float(fm.group(1))
continue
- im = _ITER_RE.match(line)
- if im:
- iter_depth = int(im.group(1))
- iter_nodes = int(im.group(2))
- if (last_iter_depth is not None and
- last_iter_nodes is not None and
- iter_depth != last_iter_depth and
- iter_nodes > last_iter_nodes and
- iter_depth > last_iter_depth):
- ratio = iter_nodes / last_iter_nodes
- depth_delta = iter_depth - last_iter_depth
- iter_log_ratio_sum += math.log(ratio) / depth_delta
- iter_ratio_count += 1
- last_iter_depth = iter_depth
- last_iter_nodes = iter_nodes
- continue
+ _note_iter(line)
if ply_nodes is not None and ply_time is not None:
ply_local_ebf = (math.exp(iter_log_ratio_sum / iter_ratio_count)
if iter_ratio_count else None)
@@ -391,10 +454,11 @@ def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen,
def build_pgn_game(start_fen, moves, result_white, white_name, black_name):
board = chess.Board(start_fen)
game = chess.pgn.Game()
- game.headers["Event"] = "typhoon DNA gate match"
+ game.headers["Event"] = "typhoon self-play match"
game.headers["White"] = white_name
game.headers["Black"] = black_name
game.headers["FEN"] = start_fen
+ game.headers["Date"] = f"{date.today()}"
game.setup(board)
result_str = {1.0: "1-0", 0.0: "0-1", 0.5: "1/2-1/2"}[result_white]
game.headers["Result"] = result_str
@@ -672,14 +736,85 @@ def main():
"the two are actually mutually exclusive now.")
tc.add_argument("--st", type=float, default=None,
help="seconds per move per side instead of fixed depth")
+ # Time odds. See play_one_game's docstring for why this exists: the
+ # headline use is HEAD vs HEAD at 2:1 to price one extra ply of
+ # depth in Elo for THIS engine, which bounds what any depth-buying
+ # search change can ever be worth.
+ ap.add_argument("--odds", type=float, default=None, metavar="MULT",
+ help="give the CANDIDATE engine MULT times the "
+ "head engine's seconds per move (e.g. --st 4 "
+ "--odds 2 => head 4s, candidate 8s). Requires "
+ "--st; incompatible with --sd. Use --st-head/"
+ "--st-candidate instead for full control.")
+ ap.add_argument("--st-head", type=float, default=None, metavar="SEC",
+ help="explicit seconds per move for the head engine "
+ "(overrides --st for that side)")
+ ap.add_argument("--st-candidate", type=float, default=None, metavar="SEC",
+ help="explicit seconds per move for the candidate "
+ "engine (overrides --st for that side)")
+ ap.add_argument("--post", action="store_true",
+ help="send 'post' so engines emit per-iteration PV "
+ "lines, which is what local_ebf (the true "
+ "per-ply branching ratio) is computed from. "
+ "Without this, local_ebf reads 0.000 -- the "
+ "engines never print those lines because "
+ "Engine.__init__ sends 'nopost'.")
ap.add_argument("--max-plies", type=int, default=200)
ap.add_argument("--scratch", default="/usr/local/tmp/typhoon_match")
ap.add_argument("--log", default="match_history.log")
- ap.add_argument("--pgn-out", default="match_games.pgn",
+ ap.add_argument("--pgn-out", default="../self_play_games.pgn",
help="every played game is appended here as PGN")
args = ap.parse_args()
- if args.sd is None and args.st is None:
+
+ # --- resolve the per-side time controls once, loudly ---------------
+ # Deliberately strict: a time-odds match that silently degrades into
+ # an even one is the dangerous failure here, because it still scores
+ # ~0.500 and looks perfectly healthy (same family of silent confound
+ # as the still-live opening book and the shared logfile this script's
+ # header documents). So every inconsistent combination is a hard
+ # error rather than a best-effort interpretation.
+ odds_requested = (args.odds is not None or
+ args.st_head is not None or
+ args.st_candidate is not None)
+ if odds_requested and args.sd is not None:
+ ap.error("time odds (--odds/--st-head/--st-candidate) needs a "
+ "time control, not --sd; fixed-depth odds would be a "
+ "different experiment entirely.")
+ if args.sd is None and args.st is None and not odds_requested:
args.sd = 8
+ if args.odds is not None:
+ if args.st is None:
+ ap.error("--odds multiplies --st, so --st is required with it")
+ if args.st_candidate is not None:
+ ap.error("--odds and --st-candidate both set the candidate's "
+ "time; pass one or the other")
+ if args.odds <= 0:
+ ap.error("--odds must be positive")
+ head_time = args.st_head if args.st_head is not None else args.st
+ cand_time = args.st_candidate if args.st_candidate is not None else args.st
+ if args.odds is not None:
+ # round: 4 * 1.5 is 6.000000000000001 in binary float, which ends
+ # up verbatim in the "st" command and in the result line's TC=
+ # stamp. Harmless to the engine, ugly in a log that gets diffed.
+ cand_time = round(args.st * args.odds, 4)
+ if args.sd is None:
+ if head_time is None or cand_time is None:
+ ap.error("need --st (or both --st-head and --st-candidate) "
+ "when not using --sd")
+ if odds_requested and head_time == cand_time:
+ ap.error(f"time odds requested but both sides resolved to "
+ f"{head_time}s -- that is an even match. Check "
+ f"--odds/--st-head/--st-candidate.")
+ print(f"TIME_CONTROL: head={head_time}s/move "
+ f"candidate={cand_time}s/move"
+ + (" *** TIME ODDS ***" if head_time != cand_time else ""))
+ else:
+ print(f"TIME_CONTROL: both sides sd {args.sd}")
+ # Stash on args so print_final_summary (a separate function, already
+ # taking args) can stamp the time control into the result line
+ # without widening its signature at both call sites.
+ args.head_time = head_time
+ args.cand_time = cand_time
head_engine = str(Path(args.head_engine).resolve())
candidate_engine = str(Path(args.candidate_engine).resolve())
@@ -706,10 +841,23 @@ def main():
def run(job):
i, fen, candidate_is_white, white_engine, black_engine = job
game_start = time.monotonic()
+ # The odds follow the ENGINE, not the seat. Every opening is
+ # played twice with colours swapped (see the jobs[] loop above),
+ # so keying the time control on White/Black instead would hand
+ # the advantage to each engine in exactly half the games -- a
+ # balanced match that measures nothing while scoring a healthy-
+ # looking ~0.500. Hence this mapping off candidate_is_white, and
+ # the per-game assertion below.
+ white_time = cand_time if candidate_is_white else head_time
+ black_time = head_time if candidate_is_white else cand_time
+ if args.sd is None and head_time != cand_time:
+ assert ((white_time == cand_time) == candidate_is_white), \
+ "time-odds seat mapping inverted"
result_white, moves, white_stats, black_stats = play_one_game(
white_engine, black_engine, f"{args.scratch}/g{i}", fen,
- depth=args.sd, time_control_sec=args.st,
- max_plies=args.max_plies,
+ depth=args.sd,
+ white_time_sec=white_time, black_time_sec=black_time,
+ max_plies=args.max_plies, post=args.post,
)
game_elapsed = time.monotonic() - game_start
candidate_result = result_white if candidate_is_white else (1.0 - result_white)
@@ -873,9 +1021,21 @@ def print_final_summary(done, candidate_points, wins, draws, losses,
sprt_str = (f" SPRT={outcome} LLR={sprt.llr():+.3f} "
f"BOUNDS=[{sprt.la:.3f},{sprt.lb:.3f}] "
f"ELO0={args.elo0:.1f} ELO1={args.elo1:.1f}")
+ # Stamp the time control into the result line itself: a time-odds
+ # score is NOT comparable with an even-match score, and a bare
+ # CANDIDATE_SCORE in a log file gives no way to tell them apart
+ # later.
+ if args.sd is not None:
+ tc_str = f" TC=sd{args.sd}"
+ elif args.head_time != args.cand_time:
+ tc_str = (f" TC=ODDS(head={args.head_time}s,"
+ f"cand={args.cand_time}s)")
+ else:
+ tc_str = f" TC=st{args.head_time}"
print(f"CANDIDATE_SCORE={score:.4f} GAMES={n_games} "
f"WINS={wins} DRAWS={draws} LOSSES={losses} "
- f"ELO={elo:+.1f}+/-{err:.1f} LOWER95={lower95:.4f}{sprt_str}")
+ f"ELO={elo:+.1f}+/-{err:.1f} LOWER95={lower95:.4f}"
+ f"{tc_str}{sprt_str}")
print(f"CANDIDATE_STATS: {candidate_stats.summary()}")
print(f"HEAD_STATS: {head_stats.summary()}")
diff --git a/src/eval_tune/test_vs_head.sh b/src/eval_tune/test_vs_head.sh
index d6d9fba..592ecf1 100755
--- a/src/eval_tune/test_vs_head.sh
+++ b/src/eval_tune/test_vs_head.sh
@@ -24,17 +24,75 @@
# matches can run concurrently without colliding.
# Defaults to "match".
#
+# ...and any further arguments are passed straight through to
+# match_play.py, so a one-off flag no longer needs this file edited.
+#
+# Three env vars override the baked-in defaults (2026-10-03), so the
+# common knobs are reachable without touching the invocation below:
+#
+# TC_ARGS time control. Default: "--st 4"
+# SPRT_ARGS hypothesis test. Default: "--sprt --elo0 0 --elo1 5"
+# GAMES game cap. Default: 10000
+#
+# Why these became overridable: the baked-in "--sprt --elo0 0 --elo1 5"
+# cannot resolve the effect sizes this project's LMR work actually
+# produces. At the observed draw rate the standard error in Elo is about
+# 249/sqrt(n), so separating 0 from 5 Elo at even 2 sigma needs ~10,000
+# games (~53h here) -- and every LMR match run to date has instead
+# wandered around llr 0 for 2,000+ games and been abandoned, which is
+# exactly what a true effect sitting BETWEEN the two hypotheses looks
+# like. For a change you would only ship at >= 15 Elo, use
+# SPRT_ARGS="--sprt --elo0 0 --elo1 15": it rejects a +2 effect in hours
+# instead of never.
+#
# Examples:
# test_vs_head.sh # old default behavior
# test_vs_head.sh /tmp/typhoon/filestorm_only /tmp/typhoon/candidate queenprox_backflight
+#
+# # Faster-rejecting bounds for a change only worth shipping at >=15 Elo:
+# SPRT_ARGS="--sprt --elo0 0 --elo1 15" test_vs_head.sh
+#
+# # TIME-ODDS CALIBRATION: same binary both sides, candidate gets 2x the
+# # time. Measures what ~1.6 extra plies (at ebf 3.6) is worth in Elo in
+# # THIS engine, which bounds what any depth-buying search change
+# # (LMR/EFP/LMP) can ever deliver. No SPRT -- this measures a quantity
+# # rather than testing a hypothesis, and ELO=+/- prints either way.
+# # ~600-1000 games is plenty: the expected effect is large, so +/-10 Elo
+# # of precision already separates "a ply is worth 25" from "worth 65".
+# SPRT_ARGS="" TC_ARGS="--st 4 --odds 2" GAMES=1000 \
+# test_vs_head.sh ../../head_reference/typhoon \
+# ../../head_reference/typhoon timeodds
set -e
HEAD="${1:-../../head_reference/typhoon}"
+CANDIDATE_ARG="${2:-}"
LABEL="${3:-match}"
+# Note the `-` (not `:-`) for the three env overrides below: an
+# explicitly EMPTY value must be honored, not replaced by the default.
+# SPRT_ARGS="" is the documented way to run without a hypothesis test
+# (e.g. the time-odds calibration above), and `:-` would silently put
+# --sprt back.
+TC_ARGS="${TC_ARGS---st 4}"
+SPRT_ARGS="${SPRT_ARGS---sprt --elo0 0 --elo1 5}"
+GAMES="${GAMES-10000}"
+
+# Drop the (up to) three positional args so "$@" holds only extra
+# pass-through flags for match_play.py. This MUST come after the three
+# positionals have been captured into HEAD/CANDIDATE_ARG/LABEL above:
+# an earlier version of this shifted first and then tested "$2" to
+# decide whether to rebuild, which (a) made every no-extra-args run
+# take the rebuild branch and silently clobber ../typhoon with a
+# different make profile, and (b) made the first pass-through FLAG VALUE
+# land in "$2" and get used as the candidate BINARY PATH -- producing a
+# baffling "PermissionError: ... Permission denied: <scratch dir>" from
+# Popen trying to execute a directory.
+for _ in 1 2 3; do
+ if [ $# -gt 0 ]; then shift; fi
+done
-if [ -n "$2" ]; then
- CANDIDATE="$2"
+if [ -n "$CANDIDATE_ARG" ]; then
+ CANDIDATE="$CANDIDATE_ARG"
else
cd ..
gmake clean
@@ -48,10 +106,11 @@ python3 ./match_play.py \
${HEAD} \
${CANDIDATE} \
--pgn ../../pgn/twic_filtered.pgn \
- --games 10000 \
- --workers 12 \
- --st 4 \
- --sprt --elo0 0 --elo1 5 \
+ --games ${GAMES} \
+ --workers 18 \
+ ${TC_ARGS} \
+ ${SPRT_ARGS} \
--scratch /usr/local/tmp/typhoon_match_${LABEL} \
--log ${LABEL}.log \
- --pgn-out ../${LABEL}_games.pgn
+ --pgn-out ../${LABEL}_games.pgn \
+ "$@"
diff --git a/src/lmr_testing/2026-09-28_four_axis_joint_table_plan.md b/src/lmr_testing/2026-09-28_four_axis_joint_table_plan.md
new file mode 100644
index 0000000..4a67561
--- /dev/null
+++ b/src/lmr_testing/2026-09-28_four_axis_joint_table_plan.md
@@ -0,0 +1,238 @@
+# Four-axis joint FH% table -- next steps, 2026-09-28
+
+Checkpoint: `git stash` (`stash@{0}`, message starting "four-axis (dumb/gap/
+late/depth) marginal calibration instrumentation...") holds the working
+instrumentation this plan builds on: `fPruneReduceEligible` (search.c), the
+`bbMinorAttacks`/`bbRookAttacks` snapshot fix, and four independently-wired
+marginal calibration axes (dumb, gap, late, depth), each confirmed clean at
+real volume on `tests/ecm_ringers.ep_`, `sd 14`, single position family. Pop
+that stash to resume from exactly this point.
+
+## Where each axis landed (marginal, single-position-family sample)
+
+All four measured against the *eligible* leftover baseline (0.31% FH,
+matching HEAD's own EFP/LMR eligibility checklist plus losing-capture
+admission via cached SEE sign, plus `iAlpha < +NMATE`):
+
+- **dumb** (material hung, `EstimateHangingMaterialCp`): every bucket below
+ baseline except 1-100cp (~3x baseline, traced to SEE-scored losing
+ captures + underpromotions -- a real SEE limitation, not a bug; plan is to
+ exclude this bucket from pruning/reduction eligibility, not fix SEE).
+- **gap** (`iAlpha - iEval`): clean, ~4 orders of magnitude, 3.80% (gap<=0)
+ down to ~0.00% (900cp+), strictly decreasing.
+- **late** (this move's ordinal position, `uLegalMoves`): clean, ~23x decline
+ move 2 (1.61%) to move 27 (0.07%), small uptick in the deep tail (raw-move-
+ number-vs-fraction-of-list issue, already known from earlier work).
+- **depth** (`uDepth / ONE_PLY` at this node, before this move's own
+ extension/reduction): clean, ~10-15x decline depth 0 (0.44%) to depth 9-13
+ (~0.00-0.06%). Hypothesis: shallow subtrees are noisier -- the opponent
+ hasn't had enough plies to find its real defense, so a "leftover" move
+ spuriously crosses beta more often near the horizon than deep, where the
+ subtree has room to converge to the move's true (usually bad) value. This
+ is in tension with the traditional "EFP is safest near the horizon"
+ argument, which is about *cost per mistake* (small subtree), not
+ *frequency of mistakes* -- our data says frequency is actually *highest*
+ right where the traditional argument calls it safest. The tradeoff is
+ real, not resolved by this data alone.
+
+## Why a joint table now, not a cheap combined estimate
+
+Discussed and rejected the "naive-Bayes combine the four marginals, skip
+the joint collection" shortcut as the first move: cheaper, but doesn't
+address the actual open question, which is what to do about the fact that
+*every* prior attempt in this project to turn a calibrated signal into a
+live pruning/reduction win has landed at "roughly even," never a clear win,
+regardless of how correctness-verified the signal is. That pattern won't be
+fixed by better precision; if it's real, it's more likely explained by
+information the search loses when a move gets skipped (history/TT/killer
+contributions later parts of the tree would have used) or by distribution
+shift (the table is trained under full search, deployed under a search that
+now prunes/reduces differently based on it). Building the joint table is
+still worth doing -- Scott wants the real thing, not an approximation, and
+a short test run is cheap -- but the *first live test* of it must be via
+**reduction, not pruning** (see "Consumption, phase 2" below), specifically
+because reduction is self-correcting (existing re-search-on-unexpected-
+raise machinery) in a way pruning structurally cannot be. If reduction
+*also* lands at "even," that is real evidence the ceiling is structural,
+not a signal-quality problem, and no further table refinement will fix it.
+
+## Table shape
+
+Same four axes as the marginal work, but joint, cache-shaped, and
+coarsened where the marginal curves already went flat:
+
+| axis | marginal buckets | shipped buckets | boundary notes |
+|-------|-----------------:|----------------:|-----------------|
+| depth | 25 | **12** | exact 0-9 (all real signal/volume lives here), then 10-13 grouped, 14+ overflow |
+| dumb | 11 | **11** | unchanged -- already tight, semantically meaningful (0=not dumb, 1-9=100cp bins, 10=901cp+) |
+| gap | 11 | **11** | unchanged -- already tight (0=gap<=0, 1-9=100cp bins, 10=901cp+) |
+| late | 33 | **15** | raw move 1 is never produced (`uMoveNum` is 1-based) and raw move 2 is the real floor (the `fPruneReduceEligible`/collection gate requires `uLegalMoves > 1`) -- exact moves 2-13 (biggest swings live here), then 14-21 / 22-31 grouped, 32+ overflow. See 2026-09-30 correction note below: the original bucketing shifted by only 1 (assuming raw 1 was the floor), leaving shipped bucket 0 permanently empty. |
+
+Total shipped cells: 12 x 11 x 11 x 15 = **21,780**.
+
+**Axis order in the C array: depth outermost.** Depth is the one axis fixed
+for an entire node's move loop; dumb/gap/late all vary move-to-move within
+that same loop. Declaring the array as `[SEARCH_DEPTH_BUCKETS][DUMB][GAP]
+[LATE]` means every lookup during one node's whole move loop stays inside
+one depth-slice: 11 x 11 x 15 = 1,815 cells. At 2 bytes/cell (see below)
+that slice is **~3.5KB** -- small enough to stay resident in L1 for the
+entire node, not just L2 for the whole table. First touch per node pays one
+cache-miss; everything else in that node is hot. The internal order of
+dumb/gap/late doesn't matter much for locality (all three vary per-move
+similarly) -- pick whatever's most readable; `[depth][dumb][gap][late]`
+matches the order we normally discuss them in.
+
+## Cell encoding: thousandths of a percent, not hundredths
+
+`USHORT`, storing `FH% * 1000` (so 1 == 0.001%, 1000 == 1.000%). Max
+representable ~65.535%, far more than needed (every real cell we've seen is
+under ~6%) -- the range headroom isn't the point, the *low-end* precision
+is: real cells run from ~0.00% up through maybe 2-4%, and confirmed-zero
+tests and dumb/gap tail buckets both live down in the 0.00-0.10% range
+where hundredths-of-a-percent (the old table's unit) was already too coarse
+to distinguish "genuinely near-zero" from "true zero." Thousandths gives
+10x finer resolution exactly where decisions get made. No special encoding
+needed for the high end -- Scott's "anything over ~0.39% is useless to us
+anyway" observation is true (pruning/reduction decisions never care whether
+a bad cell is 2% or 6%), but it doesn't require clamping or a different
+scale; a linear thousandths encoding already wastes no precision there,
+because we simply never read the top of the range for a decision. Leave it
+be.
+
+Sentinel: `0xFFFF` (`FH_TABLE_SENTINEL`-equivalent) means "below this
+run's sample floor, don't trust this cell yet" -- see below.
+
+## Sample floor, this run only
+
+This is a 90-minute preliminary collection, not the eventual continuously-
+updated table. For *this* pass, bake with a `MIN_SAMPLES` floor (propose
+500, tune after seeing real per-cell counts) -- any cell below the floor
+gets the sentinel instead of a real percentage. A consuming (reduction)
+lookup that hits a sentinel cell should fall back to the coarser gap-only
+or dumb-only marginal estimate rather than treat "insufficient data" as
+"confirmed safe." Once this table is run for real (a week, or continuously
+in the background, matching the original project's stated intent), the
+floor can loosen or come off entirely -- Scott's point that "we don't need
+to care whether 0.0% came from 10k or 10B tries" is correct *for a table
+that keeps accumulating*, not for a single 90-minute snapshot.
+
+## How to declare and populate it
+
+1. **New raw collection counters** (chess.h `COUNTERS.tree`, mirrored into
+ `g_Options` the same way the four marginal axes already are): a single
+ joint 4D array at *marginal* (uncoarsened) resolution --
+ `UINT64 u64JointTries[SEARCH_DEPTH_BUCKETS_RAW(25)][HANG_CP_BUCKETS(11)]
+ [GAP_CP_BUCKETS(11)][MOVE_NUM_BUCKETS_RAW(33)]` and the matching
+ `u64JointFH` -- 25*11*11*33 = 99,825 cells * 16 bytes (two UINT64) =
+ ~1.6MB per counter pair. This is collection-only, lives in
+ `SEARCHER_THREAD_CONTEXT` (already ~75MB), and is never shipped -- size
+ doesn't matter here, only in the final baked table. Collecting at full
+ marginal resolution (not pre-coarsened) preserves the option to try a
+ different final bucket shape later without re-collecting, matching how
+ the gap-bucket rework reused the same joint-run pickle by re-bucketing
+ at bake time instead of re-running collection.
+
+2. **Wiring in `search.c`**: reuse the exact same
+ `fPruneReduceEligible`-gated block the four marginal axes already sit
+ in. Compute all four bucket indices (`_HangCpBucket`, `_GapCpBucket`,
+ `_MoveNumBucket`, `_DepthBucket` -- already written, unchanged) and
+ increment the single joint cell `u64JointTries[depthBucket][dumbBucket]
+ [gapBucket][lateBucket]` once per eligible move, `u64JointFH[...]` once
+ per eligible fail-high, in addition to (not instead of) the four
+ existing marginal increments -- keep the marginals live during this run
+ as a cheap cross-check that the joint reduces to what we already
+ understand.
+
+3. **Reassembly format**: a new `FOURAXISRAW depth dumb gap late fh tries`
+ line, printed the same checkpoint-friendly way as the old
+ `FHGAPRAW`/`FHJOINTRAW` lines were (full cumulative dump every N
+ positions via the suite-report path, last-checkpoint-only on
+ reassembly, never summed across checkpoints).
+
+4. **Shard/collect**: 16 shards, `--cpus 1` each (matches every prior
+ collection run in this project -- split.c's parallel helpers don't feed
+ these counters), `sd 16` or deeper (need real depth to populate the
+ depth 8-13 buckets at all -- shallower runs structurally can't reach
+ them), 90 minutes wall-clock per shard via `timeout`, mixed real-game
+ (`cross_sectional_200k.ep_`) + modest tactical (`ecm.ep_`) position pool,
+ same ~90/10 blend as prior runs. Output to `/usr/local/tmp/typhoon/`
+ (persistent, not `/tmp` -- see the disk-full incident earlier this
+ project). **Do the same smoke-test discipline as every prior run**: 3-5
+ minute all-16-shards test first, confirm 0% pruned/no crashes/sane disk
+ growth, *then* commit to the full 90 minutes.
+
+5. **Bake**: python script reads each shard's last checkpoint block,
+ aggregates `FOURAXISRAW` lines into a dict keyed by the *raw* (25 x 11 x
+ 11 x 33) indices, re-buckets depth and late down to the shipped (12,
+ 15) shape (dumb and gap pass through unchanged), applies the sample
+ floor, converts surviving cells to thousandths-of-a-percent, emits a C
+ initializer for `g_uJointFHThousandths[12][11][11][15]` matching the
+ depth-outermost declaration above, and splices it into `data.c` the same
+ brace-depth-tracked way prior bakes did (never a fixed-line-count
+ assumption -- table bodies are huge and shift size on every rebuild).
+
+## Consumption, phase 2 (after the table exists -- not this pass)
+
+Replace the current flat one-ply LMR with a graduated additive term at the
+same call site, using the *already-computed* eligibility gate and bucket
+indices (no new per-move cost beyond the table lookup itself, since
+`fPruneReduceEligible` and all four bucket functions already run on every
+candidate move once this lands):
+
+```
+uPredictedFH = LookupJointTable(depthBucket, dumbBucket, gapBucket, lateBucket);
+extra_reduction_plies = f(-ln(uPredictedFH)) // more permissive than pruning
+ // ever was -- see the doc's
+ // own "on reduction vs
+ // pruning" section for why
+ // reduction can tolerate a
+ // much less conservative
+ // threshold (self-
+ // correcting via re-search,
+ // unlike a prune)
+```
+
+Exclude the dumb axis's 1-100cp bucket from ever triggering *extra*
+reduction beyond whatever the other three axes independently justify --
+that bucket is understood to be SEE's blind spot (structural undervaluing
+of certain sacrifices/underpromotions), not a real "this move is fine"
+signal, and treating it as "safe to reduce more" would be trusting exactly
+the population we already know fools the estimate.
+
+First real test: self-play against `head_reference` (current flat one-ply
+LMR), same protocol as every prior match in this project (LOWER95 >= 0.5
+gate, not raw score; fixed `sd`/`sn` matched to what's actually being
+tested). Watch specifically for whether this breaks the "always even"
+pattern -- if it doesn't, that's the signal to stop iterating on this
+approach rather than build a bigger/more precise table.
+
+## Correction, 2026-09-30: late-bucket-0 was permanently dead, now fixed
+
+Found while reviewing coverage on a fresh 16-shard collection run:
+`_ShipLateBucket`/`ship_late_bucket`'s `-1` shift (search.c, dashboard.py)
+assumed the real floor for raw `late` was 1 (`uMoveNum` is 1-based). It
+isn't -- the collection/consultation gate itself (`fPruneReduceEligible`,
+`search.c`, requires `uLegalMoves > 1`) means raw `late` is never less
+than 2. So shipped bucket 0 (meant for raw==1) could never receive a
+sample: it read `0xFFFF` (sentinel) in every baked table to date, not
+because of insufficient collection time but because the bucketing wasted
+a slot on an unreachable raw value. This alone accounted for 1,452 of the
+21,780 shipped cells (a full `depth x dumb x gap` slice) permanently
+reading "no data," inflating the apparent coverage gap by ~6.7 percentage
+points in every prior coverage report.
+
+**Fix**: shift by 2 instead of 1, anchored on the real floor (raw >= 2):
+raw 2-13 -> shipped 0-11 (singleton), 14-21 -> 12, 22-31 -> 13, 32+ -> 14.
+Same bucket widths as before, just correctly anchored. Applied in lockstep
+to `search.c`'s `_ShipLateBucket` (live lookup path), `dashboard.py`'s
+`ship_late_bucket` (coverage reporting), and the newly-written
+`joint_table_collection/bake_joint.py` (which imports `dashboard.py`'s
+version rather than redefining it, so it can't drift out of sync). The
+table in `data.c` was rebaked from the 2026-09-30 collection run with the
+corrected bucketing; real coverage came out to 18,775/21,780 (86.2%)
+populated, vs. 17,444/21,780 (80.1%) that the old (wrong) bucketing would
+have reported on the same data.
+
+No prior collection data needed to be discarded for this -- it's purely a
+bucketing/reporting bug, not a population/gate problem (a separate concern
+raised and ruled out the same day; see SPEC.md's corresponding note).
diff --git a/src/lmr_testing/2026-10-03_LMR_REDESIGN.md b/src/lmr_testing/2026-10-03_LMR_REDESIGN.md
new file mode 100644
index 0000000..68ed068
--- /dev/null
+++ b/src/lmr_testing/2026-10-03_LMR_REDESIGN.md
@@ -0,0 +1,172 @@
+# Re-imagining the baseline LMR, 2026-10-03
+
+Question: the dumb flat `-1 ply on every leftover move` is load-bearing to
+~40-50 Elo and +1.17 ply (`GET_LMR_REDUCTION.md` 10.12a). Five attempts to
+improve on it have all measured as noise. If we rebuilt the baseline from
+scratch with what we now know, what would it be?
+
+Not looking for a miracle. Looking for ~5 Elo and some sound depth.
+
+---
+
+## The reframing that matters
+
+**Every one of the five attempts pushed in the same direction: trade accuracy
+for depth.** Graded magnitude, wider admission, bigger cuts, LMP, deep
+pruning -- all of them reduce or remove more search in exchange for a higher
+root iteration number.
+
+We now have that direction's exchange rate: **~8 Elo per marginal ply**
+(10.12b). It is a bad rate, and it is bad for a structural reason -- a
+reduction is a *transfer*, not new resource. It lowers the depth of the
+refutation lines (where the move choice is actually decided) to raise the
+depth of the PV. Time-bought plies, which raise the whole distribution, are
+worth ~95 Elo/ply.
+
+**Nobody has ever tried the other direction: trade depth for accuracy.**
+
+That is the gap. If accuracy converts better than depth -- and there is no
+reason to assume it converts at 8 Elo/ply, because it is not buying plies at
+all -- then the remaining few Elo are on that side of the ledger, not this one.
+
+---
+
+## What the five attempts actually taught us
+
+| lesson | evidence |
+|---|---|
+| **Coverage beats magnitude.** Breadth of admission moves total tree size far more than depth-of-cut per move. | section 3 (HEAD-style reduces 76% of decisions and beat the "honest" 62-66% design on node count); section 6 |
+| **Magnitude is capped by the depth DISTRIBUTION, not the formula.** 76% of the LMR population sits at remaining depth buckets 2-3, where `iMaxExtra = depth - 3` permits zero extra plies -- and where the `log(d)*log(m)` curve prescribes ~1 ply anyway, i.e. exactly flat. The curve's whole contribution is **mean 0.164 extra plies ~= +0.05 root ply**. | 10.8, 10.12c |
+| **Learned per-situation grading loses to a flat formula.** Table-driven tiers vs flat `log(d)*log(m)`: 0.224 vs 0.244 mean extra plies -- a wash, and the tier version broke with a re-search-rate inversion when widened. | section 8, 10.8 |
+| **Per-move dynamic signals cannot identify the dangerous moves.** 11 of 23 verified low-band prune misses sit at own-FH-rate **exactly 0** -- the per-move history agreed with the static table and both were wrong. A veto catching the rest would refuse ~49,000 sound prunes per miss prevented. | 10.12h |
+| **`eNodeType` is uninformative here.** Full-width ALL nodes fail HIGH 69-71% of the time. The structural label is close to meaningless. | 10.12f, 10.12d |
+| **Move ordering is the real sophistication, and it is already good.** FMBC 90-95%; `r ~= 2.17`. By the time a move is in the leftover pool the generator has already triaged it -- the table is re-triaging an already-triaged population. | 10.11a, 10.12c |
+| **Only fail-HIGHS are verified.** The re-search path fires on `iScore >= iBeta`. A reduced search that fails low is accepted silently, forever. | search.c move loop |
+
+That last row is the one nobody has acted on.
+
+---
+
+## The redesign
+
+### A. Keep (this is where the 40 Elo lives)
+- **Broad admission.** ~93% of eligible decisions get reduced. Five attempts
+ to be cleverer about *which* moves all failed.
+- **One magnitude formula, `log(depth)*log(moves)`.** Already present
+ (`g_iLMRQuietReduction`, dynamic.c, built by `InitLMRTable`). Keep it as the
+ single source of magnitude. No table, no tiers.
+- **The one-full-width-ply floor.** `JOINT_EXTRA_MIN_REMAINING_DEPTH = ONE_PLY`
+ guarantees a reduced move still gets a real node before qsearch. Stockfish
+ keeps the identical invariant (`search.cpp:1370`, `std::max(1, ...)`) and
+ gets its leanness from separate explicit pruning. Twice falsified as a
+ target (10.9a).
+- **Killer-adjacency vetoes** (this ply's killers, ply-3's). Cheap, exact,
+ principled -- a specific move at a specific ply, not an aggregate rate.
+
+### B. ADD: verify fail-lows near the margin <-- the actual proposal
+The only unverified error direction, attacked with the search's own evidence
+rather than a static prior.
+
+Today, in `search.c`'s move loop:
+```c
+iScore = -Search(ctx, -iAlpha - 1, -iAlpha, uNextDepth); /* reduced */
+if ((iExtend < 0) && (iScore >= iBeta)) { ...re-search full depth... }
+```
+At a null window `iBeta == iAlpha + 1`, so that branch means "the reduced
+search beat alpha". A reduced search that returns `iScore <= iAlpha` is taken
+at face value. Proposed addition:
+
+```c
+else if ((iExtend < 0) && (iScore > iAlpha - LMR_NEAR_MISS_MARGIN))
+{
+ /* Reduced search failed low, but only just. The reduced search is the
+ weakest evidence in the tree and this is the one direction the
+ existing machinery never checks. Re-search at full depth. */
+ uNextDepth -= iExtend;
+ pi->iExtensionAmount = 0; /* defuse the null-move bailout */
+ iScore = -Search(ctx, -iAlpha - 1, -iAlpha, uNextDepth);
+}
+```
+
+Why this is different from everything tried:
+- **Sound by construction.** It only ever ADDS search. It cannot introduce a
+ wrong answer; the worst case is wasted nodes. Every previous attempt risked
+ correctness to buy depth.
+- **It trades depth for accuracy** -- the untried direction. It will cost
+ nominal plies (marginal plies are worth ~8 Elo, so that is a cheap thing to
+ spend) in exchange for catching refutations the reduction hid.
+- **Self-measuring, and falsifiable in one suite run.** Instrument
+ `near_miss_researches` and `near_miss_flips` (how many came back
+ `>= iBeta`). If the flip rate is ~0, the idea is dead immediately and
+ cost nothing. If it is a few percent, those are refutations currently being
+ missed, and the margin can be swept.
+- **It is targeted.** A move returning `alpha - 5` nearly made it; one
+ returning `alpha - 300` did not. Score proximity is real evidence, unlike
+ the cell averages the 4D table offers.
+
+Sweep `LMR_NEAR_MISS_MARGIN` over 0 / 10 / 25 / 50 / 100 cp. Measure flip rate
+and node cost per margin before any self-play.
+
+### C. DELETE (each is unmeasured or measured-as-noise, and each costs real
+### work per decision in a SHIPPING build)
+- **The 4D table admission gate.** `LMR_STATIC_ADMIT_PCT_THOUSANDTHS` vetoes
+ 509 of 21,780 cells -- 2.3% of cell space, 7.4% of traffic -- and the
+ threshold was chosen in section 6 on solve-count deltas 10.6 showed were
+ noise. Removing it also removes `EstimateHangingMaterialCp` plus four bucket
+ functions plus a table lookup *per eligible move*, which is the same class of
+ per-move cost the PERF_COUNTERS cleanup just went after. (Hold if EFP ends up
+ needing the table -- see 10.13 item 1.)
+- **`fDynamicDanger`'s extras gate.** Fires on 27.4% of decisions and only
+ zeroes the extra plies -- which 85% of reduced moves never get anyway. The
+ section 1 "honesty fix" that motivated the whole 2026-10-02 session is
+ therefore nearly inert in shipped behaviour.
+- **The bare countermove-match veto.** Unconditional, unmeasured, and
+ generate.c's own use of a countermove match is evidence-GATED precisely
+ because a bare match was shown not to carry signal.
+- **`JOINT_EXTRA_TIER1/2/3_PCT_THOUSANDTHS`** -- vestigial (TIER1 in comments
+ only, TIER3 only in the disabled LMP path).
+
+### D. CONSIDER: widen coverage past the leftover pool
+`fPruneReduceEligible` requires `fIsLeftoverMove` (sub-`GOOD_MOVE`). Crafty and
+Stockfish reduce **all** quiet moves past a move-count threshold, including ones
+the generator scored into a higher tier. Coverage is the single lever with
+replicated evidence behind it (section 3, section 6, 10.2), and this is the
+largest untapped slice.
+
+But: it buys marginal plies, so expect ~8 Elo/ply and bound expectations
+accordingly. Do it *after* B, because B makes additional coverage safer -- the
+verification catches the extra mistakes wider coverage would make.
+
+### E. DO NOT (all falsified; reasons in 10.13 item 8)
+Per-situation magnitude grading; `eNodeType` modulation; clamp removal /
+reducing into qsearch; dynamic per-move vetoes; chasing bigger cuts.
+
+---
+
+## What this adds up to
+
+A baseline LMR that is **simpler** than today's (no 4D table, no dynamic
+extras gate, no countermove veto, one magnitude formula) and has **one new
+mechanism** that is sound by construction and pushes in the only untried
+direction.
+
+Honest expectation: the deletions are roughly Elo-neutral and buy a little
+nps; the near-miss verification is the only part with real upside, and its
+upside is unknown rather than bounded at 8 Elo/ply, because it is not buying
+plies. If its flip rate comes back near zero it dies in one suite run, which
+is the cheapest possible failure mode.
+
+That is the whole pitch: stop buying depth at 8 Elo/ply, spend some of it back
+on the one error the search never checks.
+
+## Order of work
+1. Instrument the near-miss flip rate ONLY (no behaviour change): count how
+ many reduced fail-lows land within each margin band, and -- behind a
+ verify flag -- how many of those flip to `>= iBeta` at full depth. One
+ suite run answers whether B is worth building.
+2. If the flip rate is material, implement B and sweep the margin on node
+ count + `sn` suites, then self-play with `elo0=0 elo1=15`.
+3. Do the deletions in C as a separate commit, measured on nps (idle box,
+ round-robin arms) rather than on Elo -- they are a complexity/speed play,
+ not a strength play.
+4. D last, and only if B worked.
diff --git a/src/lmr_testing/2026-10-03_UNCOMMITTED_TRIAGE.md b/src/lmr_testing/2026-10-03_UNCOMMITTED_TRIAGE.md
new file mode 100644
index 0000000..f379e96
--- /dev/null
+++ b/src/lmr_testing/2026-10-03_UNCOMMITTED_TRIAGE.md
@@ -0,0 +1,137 @@
+# Uncommitted-change triage, 2026-10-03 EOD
+
+`git status` shows 15 modified files, **5,313 insertions / 194 deletions**.
+That spans several sessions, not just today (date markers in the added lines:
+33 from 2026-10-03, 24 from 10-02, 21 from 09-28, plus 09-29/09-30/10-01).
+This file sorts it into KEEP / DECIDE / DISCARD so a commit can be assembled
+without re-deriving the reasoning.
+
+Nothing here is committed. Evidence for every claim is in
+`GET_LMR_REDUCTION.md` section 10 and `SPEC.md`.
+
+---
+
+## KEEP -- unambiguous, no open questions
+
+### Bug fixes (all found by exercising code, all verified)
+
+| what | file | why it is solid |
+|---|---|---|
+| `JOINT_EXTRA_TIER_COUNT` 4 -> 6 | chess.h | `JOINT_EXTRA_MAX_PLIES` is 5, so `*puExtraTier` could be 4 or 5 and both consumers indexed 4-element arrays out of bounds -- into `u64JointExtraResearch[0]` and into `u64LMPPruned`. Unreachable at sd10 (needs remaining depth >= 14), live in any real search. Counters-only, but it corrupted the exact calibration diagnostic candidates get judged on. 10.4 |
+| Node-type surprise line: 4 defects | search.c, root.c, searchsup.c | (1) denominator was total nodes not per-type; (2) qsearch included, ~84% of the tree, so a stand-pat cutoff read as "ALL failed high"; (3) `CommonSearchInit` early exits reached the counters un-typed and scored against a stale `eNodeType` -- produced an impossible "106.2% of PV nodes"; (4) fail-low test used the IN-PLACE-RAISED `iAlpha`, so every successful alpha-raise counted as a fail-low (PV row read 99.1%). Now uses `iInitialAlpha`. 10.12f |
+| FH-rate unit unification | search.c, searchsup.c, dynamic.c, chess.h | `GetMoveFailHigh*` returned per-mille while `g_uJointFHThousandths` is thousandths-of-a-PERCENT -- 100x apart, same `_THOUSANDTHS` suffix. `ShouldLatePrune` held `LMP_DYNAMIC_MAX_THOUSANDTHS (1)` meaning 0.1% four lines from `LMP_STATIC_MAX_THOUSANDTHS (1)` meaning 0.001%. Every caller was individually correct; the naming made that luck. Converted the whole dynamic family to the table's unit, renamed to `..._PCT_THOUSANDTHS`. 10.8 |
+| `RateBucket` band edges x100 | searchsup.c | Collateral of the unit change: edges were per-mille, so every rate >= 0.1% collapsed into the top bucket and five middle bands read zero. Caught because the histogram printed only its first and last columns. |
+| `local_ebf` fix | eval_tune/match_play.py | Reported 0.000 in **every match since it was added (2026-09-18)**. Two causes: `_ITER_RE` was applied only in the trailing stats parser but the PV lines arrive during the search in the move-scan loop; and `Engine.__init__` sends `nopost`, so `root.c:248` never emitted them at all. Now parsed in both loops plus an opt-in `--post`. This is the metric that proved the published `ebf` (cumulative `nodes**(1/depth)`) has been wrong in every match report -- true per-ply ratio is **~2.0, not ~3.9**. 10.10c |
+| `test_vs_head.sh` shift ordering | eval_tune/test_vs_head.sh | Positionals were shifted BEFORE `$2` was read, so (a) every no-extra-args run took the rebuild branch and clobbered `./typhoon` with a non-standard profile (`gmake release MP=1 PERF_COUNTERS=1`, missing GENETIC/SIXTYFOUR), and (b) the first pass-through flag's VALUE became the candidate binary path -- producing a baffling `PermissionError` from Popen trying to execute a directory. Also `${VAR:-default}` -> `${VAR-default}` so an explicitly empty `SPRT_ARGS=""` is honored. |
+
+### PERF_COUNTERS cleanup (10.12g)
+The standard profile was carrying investigation debris that did real WORK, not
+counter increments.
+
+- **3 `rdtsc` sites moved to `EVAL_TIME`** (eval.c). Bracketed every
+ king-safety hash probe. An `EVAL_TIME` flag already existed for exactly this
+ and already guarded the other **32** rdtsc sites in the file -- these were
+ the only leaks. Report side in root.c was already EVAL_TIME-guarded.
+- **2 one-off diagnostics deleted outright**: `u64HeadStyle*` (called
+ `GetMoveFailHighRawCounts`) and `u64FHJointHist` (called
+ `GetMoveFailHighPctThousandths`). Both had comments saying to remove them
+ once their investigations concluded; both concluded, and both findings are
+ recorded (10.2's admission rates; section 4's bimodal dynamic rate). Removed
+ counters, `g_Options` mirrors, suite accumulation AND both report blocks --
+ a diagnostic printing zeros forever is worse than none. Binary -7,760 bytes.
+- **4D collection block re-gated to `FH_TABLE_COLLECTION`** (search.c). It
+ calls `EstimateHangingMaterialCp` per eligible move; CLAUDE.md already keeps
+ that flag out of the standard profile *because it costs nps*. Verified by
+ guard inspection: `search.c:1637` is now `PERF_COUNTERS > FH_TABLE_COLLECTION`.
+ It was also computing the hang bucket TWICE per move in any build with a
+ prune gate.
+
+**nps payoff NOT MEASURED** -- two attempts invalid (32-engine match on the
+box; one produced `perf_clean` faster than `noperf`, which is impossible).
+Re-measure idle, round-robin arms. The changes are justified on inspection
+regardless.
+
+### Infrastructure
+- **`match_play.py` time-odds support** (`--odds`, `--st-head`,
+ `--st-candidate`). Strict validation because a degraded odds match still
+ scores ~0.500 and looks healthy. Odds follow the ENGINE, not the seat --
+ verified live, since the harness swaps colours every other game, so keying on
+ White would silently balance the match. `TC=` stamped into the result line.
+ This is what produced the time-odds calibration.
+- **`PRUNE_VERIFY`** (GNUmakefile, search.c, chess.h, root.c, script.c).
+ Audit-only: evaluates the prune gate, records banded by predicted FH%, then
+ declines to prune and watches the move's own ordinary search. No shadow
+ `Search()` call, so no null-move-bailout exposure. Banding by the decision
+ variable answers "is `prune if pred <= K` sound" for every K in one run. This
+ is the instrument any future pruning work needs. 10.12h
+- **`DISABLE_LMR`** (GNUmakefile, searchsup.c). Diagnostic flag that produced
+ the control experiment. Keep the flag, never ship the configuration.
+
+### QSearch changes (10.12e) -- smoke-tested with asserts
+- **Option B**: `fIncludeChecks` waives the opponent-stand-pat requirement at
+ `NODE_PV`, keeping the material requirement. The suppression is sound for
+ null-window nodes (a fail-low there IS the parent's cutoff, so precision buys
+ nothing) but not for wide-window nodes, where the exact score propagates and
+ mate-vs-queen-loss is a different PV -- the forced-mate case the machinery
+ exists for. Reads `eNodeType`, never writes it.
+- **`iExtensionAmount` unit fix**: was written as 0 or 2 (qsearch check PLIES)
+ into a ONE_PLY-scaled field -- a latent 32x error, benign only because both
+ readers test sign.
+- **`pi->iEval` published** when the lazy `Eval()` actually runs.
+
+### Wilson re-bake (data.c, joint_table_collection/splice_joint.py) -- 10.11e
+Cells store the **Wilson upper bound** instead of the point estimate, so a
+cell's value encodes its own sample confidence and a consumer can threshold on
+the value ALONE. Under point estimates **8,248 of 21,780 cells read exactly 0**,
+mostly on little evidence, so any "prune where the table says 0" rule was far
+broader than the data supported; under Wilson, 136 do. Monotone (no cell's value
+is ever lowered), so every consumer became strictly more conservative.
+Verified behaviour-neutral for LMR: admission 92.79% vs 92.88% before, tier
+split and ringers solve count unchanged. `LMR_STATIC_ADMIT_PCT_THOUSANDTHS`
+stays at 500 -- it was briefly raised to 1000 on a CELL-count argument, which
+was wrong because cell counts are not traffic-weighted.
+
+---
+
+## DECIDE -- real, but with an open question
+
+| what | open question |
+|---|---|
+| **The 4D table itself** (data.c, 1,777 lines) + `joint_table_collection/` | Its only live shipping consumer is `LMR_STATIC_ADMIT_PCT_THOUSANDTHS`, which vetoes 2.3% of cell space / 7.4% of traffic -- and that threshold was chosen on solve-count deltas 10.6 showed were noise. Decisive cheap test: disable admission (`= 100000`) and run vs current; if neutral the table buys nothing in a shipping build. **But item 1 of 10.13 (give EFP the dumb axis) may give it a real consumer -- do that first.** |
+| **Graded LMR** (`LMR_STATIC_ADMIT`, flat-table magnitude, dynamic/countermove vetoes) | Worth ~8 Elo/ply marginal, i.e. a few Elo total, never cleanly self-play tested against flat -1 (10.12c). The `log(d)*log(m)` magnitude contributes mean **0.164 extra plies** because at remaining depth <= 2 the curve prescribes ~1 ply, which IS flat -1. Keep or simplify per the redesign in `2026-10-03_LMR_REDESIGN.md`. |
+| **`PRUNE_LIVE`** | Measured +0.88 ply, +7 Elo -- exactly its corrected ceiling. Keep the flag for measurement; do not ship the configuration. |
+| **`split.c` zero-snapshot passing** | `HelpSearch` passes all-zero attack snapshots to `GetLMRReduction`, so `EstimateHangingMaterialCp` degrades there. Documented and deliberate (every comparison protocol runs `--cpus 1`), but it means MP search reduces differently from single-threaded. Fine as long as that stays true. |
+
+---
+
+## DISCARD -- scratch that leaked into the tree
+
+Per CLAUDE.md's scratch policy these belong under `/tmp/typhoon/`, not `src/`:
+
+- `cdefault_games.pgn`, `chkdefault_games.pgn`, `codds_games.pgn`,
+ `dflt_games.pgn` -- from this session's `test_vs_head.sh` argument-construction
+ tests (GAMES=0/2 runs). **Delete.**
+- `kiwi_log.txt`, `eval_tune/kiwi_log.txt`, `eval_tune/eval_comp.txt`,
+ `blunders.pgn`, `players.dat`, `.xboardrc` -- untracked clutter from earlier
+ sessions; not mine to judge, but none look like deliberate artifacts.
+
+Deliberately-kept untracked directories (do NOT delete): `head_reference/`,
+`joint_table_collection/`, `nullmove_calib/`, `eval_tune/position_cache/`,
+`eval_tune/opening_cache/`.
+
+---
+
+## Suggested commit split
+
+1. **Bug fixes** -- OOB counter, node-type x4, unit unification, RateBucket,
+ `local_ebf`, `test_vs_head.sh` shift. Independently correct, no behaviour
+ bets. Run `./precommit_check.sh` first (search.c behaviour did change via
+ the QSearch work).
+2. **PERF_COUNTERS cleanup** -- after an idle-box nps measurement, so the
+ commit message can state what it bought.
+3. **Infrastructure** -- time-odds, `PRUNE_VERIFY`, `DISABLE_LMR`.
+4. **QSearch changes** -- option B et al. Smoke-tested; ideally also a short
+ self-play run, since option B is a real behaviour change never measured in
+ isolation.
+5. **Table + graded LMR + PRUNE_LIVE** -- hold until 10.13 items 1 and 5 are
+ resolved.
diff --git a/src/lmr_testing/GET_LMR_REDUCTION.md b/src/lmr_testing/GET_LMR_REDUCTION.md
new file mode 100644
index 0000000..5b40031
--- /dev/null
+++ b/src/lmr_testing/GET_LMR_REDUCTION.md
@@ -0,0 +1,1971 @@
+# GetLMRReduction overhaul -- session handoff (2026-10-02)
+
+**Note on filename**: this supersedes `lmr_testing/GET_LMR_REDUCION.md`
+(missing a T -- an earlier typo in this same session). That file's
+content is reproduced/updated below; don't treat the two as
+independent histories, the old one is just a mid-session snapshot.
+
+This session started by making `GetMoveFailHighPercentage`'s dynamic
+fail-high table honest, discovered that honesty alone broke LMR's
+node-savings, spent a long stretch diagnosing and redesigning the
+gate through several dead ends, and ended with a real, working
+redesign: self-play validation is pending as of this handoff (the
+user is launching it separately). This file is the full history.
+
+**Current code state** (verified by grep immediately before writing
+this): `searchsup.c`'s `GetLMRReduction` uses
+
+- `LMR_STATIC_ADMIT_THOUSANDTHS = 500` -- wide-open admission ("do we
+ reduce at all").
+- `JOINT_EXTRA_TIER1/2/3_THOUSANDTHS = 72/5/1` -- now vestigial for
+ the live magnitude decision (see section 8); TIER1 only appears in
+ comments, TIER3 is reused by `LMP_STATIC_MAX_THOUSANDTHS` for the
+ (currently disabled) LMP experiment.
+- `LMR_FH_BASELINE_THOUSANDTHS = 4` -- dynamic-danger baseline,
+ unchanged all session.
+- Magnitude ("how much extra") is now computed by
+ `_FlatTableExtraPlies(uRemainingDepth, uLegalMoves)`, which reads
+ `g_iLMRQuietReduction` (dynamic.c) -- a Crafty/Ethereal-shaped flat
+ `log(depth)*log(moves)` table that existed, fully built, completely
+ unread, since before this session started. This is the live,
+ working mechanism as of this handoff -- see section 8.
+- `JOINT_EXTRA_MAX_PLIES = 5` (cap on extra plies stacked on the base
+ `-ONE_PLY`).
+- LMP (overt late-move pruning, section 7) is implemented in
+ `ShouldLatePrune` (searchsup.c) but **disabled** at its call site
+ (`search.c:1228`, `if (FALSE && ...)`) -- measured net-negative, kept
+ for reference, not deleted.
+
+Build is clean (`gmake -j5 GENETIC=1 PERF_COUNTERS=1 MP=1 SIXTYFOUR=1`,
+no warnings) at every checkpoint described below.
+
+## 1. The honesty fix (where this started)
+
+`g_FailHighs` (dynamic.c) used to seed every move-identity to a fake
+`1 FH / 1 attempt` (100%) on `ClearDynamicMoveOrdering`, so
+`GetMoveFailHighPercentage` never returned a real 0% -- a move needed
+~9-10 *clean* observations before its decaying fake prior dropped
+below the old `<= 10%` gate. Fixed:
+
+- `ClearDynamicMoveOrdering` now zeroes the table for real (0/0).
+- Renamed to `GetMoveFailHighThousandths` (dynamic.c) -- returns the
+ rate in thousandths (0-1000), not whole percent, since the real
+ population baseline (data.c's comment: **0.400%** tries-weighted,
+ across the 500B-node joint-table collection run) needs sub-1%
+ resolution to mean anything.
+- Added `GetMoveFailHighRawCounts` (dynamic.c) -- raw FH/attempts, used
+ by diagnostics below.
+
+Also while in there: killed the stale doc-comment in `_NewCounterMove`
+(claimed depth-gated overwrites; code actually always overwrites on
+recency, confirmed by its own inline A/B-test comment -- just the
+routine description never got updated). Removed `uCounterDepth`
+(write-only, zero readers, the "read-time bonus" it was meant to feed
+was never built) and the dead `FIRST_COUNTER_MOVE`/`SECOND_COUNTER_MOVE`
+flag constants (chess.h).
+
+Added a countermove-table veto to `GetLMRReduction` itself: a bare
+match against `ctx->sPlyInfo[ctx->uPly-2].mv` (the move played
+immediately before `mv` -- **two** plies back from here, since `mv`
+has already been made by the time `GetLMRReduction` runs) withholds
+reduction, independent of generate.c's evidence-gated tier promotion.
+
+## 2. Consolidation: one function, not two files
+
+The joint-table-driven "extra reduction" logic used to live in
+search.c as post-processing on top of `GetLMRReduction`'s own baseline
+decision -- split across two files/two inconsistent dynamic checks
+purely to minimize the original diff against HEAD. Moved the whole
+decision into `GetLMRReduction` (searchsup.c):
+
+- `EstimateHangingMaterialCp`, `HangCpBucket`, `GapCpBucket`,
+ `MoveNumBucket`, `DepthBucket` are no longer file-static -- they're
+ declared in chess.h now because search.c's own joint-table
+ *collection* instrumentation (gated on `fPruneReduceEligible` alone,
+ independent of the LMR decision) still needs them.
+- `GetLMRReduction` gained three params: `bbMinorAttacksSnapshot[2]`,
+ `bbRookAttacksSnapshot[2]` (for `EstimateHangingMaterialCp`;
+ split.c's HelpSearch has no such snapshot, passes all-zero arrays,
+ which degrade safely), and `OUT ULONG *puExtraTier` (optional, feeds
+ search.c's re-search-rate calibration instrumentation).
+- search.c's move loop is now just one call + two counter increments.
+
+## 3. The surprise: depth went DOWN, not up
+
+First redesign attempt kept the *old* two-check shape (dynamic rate
+must be low AND bucket-specific, both gates, a bare 0.4%-baseline
+veto) but made it honest. Measured against `head_reference` (committed
+baseline, no joint-table mechanism at all -- its `GetLMRReduction`
+only ever applies a flat `-ONE_PLY`, old fake-prior-decay gate):
+
+- **sd10 node count went UP 2-6%** across all three curated suites --
+ the honest gate was *more* conservative than HEAD's old loose one.
+- A single position (ECM.233) pushed to sd20 showed candidate using
+ 3x HEAD's node count at the same depth (18 ply) -- undeniable, not
+ a suite-aggregate artifact.
+- A diagnostic (`u64HeadStyleEligible`/`u64HeadStyleWouldReduce`,
+ chess.h) replays HEAD's exact old gate formula --
+ `(rawFH+1)*100/(rawAttempts+1) <= 10` -- against the SAME live
+ trajectory as the real decision. Result: **HEAD-style reduces
+ ~76-77% of eligible decisions, always by exactly 1 ply.** The honest
+ design (0.4% baseline) only reduced 62-66%, but with some decisions
+ getting 2-4 plies. Average ply-saved-per-eligible-call was actually
+ *slightly higher* for the new design (0.80-0.88 vs HEAD's 0.73-0.76)
+ -- so the problem wasn't magnitude, it was **coverage**: breadth
+ (how many moves get touched at all) matters more to total tree size
+ than depth-of-cut-per-touch, and extra-tier magnitude carries a
+ re-search tax (2-3%/tier) that eats part of its own benefit. This
+ theme (coverage beats magnitude, magnitude has a tax) recurs
+ throughout the rest of the session -- see sections 8/9.
+- A pure threshold sweep on the EXTRA-tier ceiling alone (holding the
+ honest 0.4% baseline fixed) never recovered HEAD's node count, not
+ even at the old deliberate-extreme-experiment setting
+ (9000/8300/1000 thousandths) -- confirming magnitude-tuning on an
+ already-small qualified population is the wrong lever.
+- A sweep of the 0.4% *baseline* itself told a different story:
+ loosening it to 100 thousandths (10%, matching HEAD's old literal
+ ceiling) recovered coverage (75.6%) and **beat HEAD's node count by
+ 35%**. Disabling the countermove-match veto or the bucket-prediction
+ veto *individually* moved node count by only 1-10% -- noise,
+ basically. **The flat 0.4% baseline was almost the entire story**,
+ not the "smart" vetoes. This is the first sighting of the pattern
+ that eventually won: a simple, broad admission lever beats precise
+ per-decision judgment.
+
+## 4. Why the population shape matters: the FH histogram
+
+A joint histogram of `(uAttempts bucket) x (uFHThousandths bucket)` at
+the live decision point (`u64FHJointHist`, chess.h;
+`AttemptsBucket`/`RateBucket`, searchsup.c -- still live
+`PERF_COUNTERS`-only diagnostic code, not yet removed) showed, on
+confident_quick (18.7M eligible decisions):
+
+- **~71% sit at exactly 0% observed rate**, including a large,
+ well-evidenced chunk (3.29M with 100+ attempts, genuinely zero FH
+ across real volume, not a fluke).
+- **A real, separate, well-evidenced population sits durably at
+ 1-10%** -- most of that mass is in the `attempts=100+` row (713K at
+ 1-1.9%, 1.1M at 2-4.9%, 657K at 5-9.9%), i.e. **not** small-sample
+ noise. (A real, narrower small-n artifact *does* exist separately:
+ at `attempts <= 9`, rates between ~1% and ~10% are structurally
+ unreachable -- `1/9 ≈ 11.1%` is the smallest nonzero rate at 9
+ attempts -- but this is a distinct, smaller phenomenon from the
+ large well-evidenced 1-10% population above.)
+- **~7% are pure-cold** (`uAttempts == 0`) and, under the original
+ two-population design, got flatly refused any reduction regardless
+ of what the static joint table said. The static table needs NO game
+ history (keyed on depth/hang/gap/late, not move identity) -- gating
+ it behind a dynamic-history requirement threw away its whole point
+ for this slice.
+
+So the dynamic rate is **bimodal**, not unimodal around 0.4% -- the
+population average is a real summary statistic of a mixture, not
+representative of either mode.
+
+## 5. Static-first and three-population redesigns (SUPERSEDED)
+
+Two intermediate redesigns, both now superseded by section 8's simpler
+mechanism, but worth recording for what they established:
+
+- **Static-first**: flipped priority so the static bucket prediction
+ became primary ("should we reduce at all"), dynamic signals became
+ vetoes on top rather than a precondition. Established the
+ qualitative lesson (cold moves should defer to the static table, not
+ be refused outright) that survived into every later design.
+- **Three-population**: cold / warm+safe / warm+dangerous, with
+ warm+dangerous capped to base-only rather than fully vetoed. This
+ *also* established a real, lasting bug: `JOINT_EXTRA_TIER1_
+ THOUSANDTHS` was doing double duty as both the admission threshold
+ ("reduce at all") and the magnitude threshold ("+1 extra ply") --
+ decoupling these (section 6) is what unblocked real progress.
+
+**CONTAMINATION WARNING, still relevant if this file's old numbers
+ever resurface**: partway through this session,
+`JOINT_EXTRA_TIER1/2/3_THOUSANDTHS` got silently stuck at
+**9000/8300/1000** (an old deliberate-extreme-experiment setting) for
+a long stretch, because a `cp searchsup.c searchsup.c.orig_full`
+snapshot captured a sweep script's leftover state without checking it
+first. Every measurement taken during that window (the HeadStyle
+diagnostic, the FH histogram, the first static-first pass) was
+measured under that contamination. **Lesson for next time**: after ANY
+sed-based sweep script, explicitly grep the tier/baseline constants
+before trusting a `cp`'d file as a clean base for anything else.
+
+Despite the contamination, one result survived it, independently
+corroborated later: **depth gain from LMR changes is heavily damped**.
+Even at the extreme (accidentally-active) ceiling, sn5M average search
+depth only moved 0.1-0.3 ply vs HEAD. This matches a documented
+finding already in search.c's history (2026-10-01 comment): the same
+9000/8300/1000 setting, in an *overnight self-play run*, predicted
+~1.0 extra ply/decision but measured only **+0.29 actual root-depth
+ply** -- a ~0.3x damping ratio, attributed to EBF: iterative
+deepening's cost scales by the effective branching factor per ply, so
+affording one more full iteration needs saving roughly `(EBF-1)/EBF`
+(~70-75% for EBF 3.5-4) of the previous iteration's total cost.
+
+**Crucial later correction to this section's pessimism** (from the
+user, mid-session): a *different* overnight self-play run, using the
+old p5/p25/p75-derived "dodgy" thresholds, did NOT just match HEAD --
+it ran **0.9-1.0 ply deeper and was even-or-better in actual game
+strength**. This is the one piece of ground-truth (real game outcome,
+not an ECM proxy) evidence the whole session has, and it directly
+contradicts "tune LMR for modest percentage savings is the wrong order
+of magnitude lever" -- it says aggressive-even-if-locally-unsound
+reduction, tolerated because iterative deepening self-corrects
+individual mistakes on the next pass, is exactly the right lever. This
+reframing drove everything in sections 6-9.
+
+## 6. Decoupling admission from magnitude
+
+Advisor-reviewed fix, directly motivated by the three-population bug
+above and the "0.9-1.0 ply overnight" evidence: **introduced a
+separate constant, `LMR_STATIC_ADMIT_THOUSANDTHS`, used only for "do
+we reduce at all"**, independent of `JOINT_EXTRA_TIER1_THOUSANDTHS`
+("does this earn +1 extra ply"). Also fixed the sentinel (no static
+data) branch: previously required `uAttempts > 0` even when static had
+no opinion either, needlessly refusing cold+sentinel moves; changed to
+admit whenever not dynamically dangerous, since absence of data is not
+evidence of danger.
+
+Swept `LMR_STATIC_ADMIT_THOUSANDTHS` on sn5M (all three suites, vs
+`head_reference`):
+
+| setting | ringers depth/solve | confident_quick depth/solve | hard_quick depth/solve |
+|---|---|---|---|
+| HEAD | 11.6 / 6 | 12.4 / 78 | 11.4 / 19 |
+| 500 | 11.7 / 6 | 12.8 / 77 | 11.9 / 19 |
+| 1000 (no-op gate) | 11.9 / 4 | 12.8 / 76 | 12.1 / 18 |
+
+500 was flat-to-noise on solves (0/-1/0) while gaining real depth
+(+0.1/+0.4/+0.5 ply). 1000 was deeper still but **lost solves on every
+single suite simultaneously** (-2/-2/-1) -- a consistent, same-direction
+signal, not noise, meaning the extra depth from over-wide admission
+wasn't paying for itself. **500 was adopted as the working admission
+value**; it remains the current setting (section "Current code state"
+above).
+
+**Methodology note the user corrected mid-session**: sd10 (fixed
+depth) solve-count drops are expected and uninteresting (searching
+fewer nodes before the ceiling naturally solves fewer problems) --
+solve counts only matter as a signal at **sn5M (fixed node budget)**,
+where a drop means the extra depth isn't translating to correctness.
+Don't conflate the two contexts when reading solve deltas.
+
+## 7. Late move pruning (LMP) -- tried, net-negative, disabled not deleted
+
+Prompted by comparing against Crafty's actual source
+(`crafty-25.2/search.c:542,580`): Crafty's LMP is a hard `break` (skip
+ALL remaining moves at a node, not just one) gated purely on
+`depth < LMP_depth(16)` and `order > LMP[depth]` (a precomputed
+`3 + (i+.5)^1.9` table) -- no static table, no dynamic history, no
+per-move judgment at all. Implemented a conservative analog,
+`ShouldLatePrune` (searchsup.c): overt skip (not reduction) for the
+narrowest, most-certain tail only -- `uRemainingDepth <=
+LMP_MAX_REMAINING_DEPTH(6 plies)`, move count past a per-depth floor,
+AND both static (`JOINT_EXTRA_TIER3_THOUSANDTHS`, the strictest tier)
+and dynamic (well-evidenced, near-zero rate) signals agreeing it's
+ultra-safe, with killer/countermove exemptions carried over unchanged.
+Wired into search.c ahead of LMR/EFP so a pruned move skips both;
+reused `fThisMoveEFPPruned`/`fAnyMoveEFPPruned`'s existing hash-
+correctness plumbing rather than duplicating it.
+
+Measured on top of the admission=500 baseline, sn5M:
+
+| suite | admit=500 alone | +LMP |
+|---|---|---|
+| ringers | 11.7 / 6 | 11.7 / 6 (unchanged) |
+| confident_quick | 12.8 / 77 | 12.8 / **76** |
+| hard_quick | 11.9 / 19 | 11.9 / **18** |
+
+Depth/EBF barely moved (same as admit=500 alone), but solve count
+dropped one further on two of three suites at sn5M (the metric that
+matters per section 6's note) -- a real cost with no offsetting
+benefit. **Disabled at the call site (`search.c:1228`, `if (FALSE &&
+...)`), not deleted** -- the mechanism may be worth revisiting with
+different thresholds (the tested band was very narrow -- only
+~250K-moves-pruned out of ~23M total nodes on sd10 ringers, i.e. this
+barely touched anything, so "net-negative" here means "net-negative
+at this specific narrow threshold," not "pruning can't work." Crafty's
+own version is dramatically more aggressive (breaks on ALL remaining
+moves, not a per-move judgment call) -- this was flagged mid-session
+as "clearly stronger but way dodgier," and was deliberately not
+pursued further given the explicit instruction not to get crazy with
+pruning (irreversible, no re-search safety net, unlike LMR).
+
+**Known, unfixed issue in `ShouldLatePrune`'s depth gate** (found
+reviewing the Crafty comparison, documented in-code at
+`searchsup.c`'s `LMP_MAX_REMAINING_DEPTH` comment but not patched,
+since LMP is disabled anyway): the function only permits pruning near
+the leaf (`uRemainingDepth <= 6*ONE_PLY`), mirroring Crafty's
+`FP_depth`/`LMP_depth` near-leaf restriction. But our own joint table
+shows predicted FH% **declining** as remaining depth grows (checked
+directly, dumb=0/gap=0/late=0 slice: depth-bucket 0 → 17119
+thousandths, bucket 5 → 4033, bucket 11 → 617, a ~25x drop) -- so the
+ceiling permits pruning exactly where our own data says risk is
+*highest*, and forbids it where risk is *lowest*. Crafty isn't
+self-contradicting the same way: it has no static table at all, so
+the near-leaf restriction *is* its entire risk model (a mistake near
+the leaf is cheap to re-discover). We inherited the structural gate
+without inheriting its justification, stacked on a mechanism that
+already has a better, contradicting signal.
+
+The user's correct reframing: the *cost* of a wrong prune does
+legitimately grow with remaining depth, but that should tighten the
+**confidence bar**, not forbid the decision outright past a cutoff. The
+principled fix is in `lmr_testing/SPEC.md`'s "how much statistical
+confidence is actually needed" section: gate on the **static table's
+own per-cell sample count** via a rule-of-three bound (`N ≈ 3 *
+applications_per_game / target_misses_per_game`) -- not on remaining
+depth, not on the dynamic per-move-identity table. SPEC.md derives
+`N ≈ 14.4M` for a 1/1000-games miss target (no deep-zone zero-FH cell
+clears this) and, after Scott's pushback that 1/1000 was picked, not
+derived, a looser 1/100-games target gives `N ≈ 1.44M` (largest real
+cell: 1.34M, close) or 1/20-games gives `N ≈ 288K` (16 deep-zone
+zero-FH cells clear this today). **Blocker**: `g_uJointFHThousandths`
+(data.c) ships only the rate per cell, not the sample count behind it
+(cells need ≥500 tries to ship at all, but a 500-try cell and a
+1.34M-try cell are indistinguishable once shipped) -- applying this
+bound correctly needs a re-bake (`bake_joint.py` + `data.c`) adding a
+companion per-cell count array. Not done this session; a stand-in
+(depth-scaled dynamic-sample floor) was drafted and then deliberately
+reverted rather than leaving an uncalibrated heuristic in place --
+current code still has the original (acknowledged-wrong) depth
+ceiling, now with this critique documented alongside it.
+
+**Concrete lead for whoever re-bakes this**: querying the raw
+collection counts directly (not the shipped table) found **43 cells
+with a genuine 0.0% fail-high prediction (0 fail-highs across all
+tries) backed by ≥288,000 tries** -- clearing the 1/20-games bound
+above. These are heavily concentrated at **hang/dumb bucket 9**
+(`HangCpBucket`'s near-top bucket, `EstimateHangingMaterialCp`'s
+largest "this move hangs a lot of material for nothing" reading),
+essentially regardless of depth/gap/late bucket -- the single largest
+qualifying cell has **5.1M tries, zero fail-highs**. This isn't a
+sparse-sampling coincidence: it's the "dumb" axis doing exactly the
+job it was built for at its most extreme setting -- a move that
+obviously just hangs material for nothing is essentially never
+actually the best move at a node, so "it raises alpha" can't happen
+independent of depth/gap/lateness context. **This is the real
+population LMP should target once sample counts are shippable**: not
+"near the leaf" (the current, backwards ceiling), but "hang bucket 9,
+any depth" -- a structural, not positional, criterion.
+
+## 8. Magnitude-loosening attempts: the real dead end, and the actual fix
+
+With admission settled at 500, the next lever tried was MAGNITUDE --
+how many extra plies a reduced move gets. Two attempts:
+
+**Attempt A -- widen the bucket-tier thresholds.** Changed
+`72/5/1/cap-3` to `150/20/5/cap-4`. Rationale: Crafty's own LMR table,
+at the remaining-depth range our suites actually exercise (5-10
+plies), applies larger cuts (2-6 plies) than our tiers were allowing.
+**Result: sd10 ringers node count went UP, 23.0M -> 27.7M -- WORSE
+than even HEAD's 26.9M.** The re-search-rate diagnostic showed why:
+tier 1's research rate (4.13%) became *higher* than tier 0's (2.73%)
+-- exactly the "distribution-shift failure" the code's own calibration
+comment warns about (low-predicted-FH tiers should show a RISING
+research rate with tier, never an inversion). Widening tier 1's
+*admission* boundary let in a bucket of moves genuinely more dangerous
+than baseline, and the resulting re-search tax outweighed the savings.
+Reverted immediately.
+
+**Attempt B -- raise only the tier 2/3 *payout*, leave all admission
+boundaries (72/5/1) untouched.** Rationale: tiers 2/3 already showed
+LOWER research rates than tier 0 even before any change -- i.e.
+headroom, not a crack, unlike tier 1. Bumped tier2 payout 2->3, tier3
+3->5, cap to 5. **Result: sd10 node count barely moved (23.04M ->
+22.97M, 0.3%)** -- essentially null. Diagnosed (advisor-assisted)
+after the fact: **89% of all reduced moves land in tier 0 (no extra
+ply at all)**, 10.3% in tier 1, and only ~1.1% combined in tiers 2/3 --
+so tuning tiers 2/3's payout only ever touches the smallest sliver of
+the population; it mathematically cannot move the aggregate much
+either way. Separately, the new `cap=5` needed `remaining_plies >= 8`
+to even engage (`iMaxExtra` floor logic), which these suites' typical
+5-10-ply remaining depth rarely reaches -- a second reason this came
+back null.
+
+**The actual fix -- stop grading magnitude with the joint table at
+all.** The diagnosis above reframed the real question: 89% of the
+population (tier 0) was getting a flat, ungraded `-ONE_PLY` with no
+magnitude mechanism touching it whatsoever -- every experiment so far
+had been tuning the payout of an 11% minority. Separately, a grep
+turned up `g_iLMRQuietReduction[MAX_PLY_PER_SEARCH+1][LMR_TABLE_MAX_MOVES+1]`
+(dynamic.c, built by `InitLMRTable()`, called from `main.c`) -- a
+**Crafty/Ethereal-shaped flat `log(depth)*log(moves)` table, fully
+built at startup, read by nothing, anywhere in the codebase.** A prior
+session had built the simple mechanism and then gone and built the
+whole joint-table/dynamic-FH system instead, without ever wiring the
+simple one in. Sanity-checked its magnitudes against Crafty's actual
+table at the depth range that matters (5-10 plies remaining): d=5,m=8
+gives ~2.1 plies here vs. Crafty's 2.3; d=10,m=8 gives ~2.7 vs. 3.0 --
+same ballpark, same shape, and structurally immune to the "one bucket
+silently miscalibrated" failure mode that broke Attempt A, because
+it's not a lookup into learned per-situation fail-high predictions at
+all -- just depth and move order.
+
+Wired `_FlatTableExtraPlies(uRemainingDepth, uLegalMoves)` in as the
+magnitude source, **replacing** the old `_JointTableExtraPlies`
+tier-lookup (which is now dead code, deleted) -- admission logic
+(static/dynamic agreement, killer/countermove vetoes,
+`LMR_STATIC_ADMIT_THOUSANDTHS=500`) is completely unchanged; only "how
+much extra" changed. The table returns the FULL reduction in
+ONE_PLY-scaled ticks; subtracting the existing `ONE_PLY` base and
+flooring at 0 gives the extra-ply count, clamped by the existing
+`JOINT_EXTRA_MIN_REMAINING_DEPTH` floor logic as before.
+
+**Result -- the best of the whole session, every metric improved or
+flat simultaneously:**
+
+sd10 ringers node count: **19.56M**, vs. HEAD's 26.94M (-27.4%) and
+vs. the admit=500-alone baseline's 23.04M (-15.1% further). Solve
+count unchanged (3/11).
+
+sn5M, all three suites, vs. HEAD and vs. the previous best
+(admit=500 alone, no LMP, no magnitude change):
+
+| suite | HEAD | admit=500 alone | **flat-table magnitude** |
+|---|---|---|---|
+| ringers | 11.6 ply / 6 solved | 11.7 / 6 | **11.8 / 6** |
+| confident_quick | 12.4 / 78 | 12.8 / 77 | **12.8 / 77** |
+| hard_quick | 11.4 / 19 | 11.9 / 19 | **12.0 / 19** |
+
+Depth improved on every suite vs. both HEAD and the previous best
+(+0.2/+0.4/+0.6 ply vs. HEAD); hard_quick's solve count is perfectly
+flat (19/19) despite the largest depth gain of any candidate this
+session; no suite lost a solve relative to the admit=500 baseline.
+This is the first candidate all session where no metric had to be
+traded off against another.
+
+## 9. The pattern across the whole session
+
+Every lever that worked was a **simple, broad, structural** one
+(admission width, Crafty's flat depth x order table); every lever that
+failed or plateaued was a **precise, learned, per-situation** one
+(bucket-tier magnitude grading, narrow LMP thresholds). The common
+failure mode of the precise levers was a correlated miscalibration in
+one slice of the population (tier 1's admission boundary) that a flat
+mechanism structurally cannot have, because it has no slices to
+miscalibrate -- only a single smooth surface in (depth, move-order).
+Crafty's own source confirms this isn't a coincidence: neither its LMP
+nor its LMR table consults any static or dynamic per-move signal at
+all, and it reaches ply 19 at node counts where this engine was
+reaching ply 11-12 before this session. The dynamic/static per-move
+tables are still useful -- they're what's deciding *whether* to
+reduce at all (admission), where they've demonstrably worked -- the
+mistake was asking the same machinery to also grade *how much*.
+
+## Where we left off / next steps
+
+1. **Self-play validation is the next step, launched by the user
+ separately from this handoff.** Every metric used all session (sd10
+ node count, sn5M depth/EBF, sn5M solve count) is a proxy; the ECM
+ suites are small and documented-noisy (`../tests/ECM.md`); the only
+ ground-truth evidence all session is the overnight self-play run
+ referenced in section 5. **Use a genuine time-per-move or fixed-
+ node self-play gate, never `sd` (fixed depth defeats the entire
+ mechanism being tested -- confirmed in `typhoon_lmr_sd11_sn10m_
+ protocol` memory).** Check which existing script
+ (`eval_tune/match_play.py`, `.dna`-file-oriented, built for eval
+ tuning -- or `eval_tune/test_vs_head.sh`, currently modified in the
+ working tree) actually supports two *binaries* head-to-head, since
+ `match_play.py` as built takes two DNA files against one binary,
+ not two search-behavior variants.
+2. **Cross-check any solve-count deltas against `../tests/ECM.md`**
+ before trusting them -- gained/lost positions skew heavily
+ flaky/noise; the project's own doc tracks which ECM numbers are
+ known-flaky vs. known-legit.
+3. The current working config (admission=500, flat-table magnitude,
+ LMP disabled) has NOT been tried with LMP re-enabled using a
+ structurally different criterion than the narrow one tested in
+ section 7. Section 7's concrete lead (43 real cells, hang/dumb
+ bucket 9, up to 5.1M tries, genuine 0% FH -- a "this move obviously
+ hangs material" criterion, not a depth-near-leaf one) is the
+ specific next experiment: ship per-cell sample counts
+ (`bake_joint.py`/`data.c` re-bake), then gate `ShouldLatePrune` on
+ hang-bucket-9-or-similar + a rule-of-three-cleared sample count,
+ dropping the backwards depth ceiling entirely. Not attempted this
+ session (the re-bake is real work, and the explicit instruction was
+ to not get crazy with pruning without a principled basis -- this
+ gives one).
+4. Before running any further sed-based sweep, confirm the baseline
+ file is actually clean (section 5's contamination warning) --
+ explicitly grep the tier/baseline/admission constants, don't just
+ trust a `cp` from a few steps ago.
+5. The `u64FHJointHist`/`AttemptsBucket`/`RateBucket`/
+ `u64HeadStyleEligible`/`u64HeadStyleWouldReduce`/`u64LMPPruned`
+ diagnostics (chess.h, searchsup.c, root.c, script.c) are explicitly
+ one-off and still wired in under `#ifdef PERF_COUNTERS`. Harmless to
+ leave (no behavior change, diagnostic-only), but strip them out once
+ this investigation concludes.
+6. `JOINT_EXTRA_TIER1_THOUSANDTHS`/`JOINT_EXTRA_TIER2_THOUSANDTHS` are
+ now unused outside of comments/the disabled LMP path respectively --
+ candidates for removal once the flat-table magnitude approach is
+ confirmed by self-play, along with the now-dead `_JointTableExtraPlies`
+ function (already deleted) and its tier-sweep history in this doc,
+ which is kept here only as a record of why the simpler mechanism won.
+7. If self-play confirms this is a real strength gain, update
+ `head_reference/` (binary + logs + README) to make this the new
+ comparison baseline, per this codebase's own stated protocol
+ (`CLAUDE.md`'s "head_reference protocol" section).
+
+---
+
+# Update, 2026-10-03: admission-rate measurement, a counter-bounds bug, and
+# why this session's solve-count evidence does not support what it was used for
+
+Written while the self-play validation from "Where we left off" item 1 was
+running (`--st 4`, SPRT elo0=0/elo1=5, 12+ workers). At 1710 games:
+`-426 =831 +453`, score 0.508, llr +0.41, avg depth candidate 13.55 vs head
+12.90, ebf 3.464 vs 3.647, nps essentially tied (1.360M vs 1.365M).
+
+All measurements below were taken with the live candidate binary
+(`./typhoon`, confirmed `-DPERF_COUNTERS` and newer than every source file),
+`--cpus 1`, `--hash 256m`, three curated suites run serially.
+`eval_tune/match_play.py` confirmed to spawn engines with `--cpus 1`, so
+`split.c`'s degraded all-zero-snapshot `GetLMRReduction` path is not involved
+in the self-play numbers either.
+
+## 10.1. The match result is underpowered, not null
+
+Score 0.5079 over 1710 games with that draw rate gives an SD of the mean of
+0.0087, i.e. **+5.5 Elo with a 95% CI of roughly [-6, +17]**. The llr is
+drifting toward H1, not away. The honest statement is that this match spans
+everything from a small loss to a solid gain and cannot yet distinguish them
+-- not "the depth didn't translate," and not "it's fine."
+
+The extra depth is real rather than nominal: `ln(3.647)/ln(3.464) = 1.0414`,
+and `1.0414 x 12.90 = 13.43` against 13.55 measured. The ebf reduction fully
+accounts for the depth gained, with no residual.
+
+**That "no residual" is itself the interesting part.** A well-targeted
+reduction should beat the arithmetic, because the saved nodes go
+preferentially to lines that matter, so accuracy-at-fixed-nodes improves
+beyond what pure depth predicts. Getting exactly the arithmetic and nothing
+more is the signature of a near-uniform tax on the leftover pool rather than
+a reallocation. See section 10.7b.
+
+## 10.2. Admission rate: the candidate reduces far MORE than HEAD, not less
+
+Ran the still-wired `u64HeadStyleEligible`/`u64HeadStyleWouldReduce`
+diagnostic (section 3 (original)) at `sd 10`. Last cumulative checkpoint per suite:
+
+| suite | eligible calls | candidate: SOME reduction | HEAD-style: flat -1 | delta |
+|---|---|---|---|---|
+| ecm_ringers | 2,897,448 | 92.88% | 71.60% | +21.3 |
+| ecm_confident_quick | 15,345,704 | 94.47% | 70.37% | +24.1 |
+| ecm_hard_quick | 28,632,443 | 93.26% | 74.99% | +18.3 |
+
+The section 3 (original) under-coverage problem is comprehensively gone. Coverage is now
+~19-24 points WIDER than HEAD-style's.
+
+**Cause, checked rather than assumed.** The first hypothesis was that the
+smoothed re-bake widened admission, since `data.c`'s current header reads
+"Every cell is populated -- no 0xffff sentinels remain" with 5,241/21,780
+cells shrunk toward a coarser marginal. That hypothesis is wrong, and
+backwards: the sentinel branch in `GetLMRReduction` was the PERMISSIVE one
+(`fBaseEligible = (FALSE == fDynamicDanger)`, admit unless dynamically
+dangerous) while the value branch is CONDITIONAL
+(`uPredictedFH < LMR_STATIC_ADMIT_THOUSANDTHS`). Moving cells from sentinel
+to value can only TIGHTEN.
+
+Parsing `g_uJointFHThousandths` out of data.c directly: only **733 of 21,780
+cells (3.4%) read >= 500**, and 224 of those sit in dumb bucket 1 where
+`_JointTablePredictedFHThousandths` returns the sentinel regardless -- so only
+~509 cells (2.3% of the space) can actually veto. They concentrate in gap
+bucket 0 (480 of 733, i.e. near-alpha) and depth buckets 0-1. So
+`LMR_STATIC_ADMIT_THOUSANDTHS = 500` has ALWAYS been close to wide open;
+section 6 (original)'s 500-vs-1000 sweep was distinguishing a ~6% slice of calls, not a
+broad gate.
+
+What actually widened coverage from section 3 (original)'s 62-66% to today's 93-94% was
+**section 6 (original)'s decoupling**: `fDynamicDanger` was demoted from vetoing the base
+reduction to merely forcing `uExtraPlies = 0`. A code change, not a data
+change.
+
+**Corollary: section 1 (original)'s `g_FailHighs` honesty fix is now nearly inert.**
+`fDynamicDanger` no longer vetoes the base reduction (except for dumb-bucket-1
+moves, which take the sentinel branch), and it only zeroes the extra plies --
+which ~85% of reduced moves never receive anyway (section 3 (original) below). The lever
+that started that whole session has almost no remaining effect on shipped
+behavior. Worth knowing before investing further in that table's accuracy.
+
+## 10.3. Magnitude is still overwhelmingly base-only
+
+Tier share of all REDUCED moves, across the three suites: tier 0 ~85-86%,
+tier 1 ~11-13%, tier 2 ~2.2-2.4%, tier 3 ~0.0-0.01%. Section 8 (original)'s "89% of all
+reduced moves land in tier 0" survived the switch from `_JointTableExtraPlies`
+to `_FlatTableExtraPlies` essentially unchanged.
+
+So the +0.65 ply in self-play is bought almost entirely by BREADTH (93-94%
+admission at a flat -1 ply), not by depth-of-cut. The flat table replaced the
+fragile tier payouts without actually paying out much more than they did.
+
+## 10.4. BUG: `JOINT_EXTRA_MAX_PLIES` (5) overruns `JOINT_EXTRA_TIER_COUNT` (4)
+
+```
+chess.h:999 #define JOINT_EXTRA_TIER_COUNT (4) // valid indices 0..3
+chess.h:1143 UINT64 u64JointExtraTries[JOINT_EXTRA_TIER_COUNT];
+chess.h:1144 UINT64 u64JointExtraResearch[JOINT_EXTRA_TIER_COUNT];
+chess.h:1149 UINT64 u64LMPPruned;
+searchsup.c:514 #define JOINT_EXTRA_MAX_PLIES (5)
+searchsup.c:934-6 if (uExtraPlies > JOINT_EXTRA_MAX_PLIES) uExtraPlies = JOINT_EXTRA_MAX_PLIES;
+searchsup.c:942 *puExtraTier = uExtraPlies; // 0..5 escapes to the caller
+search.c:1279 INC(ctx->sCounters.tree.u64JointExtraTries[uExtraTier]);
+search.c:1489 INC(ctx->sCounters.tree.u64JointExtraResearch[uThisMoveJointExtraTier]);
+```
+
+With `uExtraTier == 4`: `search.c:1279` writes `u64JointExtraTries[4]`, which
+lands on `u64JointExtraResearch[0]`; `search.c:1489` writes
+`u64JointExtraResearch[4]`, which is past BOTH arrays and lands on
+`u64LMPPruned`. The only other clamp, `iMaxExtra` (searchsup.c:922), bounds
+against remaining depth, not against the array -- at remaining depth 14 it
+permits 11.
+
+**Why no suite run ever caught it.** `_FlatTableExtraPlies` returns
+`(total_ticks - ONE_PLY)/ONE_PLY` with
+`total = 0.7844 + log(d)*log(m)/2.4696`. Tier 4 needs `total >= 5.0`:
+
+| remaining depth | moves tried | total | tier |
+|---|---|---|---|
+| 10 | 63 (LMR_TABLE_MAX_MOVES) | 4.65 | 3 |
+| 14 | 31 | 4.45 | 3 |
+| 14 | 63 | 5.21 | **4** |
+| 20 | 63 | 5.81 | **4** |
+
+At `sd 10` remaining depth never exceeds 10, so tier 3 is the ceiling and
+every suite measurement in this document (section 8 (original)'s included) is clean. The
+live match averages 13.55 ply, so tier 4 fires there routinely.
+
+**Blast radius**: counters only. Nothing reads these to make a search
+decision, so the running match's RESULT IS VALID, and `root.c:1558-1561`'s
+memcpys use `sizeof` the 4-element destinations so it does not propagate into
+`g_Options`. What it corrupts is (a) tier 0's apparent re-search rate, i.e.
+precisely the calibration diagnostic the next candidate would be judged on,
+and (b) `u64LMPPruned`, which can read nonzero while LMP is disabled at
+`search.c:1228` -- a thoroughly confusing symptom for whoever hits it next.
+
+**Fix**: raise `JOINT_EXTRA_TIER_COUNT` to 6 (`script.c`'s report loop is
+already bounded by the constant, so reporting stays faithful). Clamping
+`JOINT_EXTRA_MAX_PLIES` to 3 instead would change search behavior and is
+therefore not a pure bug fix.
+
+## 10.5. The by-tier re-search-rate diagnostic no longer means what section 8 (original) used it for
+
+```
+ tier0 tier1 tier2 tier3
+ringers 2.63% 3.84% 3.55% 3.85% (77 tries)
+confident_quick 2.69% 2.94% 2.87% 3.98% (1,204 tries)
+hard_quick 2.60% 3.74% 3.30% 2.88% (1,768 tries)
+```
+
+Section 8 (original) rejected Attempt A because tier 1's research rate (4.13%) came in
+HIGHER than tier 0's (2.73%), calling it the "distribution-shift failure."
+That was a valid test WHEN tier was a predicted-FH% band
+(`_JointTableExtraPlies`): a rising rate meant the risk ordering had broken.
+
+Under `_FlatTableExtraPlies`, tier is pure MAGNITUDE. A bigger cut
+mechanically needs more re-searches regardless of how well-calibrated
+anything is, so the inversion carries no calibration signal at all. Note
+tier 2 < tier 1 on all three suites, which no FH-ordering story predicts but
+a "bigger cut costs more, noisily" story accommodates fine.
+
+To be useful again this diagnostic must be re-based on PREDICTED FH%
+independent of applied magnitude. As currently reported it should not be used
+to accept or reject a candidate. Aggregate re-search tax is ~2.6-2.8% of
+reduced moves, which is modest.
+
+## 10.6. The solve-count evidence in this document does not support what it was used for
+
+Ran `sn 5000000` on all three suites (the metric section 6 (original) established as the
+one that matters) against `head_reference/logs/sn5m_*.out`, then diffed
+PER POSITION instead of reading the aggregate:
+
+| suite | HEAD solved/depth | candidate solved/depth | positions FLIPPED |
+|---|---|---|---|
+| ecm_ringers | 6/11 @ 11.6 ply | 6/11 @ 11.8 ply | 4 of 11 (36%) |
+| ecm_confident_quick | 78/90 @ 12.4 ply | 77/90 @ 12.8 ply | 7 of 90 ( 8%) |
+| ecm_hard_quick | 19/90 @ 11.4 ply | 20/90 @ 12.0 ply | 15 of 90 (17%) |
+
+Depth reproduces section 6 (original)/8's admit=500 row closely (+0.2/+0.4/+0.6 ply) and
+is the trustworthy axis. The solve counts are not, and cross-referencing every
+flip against `../tests/ECM.md` shows the near-flat aggregates are
+CANCELLATION, not stability:
+
+- **ringers (net 0)**: gains `ECM.213` (catalogued flaky, +0.37 unconfirmed
+ repetition line) and `ECM.270` (catalogued BAD SOLUTION -- score exactly
+ 0.00, Crafty's own sd20 prefers Rxc5); losses `ECM.721` and `ECM.746`, both
+ catalogued CLEAN. **Net 0 is two real losses masked by two junk gains.**
+- **confident_quick (net -1)**: real gains `ECM.519` (clean, +7.33) and
+ `ECM.736` (Crafty confirms the same move across depths 24-28 while HEAD
+ wobbles); real loss `ECM.348` (NOT CATALOGUED); `ECM.357` gain and
+ `ECM.715`/`716`/`826` losses all catalogued flaky or explicit horizon
+ artifacts. **De-noised this suite is roughly +2/-1 -- the OPPOSITE sign
+ from its aggregate.**
+- **hard_quick (net +1)**: real losses `ECM.128` (clean, +2.66, no
+ oscillation) and `ECM.846` (explicitly RECLASSIFIED NON-flaky on 2026-09-11
+ from two-engine deep stable agreement); real gains `ECM.476`
+ (Crafty-confirmed legit) and `ECM.803` (Crafty finds mate in 10 from the
+ solution move). `ECM.866` is catalogued verbatim as "a genuinely coin-flip
+ position." `ECM.018`/`084`/`516`/`640`/`774` NOT CATALOGUED.
+
+**Uncatalogued positions that flipped and should be triaged into ECM.md**:
+`ECM.348`, `ECM.018`, `ECM.084`, `ECM.516`, `ECM.640`, `ECM.774`.
+
+### Implication: section 6 (original)'s admission decision may rest on noise
+
+`LMR_STATIC_ADMIT_THOUSANDTHS = 500` was chosen over 1000 because 1000 "lost
+solves on every single suite simultaneously (-2/-2/-1) -- a consistent,
+same-direction signal, not noise." But a single configuration churns 4/11,
+7/90 and 15/90 positions here, with each net produced by catalogued-junk
+cancelling catalogued-clean in BOTH directions. A -2/-2/-1 aggregate sits
+well inside that churn.
+
+Direction-consistency across three small aggregates was treated as evidence
+against noise. That inference does not hold when each aggregate is itself a
+near-cancellation of 8-36% churn.
+
+The same caveat applies to **section 7 (original)'s LMP rejection** (-1 on two suites)
+and **section 8 (original)'s "no suite lost a solve relative to the admit=500
+baseline."** None of those conclusions are refuted -- they are unsupported,
+which is different. 500 is not shown to be wrong; it is shown to rest on
+nothing.
+
+**Protocol change this implies**: never report a curated-suite solve delta as
+an aggregate again. Diff per position, classify each flip against ECM.md, and
+report "N real gains / M real losses / K junk / J uncatalogued." The script
+used here is three lines of `grep`/`sed`/`join` over the two `.out` files and
+should be checked in rather than re-derived.
+
+## 10.7. Proposed directions, in priority order
+
+**Superseded in part by 10.9** -- written before the magnitude question was
+reconciled (10.8). 10.7a and 10.7c still stand as written; 10.7b is absorbed
+into 10.9c; 10.7d is DONE; 10.7e still stands. Read 10.9 for the current
+direction list.
+
+### 10.7a. HEAD vs HEAD at 2:1 time odds -- the decisive unrun experiment
+
+Grepped `lmr_testing/*.md`, `../src/CLAUDE.md` and `../tests/ECM.md`: this
+has never been run or recorded. It is the highest-value measurement available
+and it depends on neither ECM nor any proxy.
+
+**CORRECTED 2026-10-03** -- an earlier version of this section had the
+ebf/time conversion INVERTED (it claimed +0.65 ply was "a 0.19x doubling").
+The correct arithmetic, at ebf 3.6:
+
+- One ply costs a factor of ebf in nodes, so one DOUBLING of time buys
+ `ln(2)/ln(3.6)` = **0.54 ply**, not ~1.6.
+- Conversely this candidate's +0.65 ply costs `3.6^0.65` = 2.30x the nodes,
+ i.e. it is worth about **1.20 doublings** of time-equivalent.
+
+That inversion mattered, because it made one of the two branches below look
+plausible when it is not. With the correct conversion, if the measured
+Elo-per-doubling is D, then a sound +0.65 ply "should" be worth
+`1.20 x D` Elo:
+
+| measured D (Elo/doubling) | expected Elo for +0.65 ply | vs. the ~+2 to +5 measured |
+|---|---|---|
+| 60 | ~72 | 15-30x shortfall |
+| 40 | ~48 | 10-20x shortfall |
+| 20 | ~24 | 5-12x shortfall |
+| ~3 | ~4 | matches |
+
+So for the candidate's measured result to be "exactly what depth is worth
+here", a doubling of time would have to be worth only ~2-4 Elo -- i.e.
+doubling the thinking time barely helps this engine at all. That is a strong,
+falsifiable claim, and the measurement settles it:
+
+- **D is small (< ~10 Elo/doubling)**: typhoon is severely eval-limited.
+ Depth is nearly worthless to it, no search-efficiency work can pay, and the
+ lever is eval/speed. Consistent with the known 1.5M-vs-7.5M nps gap vs
+ Crafty and with eval being the separately-identified bottleneck.
+- **D is normal (~20-70 Elo/doubling)**: then reduction-bought plies are
+ worth drastically less than time-bought plies of the same nominal size, and
+ the question becomes WHY. That is a search-QUALITY investigation (what is
+ being lost in the reduced tree), not a tuning exercise -- and it would
+ explain the whole project's "always slightly positive, never significant"
+ pattern in one stroke.
+
+Note which way the remaining bias runs: Elo-per-doubling generally SHRINKS
+with depth (diminishing returns), so a D measured at a short time control is
+an upper bound on D at a longer one. Using a short-TC D therefore
+over-estimates the expected Elo and over-states the shortfall. Record the
+time control the calibration was actually run at.
+
+### 10.7b. Direct the cuts rather than widening them further
+
+At 93-94% admission and 85% flat -1 ply, the mechanism is close to "reduce
+every late quiet move by one ply" -- a uniform tax. A uniform tax lowers ebf
+and buys depth, and it structurally CANNOT protect critical lines, because
+nothing in it distinguishes them. Section 1 (original)'s dynamic signal was demoted to
+extras-only, section 8 (original) removed the joint table from magnitude, and the
+admission gate it still owns vetoes 2.3% of the cell space: step by step, each
+individually justified, the mechanism converged on undirected.
+
+Three inputs for directing it are already in scope and unused:
+
+1. **`iImprovement`** is a declared parameter of `GetLMRReduction`
+ (searchsup.c:638) and is never read in the body. Not-improving nodes
+ conventionally reduce ~1 ply more.
+2. **`pi->eNodeType`** is computed for every node and is exact
+ (`NODE_PV` iff wide window, searchsup.c:2203). CUT nodes tolerate more
+ reduction than ALL nodes, which must search everything to prove a fail-low.
+ LMR ignores the distinction.
+3. **`g_HistoryCounters`/`g_ContinuationHistory`** feed move ordering but
+ reach reduction only as a binary FH-rate veto, never as a scale on R.
+
+Frame these as flat structural +/-1 adjustments on a single smooth surface --
+explicitly NOT the "precise learned per-situation" grading that failed in
+section 8 (original). They have no buckets to miscalibrate, which is why section 9 (original)'s own
+pattern predicts they behave differently from the tier payouts.
+
+### 10.7c. Re-examine the original section 9's conclusion
+
+Section 9 (original) concluded that simple broad structural levers work and precise
+learned per-situation levers fail. That is an accurate description of what
+moved NODE COUNTS. It may be the wrong inference about Elo: the broad lever is
+what produced the no-residual, flat-accuracy depth in section 1 (original), and all three
+of section 9 (original)'s supporting measurements (sd10 node count, sn5M solve count,
+by-tier research rate) are now either noise-level (section 6 (original)) or no longer
+meaningful (section 10.5). Node count is not Elo. This is the inference in this
+document I would trust least.
+
+### 10.7d. Fix the counter bug before the next calibration read -- DONE 2026-10-03
+
+Section 10.4. Fixed: `JOINT_EXTRA_TIER_COUNT` 4 -> 6 (chess.h:999), with the
+broken lockstep against `JOINT_EXTRA_MAX_PLIES` documented in place. Every
+user of the constant was already bounded by it (both counter structs, the
+`JOINT_EXTRA_VERIFY` arrays, all six `script.c` loops), so nothing else
+needed touching. Raised the count rather than clamping `MAX_PLIES` back to 3,
+which would have been a search-behavior change rather than a fix. Clean
+rebuild, no warnings, batch self-test passes.
+
+Note for anyone reading counters from a binary built BEFORE this fix --
+including the 1710-game match in 10.1 -- tier 0's re-search rate and
+`u64LMPPruned` are corrupted there. The game results are unaffected.
+
+### 10.7e. Re-derive the admission threshold, if 10.7a says depth is worth buying
+
+Nothing currently justifies 500 over any other value (section 10.6). If 10.7a shows
+depth converts, sweep it against a metric that survives scrutiny -- move
+agreement against a deep oracle over a few hundred positions gives far more
+samples per position than a binary solved/unsolved on 90, and measures
+"better decisions per node" directly.
+
+## 10.8. Why magnitude is shallow: fully reconciled, plus a unit landmine fixed
+
+Follow-up to 10.3 ("magnitude is still overwhelmingly base-only"), which
+reported the symptom without explaining it. Driven by a direct question:
+if we have a 4-axis table that judges P(fail-high), why fall back on a
+"more primitive" `log(depth)*log(moves)` formula to size the bet? We are
+at a blackjack table and should bet more when we have an edge.
+
+The answer turns out to be that the magnitude *function* was never the
+binding constraint, so swapping it changed almost nothing -- and the
+reason it isn't binding is a deliberate invariant, not an oversight.
+
+### The table is not signal-starved
+
+Reassembled the `FOURAXISRAW` collection cells from the three 10.2 `sd10`
+runs (87.8M eligible tries, 25,159 distinct raw cells) and looked up every
+cell's shipped prediction. Tries-weighted, over the admitted population:
+
+| predicted FH | share |
+|---|---|
+| < 0.001% | 3.49% |
+| 0.001-0.005% | 18.97% |
+| 0.005-0.072% | 48.07% |
+| 0.072-0.5% | 18.84% |
+| >= 0.5% (admission veto) | 10.36% |
+
+**70.5% of admitted moves read below 0.072%** (the old TIER1 boundary) and
+22.5% read below 0.005%. So "we rarely encounter moves the table thinks are
+safe" is false -- it thinks most of the population is safe.
+
+### The two magnitude sources are a wash
+
+Over LMR's actual population (see the correction below), mean extra plies:
+**4-axis table 0.224, flat log(d)*log(m) 0.244** -- the flat formula is
+marginally MORE generous, and they put the same ~62% of traffic in tier 0.
+Within the depth>=4 slice where extras are legal at all, 0.919 vs 1.002.
+They disagree per-cell on 36% of depth>=4 tries (21% where the table wants
+less, 15% where it wants more) at essentially identical means.
+
+So section 8 (original)'s swap did not meaningfully change how much we
+reduce. It changed WHICH moves get the extras, at the same aggregate. That
+also means section 8 (original)'s headline result -- ringers 23.04M ->
+19.56M nodes, attributed to this swap -- **is not explained by the change
+it was attributed to.** Still unexplained; flagging rather than guessing.
+
+### What actually binds: the one-full-width-ply invariant
+
+`Search()` drops into `QSearch` at `uDepth < THREE_QUARTERS_PLY`
+(search.c:462). A reduced move consumes TWO plies before any extra (the
+normal `- ONE_PLY` decrement plus the base `-ONE_PLY` carried in
+`iExtend`), so `JOINT_EXTRA_MIN_REMAINING_DEPTH = ONE_PLY` encodes exactly
+"a reduced move still gets at least one genuine full-width ply before the
+horizon." `searchsup.c`'s `iMaxExtra` then works out to
+**`remaining_plies - 3`**, and `uDepth > TWO_PLY` (search.c:1254) enforces
+the same invariant for the base ply (at remaining depth 3, base-only leaves
+exactly `ONE_PLY`). One invariant, two gates -- the design is coherent, and
+this floor is load-bearing rather than conservative padding.
+
+Consequence: **no extra ply is permissible below remaining depth-bucket 4,
+for any signal, from any source.** Depth mix of the LMR population:
+
+| depth bucket | share of LMR pop | iMaxExtra |
+|---|---|---|
+| 2 (uDepth 129-191) | 45.7% | 0 |
+| 3 | 30.5% | 0 |
+| 4 | 14.7% | 1 |
+| 5 | 5.8% | 2 |
+| 6 | 2.3% | 3 |
+| 7+ | 1.1% | 4+ |
+
+Only ~23.9% of the population can receive an extra ply at all. This is just
+the shape of an alpha-beta tree: node count grows with ply-from-root, so the
+mass is near the leaves, where there is no depth left to cut.
+
+### Reconciliation of the model against the engine
+
+10.3 left a 2.7x discrepancy between a first (wrong) model and the measured
+mean. Both causes found:
+
+1. **Wrong population floor (my error).** The first pass filtered at depth
+ bucket >= 3, but `uDepth > TWO_PLY` admits bucket 2 (`uDepth` 129-191),
+ where `iMaxExtra` is 0. Bucket 2 is 21.25% of the eligible population and
+ was excluded entirely. Note also that `FOURAXISRAW` collection is gated on
+ `fPruneReduceEligible` ALONE -- it does not apply LMR's own
+ `uDepth > TWO_PLY`, so the collection population is ~4x LMR's and must be
+ filtered before any magnitude projection. Easy trap; it is a different
+ population than the one `u64FHJointHist` counts.
+2. **The dynamic component**, as predicted. Read straight off
+ `u64FHJointHist` with no rebuild: `fDynamicDanger` (own rate > the 0.400%
+ baseline, with any samples at all) zeroes the extras on **27.4%** of LMR
+ decisions (27.0 / 25.1 / 28.6% across the three suites, 46.9M calls).
+ 62.1% are warm-with-rate-exactly-0 and 7.1% pure cold; both pass.
+
+| model | mean extra | x (1 - 0.274) | measured |
+|---|---|---|---|
+| bucket >= 3 (wrong floor) | 0.440 | 0.320 | 0.164 |
+| **bucket >= 2 (correct)** | **0.244** | **0.177** | **0.164** |
+
+Within 8%, and the residual has a known sign: `FOURAXISRAW` increments
+happen AFTER the EFP decision, so EFP-pruned moves get a
+`GetLMRReduction` call (counted in the 46.9M histogram) but no collection
+increment. EFP fires near the horizon, so the proxy is biased away from the
+shallowest depths, which inflates the model exactly as observed.
+
+**So the magnitude story is: the clamp is the dominant factor, the dynamic
+veto is the second, and the choice of magnitude function is third and
+nearly irrelevant.**
+
+### Unit landmine fixed (2026-10-03)
+
+`GetMoveFailHighThousandths` returned PER-MILLE (0-1000) while
+`g_uJointFHThousandths` is in thousandths of a PERCENT -- 100x finer. Every
+caller was individually correct, but the two families were indistinguishable
+by name, and `ShouldLatePrune` held both four lines apart:
+`LMP_DYNAMIC_MAX_THOUSANDTHS (1)` meaning 0.1% beside
+`LMP_STATIC_MAX_THOUSANDTHS (1)` meaning 0.001%. The function's own doc
+comment asserted it shared the static table's unit "so a caller can compare
+this move's own dynamic rate directly against the static table's
+prediction" -- which is how the trap got planted.
+
+Fixed by converting the whole dynamic family to the table's unit (not just
+the two constants originally flagged -- leaving EFP and LMP in per-mille
+would have relocated the trap):
+
+| constant | was | now |
+|---|---|---|
+| `GetMoveFailHighPctThousandths` (renamed) | 0-1000 | 0-100000 |
+| `LMR_FH_BASELINE_PCT_THOUSANDTHS` | 4 | 400 (0.400%) |
+| `LMR_STATIC_ADMIT_PCT_THOUSANDTHS` | 500 | 500 (unchanged) |
+| `EFP_FH_PRUNE_THRESHOLD_PCT_THOUSANDTHS` | 100 | 10000 (10%) |
+| `LMP_DYNAMIC_MAX_PCT_THOUSANDTHS` | 1 | 100 (0.100%) |
+| `LMP_STATIC_MAX_PCT_THOUSANDTHS` | 1 | unchanged |
+
+Documented in-code: the old `> 4` per-mille test could only fire once
+`fh/attempts >= 0.005`, so the SHIPPED gate behaved like 0.5%, not the
+0.400% it documented. 400 is faithful to the intent and a hair stricter than
+what shipped; the affected band needs >= 200 observations of a single move
+identity to express and is essentially unpopulated per `u64FHJointHist`.
+Intended behavior preserved; shipped behavior changed immeasurably.
+
+## 10.9. Future directions from 10.8
+
+Premise, per Scott: when the table says a move is safe we should be more
+willing to (1) reduce beyond the clamp, letting the move drop straight into
+qsearch, or (2) prune it outright. Both are right in kind; they differ by
+more than an order of magnitude in expected payoff, and the measurement
+says which is which.
+
+### 10.9a. Reduce into qsearch when the table certifies safety -- a SAFETY probe, not a depth lever
+
+Make `JOINT_EXTRA_MIN_REMAINING_DEPTH` table-conditional: `ONE_PLY`
+normally, 0 when predicted FH clears a strict bar. This is the FIRST place
+in this project where the table would do something the flat
+`log(d)*log(moves)` formula structurally cannot express -- a conditional
+floor. It is also strictly safer than pruning: the move still receives a
+qsearch score and still trips the existing fail-high re-search
+(search.c:1484), so it stays recoverable in the one direction we can detect.
+
+Measured payoff ceiling (mean extra plies, before/after the 27.4% dynamic
+veto):
+
+| variant | mean extra | after dyn veto |
+|---|---|---|
+| today (`MIN_REMAINING = ONE_PLY`) | 0.224 | 0.162 |
+| unconditional `MIN_REMAINING = 0` | 0.506 | 0.368 |
+| conditional, pred < 0.005% | 0.302 | 0.219 |
+| conditional, pred < 0.001% | 0.227 | 0.165 |
+
+**Then apply section 5 (original)'s own damping measurement** (1.0 extra
+ply/decision -> +0.29 measured root ply): the conditional-at-0.005% variant
+buys ~+0.057 extra ply/decision, i.e. roughly **+0.017 root ply.** The
+unconditional version reaches ~+0.06 root ply. Both are an order of
+magnitude below the +0.65 ply this candidate ALREADY achieves, and well
+below measurement noise.
+
+**So this cannot be justified as a strength experiment -- the arithmetic
+will not support it.** Its real value is as the cheapest available probe of
+whether the table's "safe" verdict survives a much harsher consequence: if
+pred<0.005% cells can lose their last full-width ply with no measurable
+damage, that is direct evidence the table is trustworthy at the extreme,
+which is the precondition 10.9b needs anyway. Run it at the strict
+threshold, judge it on silent-miss rate (reusing `JOINT_EXTRA_VERIFY` plus
+the `NEUTRALIZE_NULL_BAILOUT` fix, per section 8 (original)'s lesson), and
+do NOT expect or claim a depth gain.
+
+Risk class to watch specifically: the refutation that is itself a quiet move
+one ply deep. That is precisely the documented genuine-miss class 2 (the
+`Bb6` deflection, invisible to qsearch by construction), and it is the exact
+thing the discarded full-width ply existed to catch.
+
+### 10.9b. Prune outright -- where the order of magnitude actually is
+
+Reduction saves plies off a subtree; pruning removes the subtree. The
+savings scale completely differently, which is why this dominates 10.9a:
+
+- 1.88% of eligible tries sit at depth >= 4 with predicted FH < 0.005%
+ (0.87% at depth >= 5, 0.38% at depth >= 6).
+- By SPEC.md's own order-of-magnitude check a depth-6 subtree is ~20,000
+ nodes against 30-100 for a near-leaf node.
+- A 1.88% population of 100-1000x larger subtrees dominates the node budget
+ in a way no magnitude change can approach.
+
+The machinery already exists (`ShouldLatePrune`, searchsup.c, disabled at
+search.c:1228) and section 7 (original)'s concrete lead is still unused:
+**43 cells with a genuine 0.0% observed fail-high backed by >= 288,000
+tries, concentrated at hang/dumb bucket 9 essentially regardless of
+depth/gap/late, the largest with 5.1M tries and zero fail-highs.** The
+criterion is structural ("this move obviously hangs material for nothing"),
+not positional, and it replaces the current backwards near-leaf depth
+ceiling -- which section 7 (original) already documents as permitting
+pruning exactly where our own table says risk is HIGHEST.
+
+Prerequisites, unchanged from section 7 (original) and SPEC.md's agreed
+design requirements: ship per-cell sample counts (`bake_joint.py` +
+`data.c`) so a rule-of-three bound can gate on evidence rather than on
+remaining depth, and build the prune-specific shadow-verification harness
+BEFORE pruning for real.
+
+### 10.9c. Coverage into the populations currently excluded outright
+
+Neither 10.9a nor 10.9b touches anything outside the already-admitted
+leftover pool. Still untouched: the 10.4% that admission vetoes, every
+`NODE_PV` node, and every non-leftover (sub-`GOOD_MOVE`-failing) move --
+`fPruneReduceEligible` excludes the latter two structurally. Crafty and
+Ethereal both reduce all quiet moves past a move-count threshold, and
+"coverage beats magnitude" is the most replicated finding in this document
+(section 3 (original), section 6 (original), 10.2). This is the largest
+remaining lever by population size, and 10.8 is the explanation for WHY it
+is larger than magnitude: magnitude is capped by the depth distribution,
+coverage is not.
+
+Also in this family, and nearly free: the three unused inputs from 10.7b
+(`iImprovement`, a declared-but-unread parameter of `GetLMRReduction`;
+`pi->eNodeType`; history as a scale on R rather than a binary veto).
+
+### 10.9d. Standing caveat over all of the above
+
+10.7a (HEAD vs HEAD at 2:1 time odds) still gates every one of these. If a
+ply is worth ~25 Elo in this engine rather than the ~60-70 a healthy engine
+shows, then even 10.9b's node savings convert poorly and the real lever is
+eval/speed, not search. That measurement is cheaper than any direction here
+and decides whether to pursue them at all.
+
+### Two errors of mine recorded, because the reasoning is the useful part
+
+1. **"Relax `JOINT_EXTRA_MIN_REMAINING_DEPTH` to 0 as a cheap way to buy
+ magnitude"** -- withdrawn. It is not a magnitude tweak; it converts
+ reduction into a qsearch-only evaluation, i.e. stealth LMP, and the
+ invariant being relaxed is load-bearing (see 10.8). It survives only in
+ the much narrower, table-gated, safety-probe form of 10.9a.
+2. **Filtering the collection population at depth bucket >= 3** instead of
+ >= 2, which over-predicted mean magnitude by ~1.8x and briefly made the
+ clamp look like it hard-capped 56% of decisions rather than the correct
+ ~76% (buckets 2 and 3 combined). Corrected in 10.8; the lesson is that
+ `FOURAXISRAW` and `u64FHJointHist` count DIFFERENT populations and
+ neither is LMR's without explicit filtering.
+
+## 10.10. The time-odds calibration, and the Crafty gap decomposed (2026-10-03)
+
+Two measurements that between them reframe this whole project. Neither
+depends on ECM solve counts or any proxy.
+
+### 10.10a. A ply is worth ~95 Elo to this engine
+
+HEAD vs HEAD at 2:1 time odds (`--st 2 --odds 2`, same binary both sides,
+no SPRT -- measuring a quantity, not testing a hypothesis). Stopped by hand
+at 170 games once the interval was decisive:
+
+| games | score | Elo | depth (cand vs head) |
+|---|---|---|---|
+| 20 | 0.700 | +147 +/-109 | 11.93 vs 11.08 |
+| 70 | 0.650 | +108 +/- 58 | 12.37 vs 11.33 |
+| 120 | 0.646 | +105 +/- 45 | 12.33 vs 11.47 |
+| 170 | 0.621 | **+86 +/- 37** | 12.37 vs 11.47 |
+
+**Result: a doubling of time buys ~0.90 ply and ~+86 Elo, i.e. ~95 Elo per
+ply** at this time control on this box. The 95% interval [48, 123] excludes
+by a wide margin the "a ply is worth almost nothing here" hypothesis that
+10.7a entertained.
+
+**This is the yardstick the project never had.** Any future depth-buying
+change can be pre-registered against it: predicted ceiling = measured ply
+gain x ~95 Elo. A change that buys +0.3 ply has a ~28 Elo ceiling (so
+`elo0=0 elo1=15` will resolve it in hours); a change that buys +0.05 ply
+cannot clear any bar worth caring about and should not get a match at all.
+That single number retires the entire class of 2,000-game inconclusive runs
+this document is otherwise a history of.
+
+### 10.10b. Reduction-bought plies are worth ~1/11th of time-bought plies
+
+Side by side, same engine, same box:
+
+| source of depth | ply gain | Elo | Elo/ply |
+|---|---|---|---|
+| 2x time | +0.90 | +86 | **~95** |
+| LMR candidate (10.1) | +0.65 | ~+5 | **~8.5** |
+| the section 5 (original) overnight "dodgy thresholds" run | +0.9-1.0 | even-or-better | ~0-10 |
+
+Two independent LMR configurations convert at under 10 Elo/ply while time
+converts at ~95. **The conversion loss is structural to reduction, not a
+function of how hard it is pushed** -- the more aggressive config did not
+convert better. So "squeeze LMR until it reaches 1.0 ply" predicts roughly
++10 Elo, not +95, and at r~2.0 (below) reaching 1.0 ply means HALVING node
+count, against the ~27% savings achieved today.
+
+Leading explanation (Scott's, and it fits): nominal root depth advances
+honestly (PV nodes are never reduced), but the REFUTATION lines -- the late
+quiet moves at ALL nodes whose fail-low proofs justify the move choice --
+are searched shallower than before. Alpha-beta's answer is only as good as
+its cutoff proofs, and reduction weakens exactly those. Time-bought depth
+deepens everything proportionally; reduction-bought depth deepens the PV
+while thinning its own verification.
+
+### 10.10c. `local_ebf` was dead on arrival; the real branching factor is ~2.0, not ~3.9
+
+Three stacked bugs hid this, found while trying to answer "is our branching
+factor really 3.9?" (Scott's suspicion: it is ~2.0, and the harness's number
+is inflated by aspiration/root-PV-change re-search cost rather than real tree
+growth):
+
+1. `match_play.py`'s `ebf` is `nodes ** (1.0/depth)` on the CUMULATIVE node
+ count -- not a per-ply ratio. Its own docstring (2026-09-18) documents
+ this and says `local_ebf` was added to fix it.
+2. `local_ebf` -- the correct estimator (geometric mean of
+ `nodes_now/nodes_prev` across consecutive completed iterations) -- read
+ `0.000` in every match since it was added. `_ITER_RE` was applied only in
+ the trailing stats parser, but the per-iteration PV lines arrive DURING
+ the search, in the move-scan loop, which read and discarded them.
+3. Even after fixing (2), still 0.000: `Engine.__init__`
+ (`tune_eval_dna.py:120`) sends **`nopost`**, and `root.c:248` gates
+ per-iteration PV output on `g_Options.fShouldPost`, so the lines were
+ never emitted at all. Now opt-in via `match_play.py --post`.
+
+Measured once working, `sd 9`, two engines: **local_ebf 2.02 and 2.12**
+against the same runs' cumulative `ebf` of 4.12 and 4.14. Scott's estimate
+was right and the published ebf has been wrong in every match report in this
+project. Independently corroborated by the time-odds depth response: 2x time
+-> +0.90 ply implies a per-ply ratio of `2^(1/0.90)` ~= 2.1.
+
+Also found: **`--st` takes integer seconds** -- `--st 0.5` yields "Soft time
+limit 00:00.000", i.e. an effectively instant search. Any sub-second `--st`
+value in a past experiment produced garbage games.
+
+### 10.10d. The Crafty gap: 4.7 plies of branching + 2.7 plies of speed
+
+Direct measurement, 12 real-game positions from `tests/cross_sectional_1.ep_`,
+both engines at fixed depths 6-12, 64M hash, single-threaded, book off
+(`/tmp/typhoon/cr_measure/`). Node accounting verified comparable
+beforehand by code reading: Crafty increments per `MakeMove`
+(`search.c:452`, `quiesce.c:185/238/352`, `root.c:101` -- qsearch nodes ARE
+counted), typhoon once per `Search`/`QSearch` entry (`searchsup.c:2019`).
+
+Fitting `nodes(d) = C * r^d`, with `r` taken from consecutive-ratio
+geometric means (NOT from the regression -- `C` and `r` trade off badly over
+a 7-point depth range, visible as absurd `C` of 1,362-4,102 on Crafty's
+low-`r` positions):
+
+| | typhoon | Crafty |
+|---|---|---|
+| **r** (median) | **2.17** | **1.74** |
+| r range across 12 positions | 1.75-2.50 | 1.44-1.99 |
+
+Decomposition of the ~7-ply gap Scott observes in live games (Crafty at ~19
+when typhoon is at 12):
+
+| term | contribution |
+|---|---|
+| branching (r 2.17 vs 1.74 -> depth x 1.391) | **+4.7 ply** |
+| speed (1.43M vs 6.6M nps = 4.6x nodes) | **+2.7 ply** |
+| predicted Crafty depth at typhoon's 12 | **19.4** (observed ~19) |
+
+Two independently measured quantities reproduce the observation with no
+residual.
+
+**A hypothesis this KILLS, recorded because it was mine and it was wrong:**
+that the gap lives in `C` (unit-subtree cost) because typhoon's qsearch is
+too wide. typhoon's qsearch is indeed ~84% of its nodes against Crafty's
+~15% (same position, `sd 8`: typhoon 482,156 nodes / 406,205 qnodes; Crafty
+65,688 nodes / ~10K evals) -- but `C` fits LOWER for typhoon (~287) than
+Crafty (~448), and `C` sits inside the logarithm, so even a 20% `C`
+reduction is worth ~0.3 ply. The qsearch width difference is real and
+costs almost nothing in depth. The earlier arithmetic that made `C` look
+decisive assumed equal `r` and failed a plausibility check (it implied
+Crafty's depth-1 search costs 11 nodes, when it must visit ~35 children).
+
+So: **the branching term is the bigger half of the gap and the one that can
+be attacked.** Scott's instinct to work on search efficiency was right.
+
+### 10.10e. Crafty's lazy-eval rates, measured
+
+Settling a disagreement about whether Crafty's eval is cruder. Crafty has
+TWO lazy exits and the printed fields distinguish them (`iterate.c:624-630`,
+both percentages over denominator `evaluations + lazy_evaluations`):
+`lazy=` is the FIRST (material-only, `evaluate.c:60/64`), `full=` went
+through piece scoring, and the residual is the SECOND lazy exit
+(`evaluate.c:243`, skips piece scoring).
+
+12 positions, `sd 10`: material-only **28.8% mean** (median 30, range 0-45);
+second lazy exit **24.9%**; full eval **46.3%**. So **~54% of Crafty's eval
+calls skip the expensive piece scoring**, which supports Scott's 40-50%
+recollection; the material-only tier alone is ~29%, not the 20% claimed
+earlier in this session from a single position at a single depth (the same
+position reads 30% at depth 10 -- that number was a bad sample, and
+depth-dependent).
+
+typhoon has the matching three-way split (`u64SuperLazyEvals`/
+`u64LazyEvals`/`u64FullEvals` plus cycle counts, `root.c:508-542`) but
+behind `#ifdef LAZY_EVAL`, not in the standard profile -- one rebuild gets a
+like-for-like comparison.
+
+**Caveat on what this can support**: lazy exits fire when the score is
+already far outside the window, i.e. in lopsided positions where detail does
+not matter. These counters measure eval COST, not eval QUALITY where it
+counts. Neither side of the eval-quality argument is settled by them.
+
+### 10.10f. Exchange truncation: the mechanism, and three fixes with very different costs
+
+Scott's symptom: losing a pawn or knight "at the end of an exchange" against
+Crafty, prompting an idea to extend all captures fractionally.
+
+NOT the futility margin -- `iFutility = iAlpha -
+(FUTILITY_BASE_MARGIN_BY_SOURCE[pi->uLastPositionalSource] +
+rgiPositional[...]) - iEval` is indexed by eval's own positional source,
+i.e. genuinely eval-calibrated rather than hand-picked.
+
+The mechanism is the SEE-ordered early exit at the top of `QSearch`'s move
+loop: moves are SEE-sorted, and the first `iValue <= 0` does `goto end`,
+abandoning every remaining move at that node. So qsearch only ever explores
+captures SEE scores as non-losing, and **any exchange whose resolution
+requires passing through a SEE-negative intermediate capture is outside the
+horizon entirely.**
+
+That cutoff is sound only to the extent SEE is right, and SPEC.md already
+documents that it is not, specifically: `_WhoAttacksSquareBB` (see.c) has no
+pin or legality awareness -- the finding that made the queen-vs-queen dumb
+bucket read backwards and got that signal deleted. A pinned defender still
+counts as a recapturer, so SEE calls a winning capture losing and qsearch
+never tries it. Same root cause, different symptom.
+
+| fix | touches | cost |
+|---|---|---|
+| make SEE pin-aware (see.c) | `C` | ~0.3 ply for a 20% `C` rise; also improves every other SEE consumer (`fPruneReduceEligible`'s capture rule, EFP, `MVF_SEE_*`) |
+| admit SEE<0 captures at shallow qsearch depth | `C` | bounded, tunable |
+| extend all captures fractionally | **`r`** | exponential; at ~95 Elo/ply, expensive |
+
+Recommendation: fix the cause on the `C` side before spending `r`.
+
+### 10.10g. Next steps implied
+
+1. **Measure Delta-r directly for extension changes, do not infer it from
+ the counters.** `root.c:568-577` prints every extension source count, but
+ those count GRANTS, before `MINU(ONE_PLY, ...)` caps and before
+ `g_uExtensionReduction[]` decays -- so they cannot give the applied ply
+ inflation that actually moves `r`. The 12-position C/r sweep
+ (`/tmp/typhoon/cr_measure/run.sh`) is now the right instrument: build with
+ extensions trimmed toward Crafty's single SEE-gated 1-ply check and
+ re-run it. Crafty has exactly ONE extension (`search.c:476/483`, checks
+ only, `check_depth=1`, SEE-gated, and it disables reductions for that
+ move); typhoon has ten fractional per-move sources plus five node-level
+ ones. Whether typhoon's 3/4-ply-but-decayed check extension "washes out"
+ against Crafty's 1-ply-but-SEE-gated one is exactly the question a Delta-r
+ measurement answers and counter arithmetic does not.
+2. **Route matters more than destination.** Closing `r` from 2.17 to 1.74 is
+ worth 4.7 plies ~= 450 Elo at the 10.10a yardstick. But reduction-bought
+ plies convert at ~8.5 Elo/ply (10.10b), so the question is not "how do we
+ lower r" but "how does Crafty lower r SOUNDLY". Extension trimming is the
+ first candidate identified that is not "reduce harder": it declines to
+ spend effort rather than suppressing information.
+3. **Clamp removal is off the table.** Stockfish floors the reduced depth at
+ 1 ply (`search.cpp:1370`, `std::max(1, ...)`) -- the same invariant
+ typhoon has -- and achieves its leanness through separate explicit
+ pruning (Step 15, `search.cpp:1166`). Crafty has no such floor and does
+ not even verify a move reduced into qsearch (the full-depth re-search at
+ `search.c:957` is inside the `depth > 0` branch only). The strongest
+ engine keeps typhoon's invariant. Combined with 10.9a's arithmetic
+ (+0.017 root ply), two independent lines say drop this idea.
+4. The 10.9b hang-bucket-9 pruning lead is unaffected by any of the above
+ and remains the best-evidenced pruning target (3.3% of eligible tries,
+ observed FH 0.0112%, 44x safer than hang bucket 0).
+
+## 10.11. Where the branching gap actually comes from, and the prioritized backlog (2026-10-03)
+
+### 10.11a. Crafty's r advantage is move-list TRUNCATION, not reduction grading
+
+Settling the "should we grade reduction magnitude harder" question for good.
+Crafty's entire LMR adjustment, `search.c:579-581`:
+
+```c
+reduce = LMR[Min(depth, 31)][Min(order, 63)];
+if (reduce && (pv_node || extend))
+ reduce--;
+```
+
+One decrement. No history, no improving, no node-type, no per-move signal --
+and it reaches r = 1.74 against typhoon's 2.17. **So adjustments cannot be
+where their leanness comes from.** (Stockfish does have a dozen such
+adjustments; Crafty does not, and Crafty is still far leaner than typhoon.
+This retires the "typhoon's candidate is SF's base with no adjustments"
+theory floated earlier in this session, and matches Scott's report of five
+prior LMR attempts with assorted factor-mucking, none of which paid off.)
+
+What sits immediately ABOVE those lines in Crafty's move loop does the work:
+
+```c
+if (depth < FP_depth && !check && MaterialSTM(wtm) + FP_margin[depth] <= alpha && !pv_node) {
+ tree->moves_fpruned++; break; // abandons ALL remaining moves
+}
+if (order > LMP[depth] && depth < LMP_depth && !pv_node && !check && ...) {
+ tree->moves_mpruned++; break; // abandons ALL remaining moves
+}
+```
+
+Measured in one `sd 8` sample: `fp=10.0K mcp=10.3K` against `nodes=65688` --
+**~20,000 move-list truncations in a 66,000-node search.** typhoon has EFP (a
+per-move `continue`) and a disabled LMP, and **no truncation mechanism at
+all**. That is the structural difference behind the 4.7-ply branching term
+in 10.10d.
+
+### 10.11b. "We are holding the 4D table wrong" -- the specific sense
+
+The table is currently consulted for reduction ADMISSION at a threshold that
+vetoes **2.3% of the cell space** (733 of 21,780 cells read >= 500, and 224 of
+those are in the sentinel'd dumb bucket 1). Section 8 (original) removed it
+from magnitude; section 6 (original) demoted the dynamic half to extras-only.
+Step by step a surface with a ~28,000x dynamic range in predicted FH% was
+reduced to making a near-binary decision that fires on ~10% of traffic.
+
+Its dynamic range only earns anything where the decision's stakes are
+correspondingly asymmetric:
+
+- A **reduction** is recoverable and its payoff is a ply or two off one
+ subtree. Fine-grained risk estimates are wasted there -- and 10.8 measured
+ exactly that: table-driven magnitude 0.224 mean extra plies vs the flat
+ `log(d)*log(m)` table's 0.244. A wash.
+- A **prune** is irreversible and its payoff scales with SUBTREE SIZE
+ (~20,000 nodes at depth 6 vs 30-100 near the leaf). That is where
+ "0.0112% observed FH across 2.9M tries, 44x safer than baseline" is worth
+ having, and where Crafty operates blind on depth-and-movecount alone.
+
+**So: Crafty gets r = 1.74 by truncating move lists on a crude rule, and we
+have a measured FH surface they do not. Holding the table right means using
+it to decide WHERE TO TRUNCATE.**
+
+### 10.11c. Scott's dimension argument: a sound derivation of Crafty's `break`
+
+Of the four axes, only **dumbness** is genuinely move-specific:
+
+| axis | scope |
+|---|---|
+| gap to alpha | node-level constant |
+| remaining depth | node-level constant |
+| lateness (move number) | move-specific, but MONOTONE -- every later move is at least as late |
+| dumbness (hang cp) | move-specific, varies arbitrarily |
+
+Therefore: if the table **marginalized over dumbness** reads "never fails
+high" for this (depth, gap, late) cell, that read applies at least as
+strongly to every remaining move at the node, because two axes are fixed and
+the third only worsens. That justifies abandoning the rest of the move list
+-- Crafty's `break`, derived from our own data instead of assumed.
+
+**This does NOT contradict the 2026-09-27 dumb-axis-pooling bug**, and the
+distinction is subtle enough to be worth stating: that bug was
+`ConfirmedZeroFailHigh` using the dumb-SUMMED marginal to vouch for a
+*specific* move sitting in a rare, dangerous dumb bucket -- unsound, because
+the cell's zero was produced by ordinary moves dominating its volume while
+the rare queen-hanging move had never been tested on its own. The argument
+here runs the opposite direction: it uses the pooled read to vouch for the
+**dominant** population. Since dumb=0 is 82.3% of all tries, a pooled rate
+of ~0 mathematically requires the dumb=0 subpopulation to be ~0 too, so
+pooling is CONSERVATIVE for this purpose.
+
+Preferred implementation (Scott's): per-move `continue` rather than one
+`break`, so the move-specific dumbness veto still applies to the move in
+hand. Nearly the same node savings, none of the blanket assumption.
+
+### 10.11d. The near-leaf depth ceiling is backwards and should be deleted
+
+`ShouldLatePrune`'s `LMP_MAX_REMAINING_DEPTH <= 6*ONE_PLY` was inherited
+from Crafty, where a near-leaf restriction IS the whole risk model because
+they have no table. Our own table shows predicted FH% FALLING ~25x from
+depth bucket 0 to bucket 11, so the ceiling permits pruning where risk is
+highest and forbids it where risk is lowest (already documented in
+section 7 (original), never patched).
+
+The cost of a wrong prune does rise with remaining depth -- which is what a
+rule-of-three CONFIDENCE BAR is for, not a depth cutoff. Per Scott: pruning
+a move with depth 11 under it is fine *if* the static read says "no way" and
+the dynamic vetoes are silent; benefit rises exponentially with remaining
+depth while measured risk falls.
+
+### 10.11e. Per-cell sample counts are available NOW -- no re-collection
+
+`joint_table_collection/pickles/raw_2026-10-02.pkl` survives and holds
+per-cell `(fh, tries)` for all 20,075 populated cells; `grand_tries` =
+690,062,068,603 over 56,530 positions. Running SPEC.md's rule-of-three bars
+(`N ~= 3 * applications_per_game / target_misses_per_game`, ~4,800
+applications/game):
+
+| miss budget | required N | zero-FH cells clearing it |
+|---|---|---|
+| 1 game in 20 | 288,000 | **43** |
+| 1 game in 100 | 1,440,000 | **9** |
+| 1 game in 1000 | 14,400,000 | 0 |
+
+This reproduces section 7 (original)'s "43 cells" lead exactly and adds the
+1/100 tier. **The blocker named in section 7 -- that `g_uJointFHThousandths`
+ships only the rate, not the sample count behind it -- is a
+`splice_joint.py` + `data.c` change against an existing pickle, not a new
+collection run.**
+
+### 10.11f. Prioritized backlog
+
+**1. HIGHEST IMPACT -- deep pruning via the table (10.11b/c/d/e).**
+Rationale: Crafty's 4.7-ply branching advantage is demonstrably truncation
+(10.11a); typhoon has no truncation; the payoff is exponential in remaining
+depth; the data exists; 43 cells already clear a 1/20-games bar. Closing r
+from 2.17 toward ~1.9 is worth ~1.6 ply ~= ~150 Elo at the 10.10a yardstick
+-- an order of magnitude more than anything else on this list.
+Steps: (a) ship per-cell tries via `splice_joint.py`/`data.c`; (b) build the
+prune-specific shadow-verification harness FIRST, with the
+`NEUTRALIZE_NULL_BAILOUT` fix applied from day one (SPEC.md's agreed design
+requirement #4); (c) re-point `ShouldLatePrune` at a rule-of-three-cleared
+sample bar + dumb-marginalized monotonicity criterion, deleting the depth
+ceiling; (d) keep the dynamic per-move-identity veto (requirement #5).
+
+**2. CHEAP AND UNATTENDED -- HEAD vs HEAD with LMR disabled.**
+The control condition 25 years of this work has lacked. The 10.10a yardstick
+measures what TIME-bought depth is worth; nothing has ever measured whether
+HEAD's own flat -1 ply LMR converts. If removing it costs 0.5 ply and ~45
+Elo, reduction-bought depth does convert for HEAD and the candidate broke
+something specific. If it costs 0.5 ply and ~5 Elo, reduction-bought depth
+has never been worth anything in this engine and the answer is LESS
+reduction, not better reduction. One build, one match, runs while other work
+proceeds. Use `SPRT_ARGS="--sprt --elo0 0 --elo1 15"`.
+
+**3. DURABLE INFRASTRUCTURE -- the move-agreement test.**
+Replaces curated-suite solve counts (shown to be noise at these sample sizes
+in 10.6) with a metric that has a usable signal-to-noise ratio:
+ - take ~300 real-game positions (`tests/cross_sectional_1.ep_`);
+ - build an ORACLE best move per position -- ideally Crafty at high depth,
+ independently implemented and cheap for them;
+ - run each candidate at a fixed `sn` budget; record % agreement with oracle.
+Every position contributes a signal (not just the ~20% that are tactical
+puzzles with a known answer), there is no dependence on EPD solution
+correctness, and 300 positions gives a binomial SE of ~2.9%.
+Immediate use: at EQUAL node budget, does the LMR candidate pick the oracle's
+move less often than HEAD? Yes => its extra nominal depth costs real decision
+quality. No => the depth was simply never worth anything (a different problem
+with a different fix). Refinement: score both moves with the oracle and only
+count a disagreement when it prefers its own by a real margin.
+
+**4. SMALL BUT CLEAN -- drop the six negligible extension sources.**
+From a real `sd 15` dump: `zug` fired **9** times, `endg` 352, `mult+` 1,410,
+`recap` 10,339, `sing` 10,296, `q+` 35,427 -- against the check extension's
+1,483,277. These cannot affect anything at those volumes and need no gate;
+just confirm grant counts go to zero and node counts do not move. Weighted
+by ply amount the check extension is **~78% of all extension spend**; every
+other source combined is ~0.35 ply ~= a ~33 Elo ceiling, and individually
+3-9 Elo each -- unmeasurable by self-play (~25,000 games to separate 5 Elo
+from 0). So test the mid-tier (threat + bm + 1mv + !kmvs + pawn) as ONE
+bundle, not five matches.
+
+**5. SCREEN-ONLY -- check-extension magnitude variants.**
+Non-negotiable as a mechanism (Scott: prior attempts at SEE-gating caused
+sacrifice-blindness; "king moves out" variants exploded the branching
+factor). But the AMOUNT and gate are open: 3/4 -> 1/2 ply; material gate ->
+Crafty's SEE gate; 3/4 -> 1 full ply (Crafty's amount -- extensions are not
+automatically bad). ~4 minutes each on the C/r sweep
+(`/tmp/typhoon/cr_measure/run.sh`). Note the 3/4-ply SEE>=0 branch is
+700,479 grants = 2.1M of ~2.9M check quarter-plies, i.e. 57% of ALL
+extension spend -- that is the branch worth screening, not the 1/4-ply one.
+
+**6. DEAD -- do not revisit.**
+ - *LMR magnitude grading / per-factor adjustment*: five prior attempts,
+ plus 10.8 (table vs flat is a wash) plus 10.11a (Crafty has one
+ decrement and is leaner anyway).
+ - *Clamp removal / reducing into qsearch*: 10.9a arithmetic (+0.017 root
+ ply) plus Stockfish flooring reduced depth at 1 ply
+ (`search.cpp:1370`) while getting its leanness from separate explicit
+ pruning.
+ - *SEE<0 check extension removal*: measured Delta-r = -0.004 (median over 12
+ positions), a ~3 Elo ceiling. Also independently reported to cause
+ sacrifice-blindness in earlier work. The counter-based estimate that
+ sized this at 21% of extension spend was wrong by ~20x because
+ `uCheckByQuarterPly[1]` pools the SEE<0 and low-enemy-material branches
+ -- **screen with Delta-r, never size an extension from grant counts.**
+ - *Attacking qsearch width to close the Crafty gap*: 10.10d -- `C` fits
+ LOWER for typhoon (~287) than Crafty (~448), and `C` sits inside the
+ logarithm, so a 20% change is worth ~0.3 ply.
+
+## 10.12. The control experiment, the conversion-rate model, and why flat LMR is hard to beat (2026-10-03, later)
+
+### 10.12a. LMR vs no LMR -- the control condition 25 years of this work never had
+
+`DISABLE_LMR` (GNUmakefile) makes `GetLMRReduction` return 0 before consulting
+any signal. Two binaries from the SAME tree, differing only in that flag, run
+head-to-head with NO SPRT (measuring a quantity, not testing a hypothesis --
+same reasoning as the time-odds calibration in 10.10a). `noLMR` is the
+baseline, so a positive score means LMR helps.
+
+| games | score | Elo | depth (withLMR vs noLMR) |
+|---|---|---|---|
+| 50 | 0.550 | +35 +/- 69 | 13.75 vs 12.59 (+1.16) |
+| 90 | 0.550 | +35 +/- 51 | 13.96 vs 12.79 (+1.17) |
+| 110 | 0.559 | +41 +/- 46 | 13.72 vs 12.57 (+1.15) |
+| 150 | 0.553 | +37 +/- 40 | 13.59 vs 12.43 (+1.16) |
+| 180 | 0.575 | +53 +/- 36 | 13.51 vs 12.36 (+1.15) |
+| 210 | 0.569 | **+48 +/- 34** | 13.54 vs 12.35 (+1.19) |
+
+**LMR is worth roughly +40-50 Elo and buys ~1.17 ply.** Remarkably stable
+across six checkpoints. The depth delta is the largest and steadiest of any
+candidate in this document's history.
+
+This settles a question nobody had ever asked: LMR *does* earn its keep here.
+Scott's prior ("LMR has never earned its keep in this engine") was wrong, and
+so was the stronger form of the author's "reduction-bought depth is debased"
+thesis.
+
+### 10.12b. The conversion-rate model: diminishing returns, not a flat rate
+
+Pulling every Elo-per-ply measurement together:
+
+| mechanism | what it buys | measured rate |
+|---|---|---|
+| **LMR vs NO LMR** (the FIRST reduction) | +1.17 ply | **~40 Elo/ply** |
+| graded LMR *on top of* flat LMR | +0.65 ply | ~8 Elo/ply |
+| pruning (`PRUNE_LIVE=10`) *on top of* graded LMR | +0.88 ply | ~8 Elo/ply |
+| 2x time (NEW resource, not a transfer) | +0.90 ply | ~95 Elo/ply |
+
+That is a decreasing-marginal-returns curve, and it is a better model than the
+flat "~8 Elo/ply for all tree-shaping" claim made earlier in this session.
+
+**Why the earlier claim was wrong**: the graded-LMR and prune candidates were
+both measured against `head_reference`, WHICH ALREADY HAS LMR. Their +0.65 and
++0.88 ply were MARGINAL plies stacked on an already-reduced tree. 10.12a
+measures the FIRST ply of reduction, which is where the value is.
+
+**Practical calibration (Scott's framing, and it is the right one)**: there is
+not another 40 Elo available from LMR/LMP/pruning work. There is maybe 40-45
+Elo TOTAL in the whole reduction program, of which the dumb flat -1 ply
+already captures ~85%. Five failed attempts to improve on it do not mean it
+cannot be improved -- they mean expectations should be calibrated to single
+digits, and that single digits cannot be measured in affordable self-play
+(resolving +5 vs 0 needs ~10,000 games).
+
+**ALSO NOTE a yardstick error this corrects.** 10.10a divided the time-odds
+result (+86 Elo for +0.90 ply) and called it "~95 Elo per ply," then used that
+to compute ceilings all session. That division assumes depth is the ONLY
+channel through which extra nodes help. It is not: at fixed nodes, a ply bought
+by shrinking the tree is worth far less. So "95 Elo/ply" is the rate for
+TIME-bought plies specifically and must never be applied to tree-shaping
+changes. Every ceiling computed from it earlier in this document was inflated
+(e.g. `PRUNE_LIVE=10`'s ceiling was quoted as 33 Elo; corrected to ~7, and it
+measured +7 -- it hit its real ceiling exactly rather than underperforming).
+
+### 10.12c. Why a flat -1 ply is so hard to beat -- the actual mechanism
+
+The curve is already present (`_FlatTableExtraPlies` reads
+`g_iLMRQuietReduction`, dynamic.c's `log(depth)*log(moves)` table). The problem
+is WHERE it can act. Evaluating `0.7844 + log(d)*log(m)/2.4696`:
+
+| remaining depth | moves tried | formula | extra plies |
+|---|---|---|---|
+| 2 | 16 | 1.56 | **0** |
+| 3 | 16 | 2.02 | 1 |
+| 4 | 16 | 2.34 | 1 |
+| 10 | 20 | 3.58 | 2 |
+
+**At remaining depth <= 2 the curve prescribes ~1 ply -- which IS flat -1.**
+And 76% of the LMR population sits at depth buckets 2-3 (10.8). So flat is not
+a crude approximation of the curve; at the depths where the mass lives, the two
+AGREE.
+
+Measured consequence: the curve contributes mean **0.164 extra plies** over the
+LMR population, which through section 5 (original)'s damping (~1.0 extra
+ply/decision -> +0.29 root ply) is about **+0.05 root ply**. That is the
+curve's entire current contribution, and it explains the null results directly.
+
+The curve's remaining headroom is the ~11% of moves at remaining depth >= 4,
+further limited by `iMaxExtra = depth - 3` (the one-full-width-ply invariant).
+
+**Open and never cleanly tested**: curve-vs-flat in self-play. The 2212-game
+run (section "Self-play status", 2026-09-29) tested the joint-table TIER
+magnitude under the pre-unification `uLegalMoves > 5` gate, NOT section 8's
+`log(d)*log(m)` version, which was only ever validated on sd10 node counts and
+sn5M suites. An earlier draft of this section wrongly treated that null as
+having answered the question.
+
+### 10.12d. Retraction: eNodeType is too unreliable to modulate reduction
+
+10.7b and 10.9c recommended using `pi->eNodeType` to reduce more at CUT nodes
+than ALL nodes. **Withdrawn.** After fixing the node-type surprise counters
+(10.12f) the full-width rates read: ALL nodes fail HIGH 69-71% of the time,
+CUT nodes fail low 12-14%. An "ALL node" that fails high two-thirds of the time
+is not behaving like an ALL node -- the structural label (assigned purely from
+the parent's type) is close to uninformative here. Modulating reduction off a
+signal that unreliable adds complexity for noise.
+
+### 10.12e. QSearch / QSearchFromCheckNoStandPat review
+
+Reviewed for logic errors in the custom stand-pat and check-generation rules.
+
+**SHIPPED: option B -- waive the opponent-stand-pat requirement at PV nodes.**
+`fIncludeChecks`' first clause suppressed check generation whenever the
+opponent could stand pat at an ancestor. That suppression rests on a sound
+argument *for null-window nodes*: once their stand-pat has raised alpha, no
+line below that score can change what they return, so resolving mate-in-10 vs
+"drops a queen" is wasted work -- and even when their stand-pat did NOT raise
+alpha, the node is heading for a fail-low, which IS the parent's beta cutoff,
+so any score at or below alpha answers the question identically. It does NOT
+hold at a WIDE-WINDOW node, where the exact score propagates to the root and
+those two outcomes are a different PV and a different reported score -- which
+is the forced-mate-from-qsearch case this machinery exists for. Now:
+`(fCouldStandPat[FLIP(uToMove)] == FALSE) || (NODE_PV == pi->eNodeType)`,
+keeping the material requirement (an independent sanity check, not a
+suppression). Reads eNodeType, never writes it. Uses eNodeType rather than
+recomputing `(iBeta != iAlpha+1)` because iAlpha may have just been raised by
+the stand-pat, which would make a recomputed width test read narrow for a node
+that is genuinely PV.
+
+**SHIPPED: `iExtensionAmount` unit fix.** `QSearchFromCheckNoStandPat` wrote
+`uQsearchCheckExtension` (0 or 2, counting qsearch CHECK PLIES) into a field
+that is ONE_PLY-scaled everywhere else (root.c writes HALF_PLY, split.c writes
+iLMR, search.c writes iExtend) -- a latent 32x error. Benign as it stood (both
+readers only test sign) but the field is already a documented stale-state
+hazard. Now scaled; sign and all reader behavior unchanged.
+
+**SHIPPED: `pi->iEval` published** when `QSearchFromCheckNoStandPat`'s lazy
+`Eval()` actually runs. It used to stay purely local, so that ply slot kept a
+stale value. Still stale on the common in-check path where Eval is never called
+at all (no stand-pat is offered, so there is nothing to compute it for) --
+making that safe needs invalidation on entry, a separate change.
+
+**TRIED AND REVERTED THE SAME DAY: `iBestScore = MAX(iAlpha, iEval -
+iInTrouble)`.** The observation behind it was real -- the beta test lives only
+in the else-branch, so a node with `iEval >> iBeta` and hanging material
+returns a fail-low instead of cutting off. The fix was wrong twice over, and
+self-play caught it in 10 games (candidate depth collapsed to +0.05 ply vs
+head, from the +0.65 the same tree showed without it):
+
+ Work the signs. A parent searching this node passed window
+ `(-iBeta_p, -iAlpha_p)`, so `iAlpha` here == `-iBeta_p`. The parent fails high
+ iff `-V >= iBeta_p`, i.e. iff `V <= iAlpha`. So returning exactly `iAlpha` is
+ not a crude placeholder -- **it is precisely the value that hands the parent
+ its cutoff.** Returning MAX(...) instead reports a better-than-alpha value and
+ denies that cutoff (~0.45 ply of lost depth), AND asserts the node is worth at
+ least `iEval - iInTrouble` without searching anything -- a stand-pat credit at
+ a discount, exactly what the branch exists to refuse.
+
+`MIN(iAlpha, iEval - iInTrouble)` was then proposed and also rejected: it is
+depth-neutral (V <= iAlpha always, so the cutoff always fires) but it cannot
+produce the cutoff the original observation was about (MIN never exceeds
+iAlpha), and it asserts an unearned TIGHTER upper bound, which propagates as an
+inflated lower bound into the parent's `StoreLowerBound`. The original
+observation is still unfixed; a sound fix needs a bound the node has EARNED.
+Probably moot: a node with material in trouble almost always has a capture
+available, and captures sort above the `iValue <= 0` early exit, so the move
+loop usually recovers the cutoff by search.
+
+**Confirmed correct** (checked, no change needed): the qsearch-entry reset
+invariant (`search.c:573` and `command.c:739` clear `fCouldStandPat[]`,
+`uQsearchNodes`, `uQsearchDepth` and reset `uQsearchCheckDepth` before every
+drop into qsearch; the only other QSearch call site is
+`CALIBRATE_QSEARCH_FUTILITY`-only and already inside a qsearch state);
+`fCouldStandPat` scoping (each node saves/restores only its own side's flag and
+never writes the opponent's, so `fCouldStandPat[FLIP(uToMove)]` genuinely
+reflects the parent); paired `uQsearchDepth`/`uQsearchCheckDepth` increments
+with no `goto end` between any pair; and the `uLegalMoves > 0` guard on escape
+futility pruning, which correctly prevents a bogus mate score even when leading
+escapes are illegal.
+
+**The exchange-truncation mechanism**, for the record: it is NOT the futility
+margin (`iFutility = iAlpha - (FUTILITY_BASE_MARGIN_BY_SOURCE[
+pi->uLastPositionalSource] + rgiPositional[...]) - iEval` is genuinely
+eval-calibrated). It is the SEE-ordered early exit at the top of QSearch's move
+loop: moves are SEE-sorted and the first `iValue <= 0` does `goto end`,
+abandoning every remaining move. So qsearch only explores captures SEE scores
+as non-losing, and any exchange whose resolution needs a SEE-negative
+intermediate capture is outside the horizon. That cutoff is sound only to the
+extent SEE is right, and SPEC.md already documents `_WhoAttacksSquareBB`
+(see.c) having no pin/legality awareness. Three fixes with very different
+costs: make SEE pin-aware (`C`-side, ~0.3 ply for a 20% C rise, and improves
+every other SEE consumer); admit SEE<0 captures at shallow qsearch depth
+(`C`-side, bounded); extend all captures fractionally (`r`-side, exponential).
+Fix the cause on the C side before spending r.
+
+### 10.12f. Node-type surprise line: four defects
+
+Scott's question ("how can 47% of ALL nodes fail high?") was right, and chasing
+it found four separate defects in one Trace line:
+
+1. **Denominator was total nodes**, not nodes of that type.
+2. **Qsearch was included** (~84% of the tree). A qsearch node whose stand-pat
+ beats beta returns `iBestScore >= iBeta` immediately; typed ALL, that reads
+ as "ALL failed high" while being the most ordinary event in qsearch. Now
+ excluded from numerator (QSearch's copy MOVED to its own slots) and
+ denominator (`u64NodeTypeCount`, incremented in `Search()` only).
+3. **Stale node types.** Every early exit in `CommonSearchInit` returns TRUE
+ *before* `pi->eNodeType` is assigned, and the caller's `goto end` lands on
+ the counters -- which scored the node against whatever type the PREVIOUS
+ node at that ply left behind. That produced an impossible "106.2% of PV
+ nodes". Now guarded by `fNodeTypedThisVisit`.
+4. **The fail-low test used the in-place-raised `iAlpha`.** `search.c:2048`
+ does `iAlpha = iScore`, so a node that SUCCESSFULLY raised alpha ends with
+ `iAlpha == iBestScore` and `(iBestScore <= iAlpha)` reads true -- every
+ success counted as a fail-low. The PV row read 99.1%. Now uses
+ `iInitialAlpha`, which already existed for exactly this distinction
+ (`search.c:2105`).
+
+Also added, per Scott's QNODE suggestion applied to the counters rather than
+the enum: `u64QNodeTypeCount`/`u64QNodeSurprise`, plus a **PVS identity
+cross-check**. The identity: a CUT node failing low returns `score <= alpha_C`,
+so its parent (an ALL node) sees `-score_C >= beta_P` and fails HIGH, and that
+parent cannot have had an earlier child fail low (it would have cut off first)
+-- so #(CUT fails low) and #(ALL fails high) should run ~1:1. Counting qsearch
+separately brought the measured ratio from 3.6 to **1.86**. The residual means
+~46% of ALL-node fail-highs come from nodes resolved with NO child fail-low at
+all: qsearch stand-pat cutoffs, interior-node recognizer/EGTB fail-highs, and
+null-move cutoffs (including the Tord Romstad bailout, which fabricates a score
+and searches nothing). The NODE_TYPE enum and every behavioral consumer (all of
+which only test against NODE_PV) are untouched.
+
+### 10.12g. PERF_COUNTERS cleanup -- the standard profile was carrying investigation debris
+
+An audit for real WORK (not counter increments) inside `#ifdef PERF_COUNTERS`
+found five sites in three categories. All three were fixed.
+
+**Category 1 -- three `rdtsc` calls leaking into the standard build.**
+`eval.c`'s king-safety hash probe bracketed itself with
+`SystemReadTimeStampCounter()` on entry and exit, in one of the hottest paths
+in the engine; `rdtsc` is ~20-30 cycles and partially serializing. An
+`EVAL_TIME=1` flag already exists for exactly this ("make a version that counts
+cycles spent in eval") and already guards the other **32** rdtsc sites in that
+file -- these three were the only leaks. Moved to EVAL_TIME. (The report side
+in root.c was already EVAL_TIME-guarded, so nothing dangles.)
+
+**Category 2 -- two one-off diagnostics, deleted outright.** Both did per-
+decision lookups plus integer division at the hottest decision point in the
+search, and both had comments saying to remove them once their investigations
+concluded:
+ - `u64HeadStyleEligible`/`u64HeadStyleWouldReduce` (called
+ `GetMoveFailHighRawCounts`). Its investigation concluded -- it produced
+ 10.2's 92-94%-vs-70-75% admission finding.
+ - `u64FHJointHist` (called `GetMoveFailHighPctThousandths`). Also concluded --
+ it produced section 4 (original)'s bimodal-dynamic-rate finding.
+Removed the counters, the `g_Options` mirrors, the suite accumulation and BOTH
+report blocks -- a diagnostic that prints zeros forever is worse than none.
+`AttemptsBucket`/`RateBucket` went with them. Binary shrank 7,760 bytes.
+
+**Category 3 -- the 4D collection block, re-gated to FH_TABLE_COLLECTION.**
+`search.c`'s `u64JointTries`/`u64JointFH` collection calls
+`EstimateHangingMaterialCp` (real attack-bitboard work) per eligible move. It
+is COLLECTION instrumentation, and CLAUDE.md already says FH_TABLE_COLLECTION
+is deliberately kept out of the standard profile *because this measurably costs
+nps* -- this block had escaped that gating. Verified by guard inspection:
+`search.c:1637` is now `PERF_COUNTERS > FH_TABLE_COLLECTION`; the only other
+`EstimateHangingMaterialCp` call site is the prune predictor, correctly behind
+`PRUNE_VERIFY`/`PRUNE_LIVE`.
+**Bonus:** it was computing the hang bucket TWICE per move in any build with a
+prune gate (the gate needs the same value), which inflated the `PRUNE_LIVE`
+candidate's nps deficit (-6.0% vs head) and made that match mildly unfair to
+the candidate.
+
+**nps impact: NOT MEASURED.** Two attempts were made and both are worthless
+because the box was running a 32-engine self-play match. The second attempt
+produced `perf_clean` FASTER than `noperf`, which is impossible (the cleaned
+build still has every counter increment `noperf` lacks) -- proof that noise
+exceeded the effect. Also a methodology error worth not repeating: the script
+ran all of arm A, then all of B, then all of C, so a load drift masquerades as
+an effect. **Re-measure on an idle box with ROUND-ROBIN interleaved arms**,
+fixed node budget (`sn 8000000`), same position, 3+ runs each.
+
+### 10.12h. The deep-prune audit and miss verification
+
+`PRUNE_VERIFY` (GNUmakefile) -- audit-only shadow verification for the prune
+gate. It evaluates the table's prediction for every `fPruneReduceEligible` move,
+records it banded by predicted FH%, then **declines to prune** and watches
+whether the move fails high in its own ordinary search. No bespoke shadow
+`Search()` call is needed, which sidesteps the null-move-bailout fabrication
+that made `JOINT_EXTRA_VERIFY`'s first numbers 80-99.8% artifact. Banding by the
+decision variable means one run answers "is `prune if pred <= K` sound" for
+every candidate K at once.
+
+Pooled over the three curated suites (65.6M flagged decisions), measured FH%
+ran **3-19x BELOW predicted** in every prunable band -- the Wilson re-bake
+(10.10/10.11e) doing what it was for. But `cross_sectional` positions (250
+samples, the real-game distribution) came back **5-11x WORSE than the curated
+suites** in the low bands, with `measured > predicted`. The explanation is
+distributional and inverts SPEC.md's stated worry: in a tactical puzzle the
+action is in forcing moves, which gate-1 excludes by construction, so leftover
+quiet moves there really are the dregs; in an ordinary middlegame the best move
+is frequently a quiet one, landing squarely in the leftover pool. **Validating
+a prune gate on puzzles understates its risk for real games.**
+
+Verification of the 71 low-band (`pred <= 2`) cross_sectional misses against
+clean deeper searches: **27 confirmed real, 44 artifacts.** The margin column
+discriminates cleanly -- `margin ~= 0` means artifact (a score that only just
+clears a tight inherited PVS window), huge margin means a genuine forced mate.
+That inverts the author's initial guess, which had it backwards.
+
+One confirmed band-0 miss is worth keeping as a counter-example to the dumb
+axis: `6k1/p4p1p/3p2q1/2p1rP2/2P3p1/P7/1P1Qn1PK/3R1R2 w`, after `Qg6`. The
+queen only LOOKS hung -- White is losing too badly to take it (best line grabs
+the e2 knight; White is -14 at depth 19), so a move the table rates
+"obviously hangs material for nothing, never fails high" is actually winning.
+A structural mispredict that no amount of sampling conservatism catches, which
+is precisely why this harness had to exist before pruning went live.
+
+**Prune threshold measurements** (12-position C/r sweep, paired per-position
+deltas -- note the author initially reported difference-of-medians instead of
+median-of-differences, which flipped the sign on two of three arms):
+
+| threshold | share of eligible | median dr | ply @d12 | verified miss rate |
+|---|---|---|---|---|
+| `pred <= 2` | 4.3% | -0.0040 | +0.03 | ~1 in 481,000 |
+| `pred <= 5` | 16.6% | -0.0281 | +0.21 | ~1 in 360,000 |
+| `pred <= 10` | 30.6% | -0.0465 | +0.35 | ~1 in 121,000 |
+
+Band 6-10 alone carries 79% of the misses for 29% of the flagged volume, so
+`pred <= 5` keeps ~60% of the depth for ~16% of the risk. `PRUNE_LIVE=10`
+self-play: +0.88 ply, +7 Elo at 150 games (llr flat) -- which is exactly its
+corrected ~7 Elo ceiling (10.12b), so it performed as well as it possibly could
+and that is still not interesting.
+
+Also measured and worth recording: **a dynamic veto cannot save these prunes.**
+Cross-tabulating prunable-band candidates by (own-FH attempts x own-FH rate),
+11 of 23 low-band misses sit at **rate = 0** -- the per-move history agrees with
+the static table and both are wrong. A rule catching the 12 high-rate misses
+would refuse ~390,000 sound prunes to do it (~49,000 per miss prevented), the
+same failure mode that retired the `GetMoveFailHighPercentage` veto before.
+
+## 10.13. Open action items as of 2026-10-03 EOD
+
+Ordered by expected value. Items 1-2 are the only ones with a plausible
+double-digit Elo payoff.
+
+### 1. Give EFP the `dumb` axis -- the best remaining idea
+EFP is keyed on gap-to-alpha and blind to the other three. Measured over EFP's
+actual operating envelope (10.2): the **dumb axis swings FH% 15-30x** within an
+otherwise-fixed cell while **gap -- EFP's only lever -- swings ~4x** across its
+entire knight-to-rook range. EFP is reading the weakest signal and ignoring the
+strongest. And unlike every LMR/LMP experiment, this is an **accuracy** play
+rather than a depth purchase: EFP prunes the same volume either way, so it is
+not buying marginal plies at ~8 Elo/ply and nothing measured so far bounds its
+upside. It also finally gives the 4D table a consumer that uses its dynamic
+range. Caveat from section "the tactical-position gap": the first table-driven
+EFP attempt lost ECM.272 and four others and threshold-tightening rescued only
+one -- diagnosed as a training-population problem. The table has been
+re-collected and Wilson-re-baked since, so retry rather than assume.
+
+### 2. The nps gap
+Crafty measured at **5.5M nps** on a real 10-second search (an earlier 7.5M
+figure came from a 0.01s search where the timer is meaningless -- do not quote
+it) against typhoon's ~1.4M. Even discounting for Crafty counting illegal moves
+as nodes (`search.c:452` increments before the legality test), that is roughly
+4x. Time-bought depth converts at ~95 Elo/ply, so the speed term in the Crafty
+gap (~2.7 ply) is worth ~250 Elo -- the only lever on the table with that
+magnitude. Scott's profiling says abandoning the 0x88/bitboard hybrid buys
+maybe 500knps, which does not close it, and the mechanism for their remaining
+advantage is not understood.
+
+### 3. Re-measure the PERF_COUNTERS cleanup on an idle box
+Round-robin interleaved arms, `sn 8000000`, same position, 3+ runs each. Two
+prior attempts are worthless (see 10.12g). Arms are built:
+`/tmp/typhoon/candidates/typhoon_{perf,perf_clean,noperf}`.
+
+### 4. Curve-vs-flat LMR, cleanly
+Never tested in self-play (10.12c). One flag: current tree vs current tree with
+`_FlatTableExtraPlies` forced to 0. Expected ~1-3 Elo from the arithmetic, so
+self-play cannot resolve it -- measure **node count at fixed depth** instead,
+where 0.164 mean extra plies should be visible.
+
+### 5. Decide the fate of the 4D table
+Its only live shipping consumer is `LMR_STATIC_ADMIT_PCT_THOUSANDTHS`, which
+vetoes 509 of 21,780 cells (2.3% of cell space, 7.4% of traffic) -- and that
+threshold was chosen in section 6 (original) on solve-count deltas that 10.6
+showed were noise. **Cheap decisive test**: build with admission effectively
+disabled (`= 100000`) and run against current. If neutral, the table buys
+nothing in a shipping build. NOTE item 1 may make this moot by giving the table
+a real consumer -- do item 1 first.
+
+### 6. Triage six uncatalogued ECM positions into ../tests/ECM.md
+`ECM.348`, `ECM.018`, `ECM.084`, `ECM.516`, `ECM.640`, `ECM.774` all flipped in
+the 10.6 per-position diff and are not in the flaky/legit catalogue.
+
+### 7. Smaller / hygiene
+- **Strip the remaining one-off diagnostics**: `u64LMPPruned` and the
+ `JOINT_EXTRA_VERIFY` family are still wired under PERF_COUNTERS.
+- **Share the hang bucket** between the collection instrumentation and any
+ prune gate instead of computing `EstimateHangingMaterialCp` twice (10.12g).
+- **`pi->iEval` is still stale** on the common in-check qsearch path (Eval is
+ never called there). Only reader is `search.c:760`'s `iImprovement`, which
+ should never see a qsearch ply given the layering -- add an ASSERT recording
+ that invariant.
+- **`JOINT_EXTRA_TIER1/2/3_PCT_THOUSANDTHS`** are vestigial (TIER1 appears only
+ in comments, TIER3 only in the disabled LMP path).
+- **A sound fix for the `iEval >> iBeta` lost cutoff** in QSearch's
+ material-in-trouble branch is still open; both MAX and MIN forms were tried
+ and rejected (10.12e). Needs a bound the node has EARNED. Probably low value
+ -- the move loop usually recovers the cutoff by search.
+- **`ShouldLatePrune`'s depth ceiling is still backwards** (`<= 6*ONE_PLY`
+ permits pruning where the table says risk is highest). Moot while LMP is
+ disabled.
+
+### 8. Shelved deliberately
+- **`PRUNE_LIVE`/LMP**: ~7 Elo, hit its ceiling exactly, needs the table most.
+ Code stays behind its flag; do not ship.
+- **`DISABLE_LMR`**: diagnostic only. 10.12a says removing LMR costs ~40-50
+ Elo, so this is NOT a shipping candidate.
+- **Clamp removal / reducing into qsearch**: twice falsified (10.9a arithmetic;
+ Stockfish floors reduced depth at 1 ply while getting its leanness from
+ separate explicit pruning).
+- **LMR magnitude grading via the joint table**: 10.8 (table vs flat is a wash)
+ plus 10.11a (Crafty has ONE `reduce--` and is leaner anyway).
+- **`eNodeType` as an LMR signal**: 10.12d.
+- **SEE<0 check-extension removal**: measured dr = -0.004, ~3 Elo ceiling, and
+ independently reported to cause sacrifice-blindness.
diff --git a/src/lmr_testing/SPEC.md b/src/lmr_testing/SPEC.md
new file mode 100644
index 0000000..55e6c5c
--- /dev/null
+++ b/src/lmr_testing/SPEC.md
@@ -0,0 +1,1543 @@
+# EXECUTIVE SUMMARY:
+
+We are implementing late move reduction / late move pruning in a chess
+engine based on a "simple idea": measure the propensity of a given
+move at a given node in the search tree (search.c/split.c) to fail
+high using (at least) four signals:
+
+- The move's "lateness", i.e. the order it was generated by the move
+ generator code (generate.c). Because this code generates moves in
+ order with the best moves first, the only moves left at the end are
+ either quiet moves or losing captures, usually. Not very
+ interesting ones, usually. To be extra certain that the late moves
+ we are looking to reduce/prune are uninteresting, we also prohibit
+ checking moves, escapes from check, moves on PV nodes, moves with
+ extensions, killer moves, and, in general, do not prune when any of
+ several signals indicate that this "uninteresting quiet move" might
+ be more than meets the eye.
+- The move's "dumbness", i.e. does it seem to hang material? A move
+ of our queen next to an enemy pawn might be a brilliant sacrifice
+ but most of the time it's a blunder.
+- The situation at the node (position) at which the move was
+ generated, i.e. is the side to move "winning" or are things "going
+ badly"? Since our search is an alpha..beta search, the gap between
+ a static evaluation of the current node (iEval) and alpha (iAlpha)
+ indicates "how far the side to move would have to improve the score
+ at the current position in order for our move to matter (reach the
+ PV and/or fail high). The bigger this gap, the less likely a move
+ is to fail high because to do so requires a stronger move.
+- The move's remaining depth. This one is counter-intuitive but if
+ you do the work to compute the probability of a move to fail high
+ then you find that the deeper we search the move tree under that
+ move, the *less* likely the move is to fail high. This is a really
+ interesting phenomenon -- it indicates that scores (and therefore
+ PVs) returned by shallow searches are very noisy but once you search
+ 3-4-5-8-12 ply under the move, the score usually stabilizes and
+ actually diminishes the deeper you look.
+
+We call these four signals "axes" and have computed a probability table
+where, given a score for each of these signals, you can look up the
+computed probability that the move (and node) exhibiting these signals
+will fail high. This is the core of our approach.
+
+# GOAL:
+
+The goal is to reduce the size of the chess search tree that we search
+(and therefore to reduce our effective branching factor) by reducing
+the depth we search the subset of moves we believe will not fail high
+or outright pruning them (i.e. not searching under them at all). Each
+node saved (via reduction or pruning) is re-allocated to cause the
+engine to be able to search deeper on (hopefully) more interesting and
+meaningful nodes/moves elsewhere in the tree.
+
+# DANGER:
+
+When we reduce the search under a move we introduce error in the score
+returned. It can bite us in two ways:
+- The move can fail high even though we did not expect it to. If a
+ move fails high with a reduced depth search, search.c re-searches
+ it at full depth to confirm the fail high so this is not fatal; it
+ just wastes some time and double searches the tree under the move
+ in question.
+- The move can fail low even though if we would have searched it with
+ full depth it would have failed high. This is a fatal miss; we have
+ lost information here. To help defend against this, we have built a
+ version of the engine that, when it selects a move to reduce, it
+ then proceeds to run the search at full depth anyway and pays
+ attention to how many times a reduced move would have ended up
+ failing high. This is very rare but not impossible. It is, by far
+ the most dangerous thing about reducing moves.
+
+All in all, though, reducing the search tree under a weak,
+uninteresting move is less dangerous than the alternative: pruning the
+move completely. Pruning means never searching under the move at all
+-- skipping it completely. Therefore, the threshold to reduce a move
+is much less than the threshold needed to prune a move outright.
+When we are making irrecoverable decisions, we must be conservative!
+
+# Four-axis joint FH% table -- next steps, 2026-09-28
+
+See `lmr_testing/2026-09-28_four_axis_joint_table_plan.md` for the full
+plan (axis definitions, table shape/encoding, collection/bake recipe,
+consumption design). This section used to duplicate that entire file
+verbatim; removed 2026-09-30 (alongside the late-bucket fix below) so
+future edits only need to touch one copy.
+
+**Correction, 2026-09-30, carried over from the plan doc**: the table's
+`late` axis shipped-bucket mapping had an off-by-one. Raw `late`
+(`uLegalMoves` at the point it's sampled) can never be less than 2 -- the
+collection/consultation gate (`fPruneReduceEligible`, `search.c`) requires
+`uLegalMoves > 1`, and `_MoveNumBucket`'s own 1-based-index comment
+already ruled out 0. The old bucketing (`raw - 1`, real floor assumed to
+be 1) left shipped bucket 0 permanently unreachable -- a full
+`depth x dumb x gap` slice (1,452 of 21,780 shipped cells) that read the
+`0xFFFF` sentinel forever, not from insufficient collection but because
+no sample could ever land there. Fixed by shifting `raw - 2` instead (real
+floor is 2): raw 2-13 -> shipped 0-11, 14-21 -> 12, 22-31 -> 13, 32+ -> 14.
+Applied in `search.c`'s `_ShipLateBucket`, `dashboard.py`'s
+`ship_late_bucket`, and `joint_table_collection/bake_joint.py` (newly
+written -- imports `dashboard.py`'s bucket functions rather than
+redefining them). `data.c`'s `g_uJointFHThousandths` was rebaked from the
+2026-09-30 collection run with the fix in place: real coverage is
+18,775/21,780 (86.2%) populated, not the 17,444/21,780 (80.1%) the old
+bucketing would have reported on the same data. No data was discarded --
+this was a bucketing/reporting bug, not a population/gate mismatch (a
+separate concern raised and ruled out the same day, see below).
+
+# FH%-by-(depth, move, gap) table, EFP==LMP unification, and LMR as a
+# fallback -- brain dump, 2026-09-26
+
+This is a different, unrelated project from the `README.md`/
+`searchsup_GetLMRReduction.c`/`generate_counter_move_block.c` files
+already in this directory (that's the 2026-08-27 graded-LMR-curve +
+counter-move sweep, reverted to a clean baseline and stashed for
+possible later reuse). This write-up is about a same-week but
+independent effort: building a real, measured `P(fail-high)` surface
+and using it to drive EFP, and eventually LMR, instead of hand-picked
+constants or a hand-picked reduction curve.
+
+## The core idea
+
+EFP is a pruning method currently in use in the engine; we are
+considering amending it or replacing it.
+
+Historically, EFP (extended futility pruning) was Ernst Heinz's
+two-tier design: only active within ~2.75 ply of the qsearch horizon,
+gated by a hand-picked material margin (`VALUE_KNIGHT` at <=1.75 ply,
+`VALUE_ROOK` at <=2.75 ply), with a per-move `ComputeMoveScore(mv) <
+uFutilityMargin` test on top. LMP (late move pruning) didn't exist in
+this codebase at all; Crafty-style engines that have it usually key it
+on move number/depth alone, blind to how far below alpha the position
+actually looks.
+
+The thesis this session worked from: **EFP and LMP are answering the
+same underlying question** -- "is it safe to stop searching from here"
+-- just entering from different angles (EFP: "this move's static score
+already looks bad, gap-driven"; LMP: "we've tried enough moves,
+move-count-driven"). Once you have real data on `P(fail-high | depth,
+move, gap-to-alpha)`, there's no reason to maintain them as two
+separate hand-tuned mechanisms with two separate blind spots. One
+table, consulted from either direction, replaces both. See the
+conversation this file's SessionId corresponds to for the full
+back-and-forth that arrived at this; the short version is EFP was found
+to be nearly move-number-blind (a move at position 3 and a move at
+position 25 with the *same* gap get treated identically by the old
+margin test, despite real data showing a 6-12x difference in FH% between
+them at the same depth/gap), and that's what motivated building the
+joint table instead of patching EFP's old formula.
+
+## The data: what it is, exactly
+
+`f(remaining_depth, move_number, gap_to_alpha) -> P(fail-high)`,
+where:
+
+- **remaining_depth**: `uDepth / ONE_PLY`, whole plies, NOT capped at
+ the old EFP's 2-3 ply window -- deliberately extended, because the
+ data shows depth 1-2 is the *riskiest* part of the curve, not the
+ safest (FH% falls ~5x from depth 0-1 to depth 9-12, confirmed
+ independently holding move number fixed, so it's a real depth effect
+ not a move-mix artifact). EFP's old activation window covered exactly
+ the highest-risk region and left the safer, deeper region entirely
+ unpruned.
+- **move_number**: 1-based ordinal position in the move list. Also
+ measured as move-*fraction* (fraction of the move list exhausted,
+ 20 buckets of 5%) as a parallel table, since "move 15 of 18" and
+ "move 15 of 54" are different signals and raw move count doesn't
+ capture that. The fraction table exists but the raw-move-number one
+ is what's actually wired into the live lookup right now.
+- **gap_to_alpha**: `iAlpha - iEval`, the NODE's static eval deficit
+ below alpha, in centipawns, bucketed 50cp wide (`FH_GAP_BUCKET_WIDTH`),
+ from -200cp up through +1400cp+ (`FH_GAP_BUCKETS = 32`, chess.h).
+ Deliberately the cheap, already-computed node-level gap (`iEval` is
+ computed for every node regardless of EFP) rather than a per-move gap
+ built on `ComputeMoveScore` -- the costlier per-move version was
+ considered and explicitly deferred, since computing it unconditionally
+ for every leftover move (to avoid the exact censoring bias described
+ below) would add real per-move cost everywhere, not just within EFP's
+ old narrow activation window.
+
+Of the three, **gap dominates by far** -- a ~28,000x swing in FH% from
+the worst to best gap bucket (measured on real data: 28.3% at -200..
+-150cp down to 0.001% at +1300..+1350cp), versus roughly 5-12x from
+depth or move number alone. But move number and depth both carry real,
+independent signal on top of gap -- confirmed by slicing FH% at a FIXED
+gap bucket and watching it still vary 4-12x across move number, and
+still vary ~5x across depth. All three matter; none alone is sufficient.
+
+## The population the data is measured over: "gate-1"
+
+This is the single most important methodological point, and got fixed
+late after an expensive lesson (see "the first real attempt" below).
+**The table must be trained on exactly the population that will
+actually reach it in real search, not on "all leftover moves."**
+
+Note: a "leftover move" is not a capture, killer or good countermove.
+They are the quiet moves and losing captures. They are the tier of
+moves produced by generator.c with a score < GOOD_MOVE.
+
+If you train an FH-rate table on a broader population that includes
+checks, killer-adjacent moves, threat-node members, and normal
+captures -- categories that a real EFP/LMR gate would ALREADY exempt
+via cheap, separately-validated exemptions -- the resulting table is a
+systematic OVERESTIMATE of risk for the population that actually
+reaches it, because those excluded categories are (by design, since
+they earned their exemption from evidence) higher-FH-rate populations
+diluting the measurement.
+
+The "gate-1" eligibility checklist, applied both when COLLECTING the
+data and when CONSULTING the table live, is:
+
+- `fIsLeftoverMove` (sub-`GOOD_MOVE` -- not a winning/even capture,
+ killer, killer-mate, or sufficiently-evidenced countermove)
+- `!IS_CHECKING_MOVE(mv)`
+- `!IS_ESCAPING_CHECK(mv)`
+- `iExtend <= 0` (no extension already applied)
+- `!fThreat` (this node's own null-move probe didn't raise a threat
+ signal)
+- `(!IS_CAPTURE_OR_PROMOTION(mv)) || (iMoveSee < 0)` -- captures/
+ promotions excluded UNLESS losing (SEE < 0), which are admitted. This
+ was a deliberate widening from the old EFP's blanket capture
+ exemption: a losing capture is character-wise much closer to a bad
+ leftover move than a good one, and `iMoveSee` is already computed for
+ exactly this class of move at shallow depth (`uDepth < THREE_PLY`),
+ so admitting it costs nothing extra. Caveat: `iMoveSee` defaults to 0
+ (not a real SEE value, treated as "non-losing") for captures outside
+ that depth range where `GetMoveSee` never actually ran -- a
+ non-issue for the current depth-1-9 beachhead, but worth remembering
+ if the table's depth range is ever pushed further out without also
+ extending where SEE gets computed.
+
+The first data-collection pass (before this fix) trained on the
+unrestricted leftover population and showed real cells with FH% up to
+20-30% at aggressive-looking gap/move combos -- looked alarming, until
+the `fThisMoveEFPCandidate` audit mechanism (below) showed the ACTUAL
+population reaching a table lookup in real search had real FH% around
+0.1-0.15% at a "5.0% predicted" cutoff, a 30-50x gap entirely explained
+by the population mismatch, not a flaw in the gap/depth/move signal
+itself. Re-collecting on the gate-1-restricted population removed this
+bias; the retrained table's own audit numbers then matched its
+predictions almost exactly (within ~1-2%), confirmed by comparing real
+outcomes against the volume-weighted average PREDICTED value for
+exactly the flagged population -- NOT against the raw threshold used to
+flag it, which is a different, much more permissive number and an easy
+place to fool yourself (a threshold is a ceiling on what gets flagged,
+not the typical value of what actually does).
+
+## How the data is collected
+
+Three parallel counter tables live in `chess.h`'s `COUNTERS.tree`
+(mirrored into `g_Options` for suite-level reporting), all gated behind
+`#ifdef FH_TABLE_COLLECTION` (its own opt-in `gmake` flag, NOT part of
+the standard `PERF_COUNTERS` profile -- this instrumentation measurably
+costs NPS when active on every gate-1-eligible move, and there's no
+reason to pay that cost outside a dedicated collection run):
+
+- `u64LeftoverTriesByDepthMove` / `u64LeftoverFHByDepthMove` -- raw
+ move-number x depth, `[65][64]`.
+- `u64LeftoverTriesByDepthMoveFrac` / `...FHByDepthMoveFrac` -- move
+ FRACTION x depth, `[65][20]`. Watch out: `MOVE_COUNT(ctx, ctx->uPly)`
+ can be 0 when the hash move is tried before this ply's moves are
+ generated (a distinct search stage) -- skip the frac increment
+ entirely in that case, don't clamp/floor it. An earlier version used
+ a `MAXU(1, ...)` floor instead of skipping, which silently dumped
+ every such move into the "95-100% exhausted" bucket regardless of its
+ real position, corrupting it with a huge, spuriously-low-FH%
+ population -- caught by a smoke test where that one bucket had
+ 20-100x the sample count of its neighbors. **Always smoke-test a new
+ counter design on a tiny run before trusting a big collection pass.**
+- `u64LeftoverTriesByDepthMoveGap` / `...FHByDepthMoveGap` -- the full
+ 3-way table, `[65][64][32]`. This one's cell count is ~65x the other
+ two combined, and printing it in full (even just the compact
+ `FHGAPRAW` reassembly line, not a human-readable duplicate) produces
+ tens of thousands of lines per checkpoint on a real collection run --
+ budget log volume accordingly (this box's `/tmp` is a 30GB tmpfs;
+ don't let it fill during an unattended run).
+
+Reassembly across shards: each shard's raw output is greppable for a
+`FHRAW`/`FHFRACRAW`/`FHGAPRAW` line format
+(`TAG depth move [gap_bucket] fh_count tries`), one line per populated
+cell, printed at the end of a `script` run via `_PrintSuiteReport`
+(`script.c`). **Periodic checkpointing matters for anything that runs
+long enough to risk a timeout**: `ScriptCommand`'s position loop
+re-prints the full (cumulative, not delta) report every
+`CHECKPOINT_INTERVAL` (currently 5) positions, not just once at the
+true end -- found the hard way, the first version of this only printed
+once at the end and a shard that missed its `timeout` budget by 0.05
+seconds lost 100% of its data. **When reassembling from checkpointed
+output, take the LAST checkpoint block in a shard's log, never sum
+every occurrence** -- each checkpoint is a full cumulative snapshot,
+not a delta; summing multiple checkpoints double- (or 18x-) counts.
+
+A second, unrelated gotcha in the checkpoint counter itself: it used to
+key off `g_SuiteCounters.uTotal`, which is gated by `WeAreRunningASuite()`
+-- true only when the current position has a recorded `solution`/`avoid`
+move. Plain `setboard`/`go` position pools (like
+`cross_sectional_200k.ep_`, no EPD solution annotations) never
+increment `uTotal` at all, so the checkpoint silently never fired on
+that file format despite the code looking correct. Fixed with a
+separate, unconditionally-incrementing `uPositionsProcessed` counter.
+**If a checkpoint/report mechanism silently does nothing on a specific
+file format, suspect a hidden dependency on EPD solution metadata.**
+
+Position source used so far: `tests/cross_sectional_200k.ep_` -- per
+`TESTING_PLAN.md`, this pool's actual documented purpose is
+`fuzz_test.sh`'s crash-fuzzing corpus, not curated for FH-rate
+calibration specifically. No generation script for it exists anywhere
+in the repo (checked); its contents look like ordinary, plausible
+real-game positions on inspection, but that's "looks fine," not
+"verified representative" -- worth keeping in mind, and see "the
+tactical-position gap" below for why this specific pool's composition
+turned out to matter a lot.
+
+## The upcoming large sharded run
+
+Plan: run this collection sharded across many single-threaded
+(`--cpus 1` -- `split.c`'s parallel helpers are NOT wired into these
+counters, deliberately, so multi-threaded runs would silently under-
+count) processes, at real depth (the whole point of collecting deep-
+remaining-depth cells is that they're thin unless the root search goes
+substantially PAST the depth being measured -- a node with remaining
+depth R sits at ply ~(root_depth - R) from the root, and node count
+grows with ply-from-root, so `sd 6` can never produce a single
+remaining-depth-10 sample; you need `sd 16+` to make that shell
+non-trivially populated), for on the order of a week, against a large
+position sample.
+
+**Composition of that sample matters and was the subject of a real,
+expensive lesson this session (see "the tactical-position gap" below):
+mostly real-game cross-sectional positions, with a MODEST tactical
+fraction mixed in -- not a puzzle-heavy pool.** Scott's explicit framing:
+"we want an engine that plays chess, not solves tricky puzzles." A
+pool dominated by tactical puzzles would teach the table that leftover
+moves in general are riskier than they really are (puzzles are
+adversarially selected to have a non-obvious best move), producing a
+table that under-prunes in the ordinary quiet middlegame positions
+that make up most of a real game. Something like a 80/20 or 90/10
+real-game/tactical blend is a reasonable starting point to test, not a
+locked-in ratio.
+
+Practical constraints learned from the smaller collection runs already
+done:
+- Per-position cost at real depth varies enormously (34s to 300+s
+ observed at `sd 14-16` on different positions, single-threaded) --
+ don't budget wall-clock by assuming uniform per-position cost.
+ Feeding each shard a generous position POOL (more than it's expected
+ to finish) and bounding by wall-clock `timeout` rather than position
+ count means the run naturally uses whatever time is available without
+ needing to guess position count in advance.
+- The `/tmp` tmpfs on this box is shared and has been observed at 99%
+ full from OTHER processes mid-session (not our own files) -- don't
+ assume a `du`-based capacity check at the start of a run is still
+ valid hours later.
+- A stray, unrelated process (an interactive `xboard -fcp ./typhoon
+ -scp crafty` game, apparently left running from earlier work) has
+ shown up sharing the box's cores during a collection run. Worth a
+ `ps aux | grep typhoon` sanity check before assuming all available
+ cores are actually free.
+
+## What the data is FOR: EFP == LMP, LMR as a lower-risk fallback
+
+Once gate-1 is passed, the joint table gives a real `P(fail-high)`
+estimate. Two consumers, deliberately sequenced -- prune first (since
+it was the smaller, more contained change), reduction later:
+
+**EFP/LMP (unified)**: skip the move outright if predicted FH% is below
+some `epsilon_prune`. Irrecoverable, so `epsilon_prune` needs to be very
+conservative and, critically, should be picked from a real measured
+precision/aggressiveness curve (sweep several candidate thresholds
+through the SAME audit mechanism used to validate the table, plot real
+FH% and candidate volume against each), not guessed. Runtime-tunable
+via `set EFPCandidateThresholdTenths <tenths-of-a-percent>` specifically
+so this sweep doesn't require a rebuild per value.
+
+**LMR (future work, not yet touched this session beyond discussion)**:
+the SAME table, but as a reduction-amount driver instead of a binary
+skip -- `reduction_plies ~ f(-ln(predicted FH%))`, monotonically more
+aggressive the safer the cell looks. The key asymmetry that justifies a
+MORE permissive threshold for reduction than for pruning: a reduction
+is recoverable in (at least) the direction we can detect -- the
+existing "research deeper if history pruning failed" path already
+re-verifies a reduced move whose shallow search unexpectedly raises
+alpha. What it does NOT catch is the other direction: a reduced search
+that fails low, silently accepted, with nobody ever checking whether
+the FULL-depth search would have found a raise there instead. Built an
+audit for exactly this (`LMR_AUDIT` build flag, its own opt-in profile
+since it costs a real extra `Search()` call per audited candidate, not
+just a counter increment) -- for a move that got reduced and then
+failed low at the reduced depth, re-run the SAME scout window at full
+(reduction-undone) depth and check if THAT raises alpha. First real
+numbers (small sample): miss rate 4-10% at remaining depth 2, dropping
+under 1% at depth 3-4, then creeping back up to 3-7% at depth 5-6 -- a
+non-monotonic, "high-low-high" shape that wasn't the expected smooth
+decay and hasn't been investigated further yet.
+
+Both consumers, plus per-move veto signals (below), sit on top of the
+same gate-1-gated table lookup -- this is the concrete sense in which
+EFP and LMP "are the same thing now."
+
+## Per-move veto signals: two tried, both hit cliffs, no smooth middle ground
+
+The idea: once the table flags a move as a pruning candidate, consult a
+cheaper-or-not-much-more-expensive per-move signal as a final veto,
+since the table only knows the aggregate risk for this (depth, move,
+gap) CELL, not anything about this specific move's own tactical merit.
+Both signals tried this session are audit-only-first, real-pruning-
+second, and both turned out to be effectively binary rather than a
+smooth dial:
+
+1. **`ComputeMoveScore` veto, Heinz-style depth-scaled margin.**
+ Generalized the old two-tier `VALUE_KNIGHT`(1.75ply)/`VALUE_ROOK`
+ (2.75ply) activation margin to a linear scale across the whole
+ depth-1-9 range (`uEFPVetoMarginBase + (depth-1) *
+ uEFPVetoMarginPerPly`, both runtime-tunable). Swept the margin from
+ 0 up to 300/200: below ~(200-300, 100-150) nothing gets rescued at
+ all; at (300, 200) every one of 5 known-loss positions gets rescued,
+ but node count on `ecm_ringers` comes back to within 0.6% of the
+ ORIGINAL unpruned baseline -- i.e. that setting is functionally "never
+ prune," not a real middle ground. **Retired this veto** in favor of
+ (2) below; not worth resurrecting without a fundamentally different
+ margin shape (this was linear-in-depth; something with real curvature
+ might behave differently, untested).
+
+2. **`GetMoveFailHighPercentage` veto** (per-move-IDENTITY history,
+ `dynamic.c`'s `g_FailHighs` table, keyed by `mv.uMove & 0xFFFFF` --
+ near move-exact, not just piece+destination). Exempt a flagged
+ candidate from pruning if it has real, evidenced history of failing
+ high: `(uFHAttempts >= MinSamples) && (uFHPct > Threshold)`.
+ **Critical, easy-to-miss fact about this table's lifecycle**: every
+ cell resets to `0x00010001` (1 attempt, 1 fail-high = a 100% FH-rate
+ PRIOR), not zero, on `ClearDynamicMoveOrdering()` -- which fires on
+ `setboard`/`new` (`PreGameReset`'s callers), i.e. **once per position
+ in a suite run, but only once per GAME in real play, never between
+ moves.** At the most permissive setting tested (`MinSamples=1,
+ Threshold=0`), the veto protects EVERY move by default until real
+ accumulated failures (only `_RecordMoveFailure` ever touches these
+ cells during a normal search; it increments the attempts denominator
+ without touching the fail-high numerator, diluting the percentage
+ toward 0 -- reaching an integer-truncated 0% takes on the order of
+ 100+ observed failures of that exact move-identity within the
+ current search) prove a specific move safe. This is NOT "smart
+ triage using real evidence" so much as "protect nearly everything
+ by default, until proven otherwise" -- confirmed by measuring the
+ actual prune rate among flagged candidates: **70-80% of everything
+ the table flags gets vetoed and fully searched anyway**, at every
+ depth checked. That's a real, quantified NPS cost for zero node
+ savings on the majority of "candidate" evaluations.
+ Swept `(MinSamples, Threshold)` away from the extreme (1,0) setting
+ -- same cliff pattern as (1): every tested combo away from (1,0)
+ lost 4-5 of 5 known-loss rescues while only partially reducing the
+ waste rate; none beat "just tighten the table threshold with no veto
+ at all" (which alone recovers exactly 1 of 5, `ECM.272`, the one
+ case that was ever a pure threshold-calibration problem rather than
+ a population/data problem).
+
+**Open question, not yet resolved**: does this veto's real-game behavior
+degrade over the COURSE of a single game, given the table never resets
+mid-game? Live self-play match data shows the candidate's average-
+search-depth advantage over the unmodified baseline shrinking as the
+match's game count grows (within one fixed-configuration match:
++1.09 ply at 30 games, +0.57 at 60, +0.49 at 300) -- consistent with,
+though not yet proven to be caused by, staleness accumulating within
+individual games as they get longer. Needs a direct test: instrument
+depth-gain (or the candidate/actually-pruned ratio) as a function of
+MOVE NUMBER WITHIN a game, not just averaged across whole games, using
+actual game logs from a running match rather than inferring it from the
+aggregate trend.
+
+## `sd` vs `sn` vs real games -- and getting this backwards cost real time
+
+**`sd N` (fixed depth) is structurally the WRONG test for anything that
+trades tree size for depth.** At equal depth, a leaner-tree change can
+only ever cost solves relative to baseline -- it never gets credited
+for the depth it's supposed to buy, because the test held depth fixed
+instead of letting the efficiency convert into extra plies. This
+session ran an expensive round of `sd10` suite comparisons that showed
+what looked like a severe regression (14 net lost solutions across two
+curated suites) before catching that the test itself was the problem,
+not (only) the mechanism: re-run at `sn 5000000` (fixed node budget,
+the fair comparison, since it lets the efficiency actually convert into
+depth the way it's supposed to), the SAME positions and SAME build
+showed net +6 solved across all three curated suites with deeper
+average search depth everywhere. **Any test of a pruning/reduction
+change needs to specify which of these three it's using and why**:
+`sd` for "does this change the tree SHAPE at fixed effort" (the right
+question for judging LMR/EFP's aggressiveness in isolation), `sn` for
+"does this deliver more accuracy at fixed COST" (the right question
+for judging whether a leaner-tree change is worth it), real self-play
+games for "does this actually win more, accounting for everything
+neither synthetic test can capture" (time-control-driven node budgets
+that vary naturally by position, and the compounding effect of
+thousands of small decisions across a whole game rather than a handful
+of curated positions).
+
+Related, and worth its own callout: **curated ECM suites have real,
+documented noise** (see `tests/ECM.md`'s own "flaky/garbage" catalog --
+oscillating PVs, near-0.00 scores, single-line coin-flips). A ±1-2
+solve swing on a 90-position suite, or any result on the 11-position
+`ecm_ringers`, is not strong evidence on its own. Chasing a specific
+named position's recovery (the 5-position known-loss set used
+throughout this session's veto sweeps) can actively mislead: the
+0.5%-threshold configuration that achieved a PERFECT 5/5 recovery on
+that named set actually performed WORSE in full-suite aggregate (net
++2) than the 1.0%-threshold configuration it was being compared against
+(net +6) -- optimizing against a small, hand-picked set pulled toward a
+setting that was worse on the broader, still-imperfect-but-larger
+sample. The named positions are useful as a fast smoke test for "did
+this obviously break something we already know about," not as an
+optimization target.
+
+## The tactical-position gap: why this matters more than it first looked
+
+The first live-pruning attempt (table threshold 1.0%, no veto) showed a
+real, `ECM.md`-confirmed regression: legitimate tactics lost, including
+`ECM.272` -- a position independently, manually re-verified from scratch
+this same session (traced the exact sacrifice line, confirmed our own
+engine solves it cleanly and stably from depth 7, confirmed by an
+independent Crafty search converging to the same move) as a genuine,
+non-flaky tactic. Root cause, confirmed by a threshold sweep specific to
+the 5 known-loss positions: tightening the table threshold all the way
+to 0.1% (10x tighter than the default that failed) rescued exactly 1 of
+5 (`ECM.272`); the other 4 stayed wrong regardless. **That's the tell
+that it's a population/training-data problem, not a threshold-
+calibration problem** -- if tightening 10x doesn't move 4 of 5 cases at
+all, the table's own predicted value for whatever cell is involved is
+wrong, not just permissively thresholded. `cross_sectional_200k.ep_`
+(real games only) apparently underrepresents the "quiet-looking but
+critical" move shape tactical positions are specifically curated to
+contain, enough that the table confidently mispredicts it. This is the
+direct motivation for mixing a modest tactical fraction into the next,
+larger collection pass (see above) -- not to make the engine solve
+puzzles better, but because the training population needs to actually
+contain examples of the shape it's being asked to judge.
+
+## Status as of this write-up
+
+- Real pruning is OFF pending resolution of the open questions above
+ (in-game staleness, and the not-yet-attempted ECM-augmented
+ retraining). What's live right now (via `EFPCandidateThresholdTenths`/
+ `EFPFHVetoMinSamples`/`EFPFHVetoThreshold`, all runtime-settable) is
+ the most conservative configuration tested (0.1% table threshold,
+ `(1, 0)` veto), currently running as `candidate` in an ongoing SPRT-
+ style self-play match against unmodified `head` -- last checkpoint:
+ 300 games, score 0.473, llr -0.50 (inconclusive, hasn't crossed either
+ bound; trending toward parity after an unfavorable dip at 60 games
+ that looks in hindsight like small-sample noise, consistent with the
+ "ECM suites are noisy, don't over-read small samples" lesson above).
+- `FH_TABLE_COLLECTION` build flag exists and works; a real, week-long,
+ larger, tactically-augmented collection run is the planned next major
+ step, informed by everything above.
+- The `ComputeMoveScore` veto is retired (dead end, see above); the
+ `GetMoveFailHighPercentage` veto's mid-game staleness question is
+ open and needs direct instrumentation, not just inference from match
+ trends.
+- LMR (reduction) work has not actually started yet beyond the
+ `LMR_AUDIT` miss-rate measurement infrastructure and this discussion
+ of how the risk threshold should differ from pruning's -- next in
+ line once the pruning side's open questions are resolved.
+
+---
+
+# Update, 2026-09-27/28: table-driven EFP goes fully live, two real
+# bugs found and fixed, self-play verdict is "roughly even"
+
+Continuation of the above, same overall project. This section covers
+what changed from "status as of 2026-09-26" (real pruning OFF,
+conservative audit-only config) through making it genuinely live,
+finding and fixing two real bugs via self-play (not suites), and the
+honest current verdict.
+
+## Old EFP fully replaced, not unioned
+
+Early in this stretch, discovered the old margin-based EFP block and
+the new table-driven block were BOTH live simultaneously (unioned via
+a shared `fThisMoveEFPPruned` flag) -- an artifact of iterating on the
+veto experiment without reconciling the two blocks. Fixed: old EFP
+wrapped in `if (FALSE && ...)`, permanently disabled; the table-driven
+mechanism is now tested as a genuine replacement, not an addition.
+
+Also added, matching old EFP's own behavior that the table-driven
+block had been missing: explicit `NODE_PV != pi->eNodeType` exclusion,
+and the losing-capture admission switched from a depth-gated local
+`iMoveSee` (only computed for `uDepth < THREE_PLY`, silently
+defaulting to "non-losing" outside that range) to the cached
+`MVF_SEE_KNOWN`/`MVF_SEE_NONNEGATIVE` move-stack flags `generate.c`
+already sets unconditionally at generation time for every capture/
+promotion -- correct at every depth, not just near the horizon.
+
+**Also discovered `ComputeMoveScore` is a no-op for quiet/leftover
+moves** under `MOVE_SCORING_NORMAL` (always returns 0) -- meaning old
+EFP's real selectivity, historically, came ONLY from its coarse
+depth/margin gate plus the killer/FH-history exemptions, never from
+any actual per-move tactical read. Worth remembering next time
+something is attributed to "EFP's margin test was smart about this
+move" -- it wasn't; it was smart about depth alone.
+
+## Depth-scaled threshold, not a flat one
+
+A flat FH% threshold applied uniformly across the whole prune-eligible
+depth range is structurally wrong: old EFP only ever operated within
+~1-2.75 ply of the horizon, where a wrong prune costs little (a small,
+bounded subtree). This mechanism's point is to extend pruning deeper,
+where a wrong prune is far more expensive (an exponentially larger
+subtree). `_EFPThresholdForDepth()` (search.c) interpolates
+GEOMETRICALLY (not linearly -- the two endpoints span orders of
+magnitude) between `uEFPThresholdShallowHundredths` (depth 1,
+permissive -- old EFP's own real gate tolerated FH% up to the tens of
+percent at 1-2 ply and still won) and `uEFPThresholdDeepHundredths`
+(at `uEFPMaxPruneDepth`, near the table's measurement floor).
+
+## `MoveLooksDumb`: from boolean, to magnitude bucket, to the joint table
+
+A new 4th signal, alongside depth/move/gap: does this quiet move
+statically look like it hangs material? Went through several rounds:
+
+1. **First attempt**: three OR'd sub-checks (a "reversal" move, a
+ pawn-attack, an ignored-hanging/trapped-piece read). Isolation
+ testing found only the pawn-attack idea behaves as intuition
+ predicts. The other two were retired for real, understood reasons,
+ not just "didn't help": the reversal check had a genuine indexing
+ bug (compared against the WRONG ply's move, `uPly-2` instead of
+ `uPly-3`) that masked a deeper conceptual problem once fixed --
+ reversal moves measure BACKWARDS (higher FH%, not lower), because a
+ reversal responds to the opponent's own intervening move and is
+ often a genuine correct reaction, not a wasted tempo. The
+ hanging/trapped-piece check also measured backwards: `cEnprise`/
+ `cTrapped` are written by the same parent `Eval()` call that
+ produces `iEval` (the gap axis's own input) -- a real confound with
+ gap, not independent signal.
+2. **Second attempt** (kept, still live): "does this move hand
+ material away for no reason" -- unconditionally for a strictly
+ cheaper attacker (pawn attacks any non-pawn; minor attacks a rook;
+ minor or rook attacks a queen), and conditionally for a
+ queen-vs-queen "undefended" case discussed and then REMOVED below.
+ Tried and retired: an equal-value-attacker-AND-zero-defenders test
+ for minors/rooks -- structurally broken, because
+ `pos->bbMinorAttacks[uMover]`/`bbRookAttacks[uMover]` (computed
+ PRE-move) already includes the mover's own pre-move reach to its
+ destination square (that's what makes the path legal), so "zero
+ defenders" was almost never true with multiple minors/rooks per
+ side and no cheap way to tell "the piece that just left" from "a
+ different piece of the same type still defending." The QUEEN case
+ sidesteps this because there's normally exactly one queen per side
+ (gated on `FastCountBits(bbPieces[uMover][QUEEN]) == 1`).
+3. **Boolean -> magnitude bucket**: "dropping a bishop to a pawn" and
+ "dropping a queen to a pawn" were both just `TRUE` before, throwing
+ away real information (a ~28,000x FH% swing across gap buckets
+ alone says magnitude matters). `MoveLooksDumbLossCp()` now returns
+ an approximate centipawn loss; `MoveLooksDumbLossBucket()`
+ coarsens it into `DUMB_LOSS_BUCKETS` (chess.h, =6) piece-shaped
+ tiers.
+4. **A real UnmakeMove-ordering bug**, caught before it shipped: the
+ FH-side counter increment originally called `MoveLooksDumbLossCp`
+ a SECOND time, after `UnmakeMove` had already restored the board
+ and flipped side-to-move -- silently classifying a different
+ position. Produced an impossible >100%-FH cell in testing. Fixed by
+ computing the dumb bucket ONCE (before `UnmakeMove`) and reusing
+ the stored value on both the tries-side and FH-side counters.
+5. **Joint (4D) table**: built `(depth, move, gap, dumb)` jointly, not
+ just as two separate marginal cuts, to test whether "dumb"
+ compounds with "gap" rather than being redundant. Confirmed real
+ compounding (e.g. depth 3, gap bucket ~4: dumb=0 -> 0.948% FH,
+ dumb=4 (post-coarsening numbering) -> 0.008% FH, a ~120x reduction
+ at 3.2M samples) -- this justified keeping dumb as a real 4th axis
+ instead of folding it into gap.
+
+### Queen-vs-queen removed outright (2026-09-27, this session's tail)
+
+The queen-vs-queen-undefended sub-check (originally bucket 5, ~975cp
+loss) was the one case that never behaved like the rest. Direct
+analysis of the 45-minute joint-run data: marginal FH% by dumb bucket
+was 0.057%/0.072%/0.098%/0.010% for buckets 1-4, but **0.457% for
+bucket 5 -- higher than "not dumb" (0.335%) baseline**, and this
+persisted (0.0147% vs. 0.0195% baseline, still elevated relative to
+bucket 4's 0.0045%) even after controlling for the gap axis. Root
+cause suspected but NOT confirmed: `_WhoAttacksSquareBB` (see.c) has
+no pin/legality awareness, so a pinned enemy queen still "shows" as
+attacking. Rather than ship a signal nobody understands, **the
+queen-vs-queen check was deleted from `MoveLooksDumbLossCp` entirely**
+(search.c), not just excluded downstream -- "losing a rook to a pawn
+is never a good idea" is a claim we can defend; "queen attacked by
+queen looks safe to prune" turned out not to be, for reasons still
+unexplained. `MoveLooksDumbLossBucket()` still has 5 tiers (0=not
+dumb, 1-4 = increasing loss magnitude: ~200/~400-475/~675/~875cp);
+bucket 5 (975cp) is simply unreachable now. `DUMB_LOSS_BUCKETS` stays
+at 6 in chess.h so the baked tables (sized `[...][6]`) don't need
+reshaping -- the top slot is just always zero.
+
+**A bug caught during this edit, worth remembering**: the first pass
+at simplifying `MoveLooksDumbLossBucket()` collapsed the 675cp and
+875cp tiers into one bucket, based on an incorrect assumption that
+removing the queen-vs-queen check also removed the 875cp value. It
+didn't -- 875cp (a queen attacked by a pawn) is still produced by the
+generic, unconditional pawn-attack branch at the top of the function,
+which was never touched. Caught by rereading the actual reachable
+value set before trusting the "simplification."
+
+## Real pruning: Tier 1 / Tier 2, then unified into one mechanism
+
+Two designs were tried, in order:
+
+**Tier 1 + Tier 2 (retired)**: Tier 1 = percentage-curve pruning,
+capped at `uEFPMaxPruneDepth`. Tier 2 = `JointMoveNeverFailsHigh()`
+(data.c) -- prune unconditionally at ANY remaining depth when the 4D
+joint table shows a confirmed zero fail-high over a large-enough
+sample (rule-of-three statistical confidence), EXCLUDING dumb buckets
+0 (not dumb -- this table says nothing about ordinary moves) and 5
+(queen-vs-queen, even before it was removed from the signal entirely).
+Audited before trusting: found real misses at depth 3-4 even at
+`MinSamples=10000` -- one exact miss traced to a genuine, deliberate
+queen sacrifice (`4rrk1/6p1/p7/1p1Np3/1bq1P3/7Q/PPP5/1K1R2R1 w - - 3 0`,
+`Qh6`) near the enemy king, not a bug. Added `Tier2MinDepth=5` after
+confirming clean audits at depth >=5 across all three curated suites.
+
+**Unified (current, live)**: per explicit direction ("I think we
+should not have tier1 and tier2 ... prune everywhere again but use a
+graduated threshold" -- then, when the deep end of the percentage
+curve flatlined at its representable floor, "I don't want 0.01%. I
+want 0 FHs ever seen with >5k positions, >10k positions, etc"):
+
+```
+uCandDepth = uDepth / ONE_PLY
+if (uCandDepth < g_Options.uEFPMaxPruneDepth):
+ prune if PredictedFHPercentHundredths(...) < _EFPThresholdForDepth(uCandDepth)
+else:
+ prune if ConfirmedZeroFailHigh(uCandDepth, uMoveNum, gap, dumbBucket)
+```
+
+`ConfirmedZeroFailHigh()` (data.c) requires a literal zero observed
+fail-high over a sample count that DOUBLES each ply past
+`uEFPMaxPruneDepth` (base `g_Options.uZeroConfBaseSamples`, default
+5000 at the boundary depth, 10000 the next ply, 20000 the next, ...).
+**As of 2026-09-27 this reads the same 4D joint table Tier 2 used**
+(`g_uJointTries`/`g_uJointFH`), not a separate gap-only marginal --
+see "the self-play regression and its real cause" below for why that
+change was necessary.
+
+## The self-play regression and its real cause
+
+First live self-play run of the unified design (30 games): **-8=21+1,
+score=0.383, llr=-0.42** trending firmly toward reject, with `ebf`
+WORSE for candidate (3.590 vs head 3.522) and candidate searching
+SHALLOWER (12.87 vs 12.92 avg depth) at similar nps -- exactly
+backwards from what a working pruning mechanism should show. This was
+"clearly a bug, not just an aggressive tradeoff" territory, matching
+Scott's own read: "I think there's a bug in the pruning impl."
+
+An advisor consultation (mid-investigation) proposed a structural
+theory -- pruning removes move-ordering/TT information that's
+load-bearing elsewhere in the tree, and this cost scales with how deep
+the prune fires, which old EFP never paid because it stayed within
+1-2.75 ply of the horizon. That's a real, general risk (see "on
+reduction vs. pruning" below), but a direct code audit found something
+more concrete and immediately actionable: **`ConfirmedZeroFailHigh`
+was reading `g_uGapRawTries`/`g_uGapRawFH` -- the (depth, move, gap)
+marginal SUMMED OVER THE DUMB AXIS.** A cell could read "zero FH in
+5000+ tries" purely because ordinary, non-dumb moves dominated that
+cell's volume, while a rare queen-hanging move sharing the exact same
+(depth, move, gap) cell had never actually been safely tested on its
+own. Fixed by switching `ConfirmedZeroFailHigh` onto the 4D joint
+table (adding a `uDumbBucket` parameter, same table
+`JointMoveNeverFailsHigh` already used), refusing to trust a zero read
+for the queen-vs-queen bucket outright (matching Tier 2's own
+caution, at the time this bucket still existed).
+
+**Result, same match restarted after the fix (60 games total, 30 new
+under the fixed binary)**: ebf flipped from "candidate worse" to
+EXACTLY TIED (3.619 = 3.619), and average depth flipped from
+"candidate shallower" to "candidate deeper" (13.05 vs 12.81) -- the
+signature of a real fix, not noise. `g_uGapRawTries`/`FH` and the
+`GAPRAW_*` macros are now fully dead code, left in place (unused) at
+the time of this write-up but candidates for deletion.
+
+**The `g_uGapRawTries`/`g_uGapRawFH` tables were later deleted
+entirely** (2026-09-27, same session) once the gap-bucket rework below
+made keeping them around pointless.
+
+## Quantifying "how much statistical confidence is actually needed"
+
+A real question raised directly: given a per-cell "confirmed zero"
+read only bounds the true rate via rule-of-three (~3/N for N samples),
+and this gate fires many times per real game, how much does that
+matter? Worked from real counters, not guesses:
+
+- Real `sd10` counters (post-fix): ~303 actual deep-zone (depth >=6)
+ prunes per 31.3M nodes searched, i.e. ~1 per 103,000 nodes.
+- Scaled to a real blitz budget (1.5M nps x 350s ~= 500M nodes/game):
+ **~4,800 deep-zone prunes per game.**
+- Rule-of-three bound at the current sample bar (5000 at depth 6,
+ doubling deeper): expected WORST-CASE misses per game ~= 2 (not
+ thousands -- an earlier back-of-envelope estimate using a wrong,
+ much larger node-count assumption was corrected in-session).
+- To hit a "1 unlucky game in 1000" target at that per-game
+ application volume: `required_N ~= 3 * applications_per_game /
+ target_misses_per_game ~= 3 * 4800 / 0.001 ~= 14.4 million` samples
+ in the specific cell being trusted, not 5,000-40,000.
+- **Brute-force collection to reach that is not viable**: the
+ 45-minute run put ~5,000 samples into a representative deep cell:
+ getting to 15M is ~3000x more volume, i.e. ~94 days wall-clock even
+ running all 16 shards this box supports in parallel (already at the
+ ceiling -- more shards doesn't help past core count). Confirmed and
+ accepted as a dead end in-session.
+
+**2026-10-01 correction: the "1 unlucky game in 1000" target itself was
+never validated, and the whole framing here was too conservative.**
+Scott's direct pushback: nobody decided 1/1000 was the right number --
+it was picked, not derived -- and 1/100 or even 1/20 is reasonable. Two
+things compound this:
+
+1. **Not every prune mistake costs the game.** A silent miss (we prune
+ a line that would have fail-highed) only matters if that line
+ actually would have changed the game's outcome -- a strict subset of
+ "a miss occurred." The rule-of-three math above bounds the rate of
+ MISSES, not the rate of GAMES ACTUALLY LOST to a miss, so even the
+ loosened budgets below are still conservative relative to the thing
+ that actually matters (Elo).
+2. **The deeper problem: this section was optimizing the wrong
+ quantity.** It treats "minimize miss probability until some arbitrary
+ confidence bar" as the goal in itself. The actual goal is net playing
+ strength: aggregate nodes saved by pruning/reducing get reinvested as
+ extra search depth elsewhere, and depth finds tactics -- that's a real
+ benefit that has to be weighed against the real (but apparently
+ small, see "the self-play regression" and the LMR self-play match
+ above) cost of occasional misses. A `g_uJointFHThousandths`-driven
+ gate exists precisely so this tradeoff can be made *intelligently*
+ (deeper/leaner where the data says it's safe-ish) rather than via a
+ single global, hand-picked confidence bar -- but picking that bar by
+ "make misses astronomically rare" short-circuits the whole exercise
+ and defaults back to maximal conservatism regardless of what the
+ table actually knows. The open comparison against Crafty (much
+ leaner trees, routinely 7-8 plies deeper at equal node budgets) is
+ the concrete signal that this project has been on the over-cautious
+ side of that tradeoff.
+
+Recomputed the same formula at looser budgets, same 4,800
+applications/game assumption:
+
+```
+1/1000 games -> N ~= 14.4M (0 deep-zone zero-FH cells clear this, as of this run)
+1/100 games -> N ~= 1.44M (0 deep-zone zero-FH cells clear this -- largest is 1.34M, close)
+1/20 games -> N ~= 288K (16 deep-zone zero-FH cells clear this TODAY, no more collection needed)
+```
+
+**Decided direction, not yet executed**: stop treating the
+rule-of-three sample bar as the primary lever at all. Keep it as a
+floor against outright garbage (near-zero-sample cells), but the real
+experiment going forward is to try materially more aggressive
+settings on both mechanisms -- the 1/20-budget prune bar (288K, usable
+immediately) and reduce tiers picked to target real extra depth (aim
+for whole plies, not the +0.3 ply the first p75/p25/p5 self-play run
+produced) -- and read the result by playing strength and search depth
+in self-play, not by how rare a miss is in isolation. The right
+long-term framing: **the actual metric to tune against is net Elo
+(or a cheap proxy -- depth/nps/ebf movement under a fixed node budget),
+with the FH% table used to bias WHERE cuts happen, not to justify an
+arbitrarily tiny global miss rate.**
+- **What DOES help, cheaply**: coarsening the bucket grid concentrates
+ the same collected volume into fewer, denser cells -- see next
+ section. This buys real headroom without any extra collection time.
+
+## Open gap, 2026-10-01: compounding errors under reinvested depth is unmeasured and unmeasurable by anything built so far
+
+Every statistic gathered for this project so far -- raw FH%, the
+`JOINT_EXTRA_VERIFY` silent-miss rate, the rule-of-three sample bound
+above -- is a SINGLE-DECISION statistic: it characterizes the chance
+that one gate, at one node, misjudges. None of it says anything about
+what happens once that misjudgment is embedded in a continuation that
+is then searched DEEPER, specifically because the nodes saved by
+pruning/reducing elsewhere get reinvested as exactly that extra depth.
+
+Scott's framing: we have no information about what happens if we prune
+wrong ~5x/game and reduce wrong such that a reduced-depth search
+fails low where a full-depth search would have failed high, AND in
+doing so we bought ourselves ~2 extra plies per move on average --
+because searching those 2 extra plies means the SAME gate fires again,
+2 more times, deeper under the same kind of position, with more real
+tree beneath each repetition.
+
+**Why this could be qualitatively worse than the per-decision rates
+suggest, not just "the same risk, more often":** if the 4-axis model's
+misjudgments were i.i.d. noise, compounding wouldn't be very dangerous
+-- a short chain of independently low-probability errors is still
+low-probability, same as any noise process. But this project has
+already found the model has CORRELATED, structured blind spots, not
+just noise: the queen-vs-queen dumb-bucket (removed outright, see
+above -- "safe" reads that weren't, for reasons still not fully
+understood) and the null-move-bailout signature bug (silent-miss
+investigation, see above) were both systematic, not random. Reinvesting
+saved nodes as depth specifically UNDERNEATH the branches just judged
+"safe" means that if a blind spot like those exists, extra depth
+doesn't dilute it with more search -- it drills further into exactly
+the part of the tree most likely to share it.
+
+**This cannot be measured by instrumentation built so far, even in
+principle.** `JOINT_EXTRA_VERIFY`'s silent-miss rate and the rule-of-
+three bound both describe P(one gate misfires once); there is no way
+to decompose an aggregate self-play score back into "cost from
+compounding errors under reinvested depth" vs. "benefit from genuinely
+sound extra depth" -- they are inseparably confounded in that single
+number by construction. Per-node instrumentation was always going to
+be structurally blind to this, no matter how much more data is
+collected at the per-cell level.
+
+**Practical implication for the next (more aggressive) self-play run**:
+don't just read the final SPRT score. Pull the actual lost/drawn games
+under the aggressive settings and check whether losses cluster around
+a recognizable, repeated position type (a live blind spot, same shape
+as queen-vs-queen) versus being spread uniformly (ordinary noise at a
+higher-variance operating point). `SILENT_MISS_TRACE`'s existing
+FEN/move/score logging is most of the needed infrastructure already --
+it would need to stay attached through to which game a flagged miss
+occurred in (not just logged as an isolated per-node event) to make
+this check possible. Not yet built; flagging as the concrete next
+instrumentation gap before trusting a bolder self-play result as
+"just noise" either way.
+
+## Gap-bucket redesign: 32 uniform bins -> 6 piece-value-aligned bins
+
+Per direct instruction ("Why do we have 32 gap buckets?! I think they
+should be like: alpha-100, alpha-300, alpha-500, alpha-900,
+alpha-1500, worse than that"), `FH_GAP_BUCKETS` (chess.h) went from
+32 uniform 50cp-wide bins (`FH_GAP_BUCKET_MIN=-200`,
+`FH_GAP_BUCKET_WIDTH=50`) down to 6:
+
+```
+bucket 0 : gap < 100
+bucket 1 : 100 <= gap < 300
+bucket 2 : 300 <= gap < 500
+bucket 3 : 500 <= gap < 900
+bucket 4 : 900 <= gap < 1500
+bucket 5 : gap >= 1500
+```
+
+Rationale: the fine 50cp resolution was mostly wasted precision in the
+near-alpha region (highest FH%, 9%+ -- never going to pass any sane
+pruning threshold regardless of exactly which of ~6 fine bins it lands
+in) and starved precision in the tail, which is exactly where a
+pruning DECISION actually lives. A 32->6 collapse is a ~5.3x reduction
+in total cell count, i.e. a ~5.3x increase in effective samples per
+cell for the SAME collection run, for free.
+
+**`GapCpToBucket(iGapCp)` (data.c) is now the single source of truth**
+for this mapping -- previously the -200/50 arithmetic was duplicated
+inline at SIX separate call sites (two lookup functions, one
+collection increment... actually three lookup functions and three
+collection sites) with no shared helper, exactly the kind of
+duplication that drifts silently. Every lookup
+(`PredictedFHPercentHundredths`, `JointMoveNeverFailsHigh`,
+`ConfirmedZeroFailHigh`) and every collection increment (search.c, two
+sites) now calls this one function.
+
+**Both baked tables were rebuilt from scratch** using this new
+bucketing, sourced from the SAME 45-minute joint-run pickle
+(`reassembled_joint.pkl`'s `gap` marginal for the percentage table,
+`joint` dict for the 4D table) -- this also fixes a real, previously
+flagged inconsistency: the percentage table used to be baked from an
+EARLIER, smaller, differently-composed collection run
+(`reassembled_gate1.pkl`) than the joint table, meaning the two halves
+of the "one seamless mechanism" disagreed about what population they
+described. Now both come from one dataset.
+
+**Verified after rebake** (`sd10`, all three curated suites): solve
+counts essentially unchanged (one single-position flip on
+`ecm_confident_quick`, within normal suite noise), and candidate
+volume at depth 6-9 on that suite jumped from ~132/85/50/1 tries
+(before coarsening) to **4,177/4,603/3,150/170** (after) -- a ~30-60x
+increase in samples per surviving cell, exactly the intended effect,
+with real observed FH still 0.000% at every one of those depths.
+
+## Self-play verdict as of this write-up (2026-09-28)
+
+A fresh match (post queen-vs-queen removal + gap-bucket rebake)
+against `head_reference`, tracked over time:
+
+| games | score | llr | ebf (cand vs head) | avg depth (cand vs head) |
+|---|---|---|---|---|
+| 100 | 0.520 | +0.10 | 3.641 < 3.654 (better) | 13.21 > 12.93 (better) |
+| 170 | 0.521 | +0.16 | 3.679 < 3.685 (better) | 12.94 > 12.69 (better) |
+| 1000 | **0.479** | **-1.34** | 3.617 > 3.600 (worse) | 13.19 > 13.05 (still better) |
+
+**Honest read: the early (100-170 game) positive trend did not hold
+at 1000 games.** llr swung from +0.16 to -1.34 -- more than a third of
+the way to the H0=-2.94 reject boundary. Average search depth still
+favors candidate at every checkpoint (the mechanism genuinely is
+reaching deeper), but that extra depth is not converting into a score
+edge, and ebf has flipped to favor head at the larger sample. This is
+roughly consistent with nearly every measurement across this whole
+project once the concrete bugs (EFP/table union, dumb-axis pooling,
+queen-vs-queen) were fixed: **"even," not "clearly ahead."**
+
+This matches the advisor's structural argument from the mid-session
+consultation: pruning at depth ranges beyond old EFP's narrow 1-2.75
+ply removes move-ordering/TT information that's load-bearing
+elsewhere in the tree, and that cost doesn't fully disappear just
+because the individual prune decisions are well-calibrated. Fixing
+concrete bugs recovers lost ground (30-game catastrophe -> 1000-game
+parity) but does not, on its own, produce a clear win.
+
+**Decision point reached, not yet resolved**: either (a) let the
+current match run to a real statistical conclusion (llr crossing
+either bound) before drawing further conclusions from noisy
+checkpoints, or (b) accept "even" as pruning's ceiling at this depth
+range and pivot to reduction (see next section) instead of continuing
+to chase a clear win from harder pruning alone.
+
+## On reduction vs. pruning, as the likely next step
+
+Not yet implemented. The core argument for why reduction should not
+need anywhere near the ~15M-samples-per-cell bar pruning would need at
+a permissive threshold: **a reduction is self-correcting when wrong.**
+The standard PVS/LMR re-search machinery already re-verifies a reduced
+move whose shallow search unexpectedly raises alpha or fails high. A
+wrong prune is permanently lost; a wrong reduction costs one extra
+re-search. That changes the risk calculus completely and was always
+the second half of the original plan ("beat EFP at its own game and
+then use this table to layer on reduction with a less conservative
+threshold").
+
+Sketch discussed, not yet built: stack an additive `extra_reduction_
+plies` term onto whatever `GetLMRReduction()` already computes for a
+gate-1-eligible move, driven by `PredictedFHPercentHundredths`:
+
+```
+FH% >= 5% : +0 (no extra reduction)
+1% <= FH% < 5% : +1 ply
+0.1% <= FH% < 1% : +2 plies
+0.01% <= FH% < 0.1% : +3 plies
+confirmed zero, or
+ FH% < 0.01% : +4 plies
+```
+
+Clamped so total depth never goes below some floor, same clamping
+discipline `ComputeMoveExtension`/`GetLMRReduction` already use.
+Reuse the SAME gate-1-eligible population and the SAME table this
+whole document is about -- no new data collection needed to start
+this, only new search.c wiring at the LMR call site.
+
+## How to regenerate the tables (reproducibility notes)
+
+Both baked tables (`g_uFHPercentHundredths` and `g_uJointTries`/
+`g_uJointFH`, all in data.c) are derived from `.pkl` files produced by
+reassembling a sharded collection run's raw `FHGAPRAW`/`FHJOINTRAW`
+output lines (see "how the data is collected" above for the format).
+**These `.pkl` files and the reassembly/codegen scripts live under
+`/tmp/typhoon/` (scratch, per this repo's `CLAUDE.md` policy) and are
+NOT checked into git** -- if they're gone, the tables can only be
+regenerated by re-running collection from scratch. As of this
+write-up they still exist at `/tmp/typhoon/fh_joint_run/
+reassembled_joint.pkl` (the current, live source for BOTH tables).
+
+To regenerate from an existing reassembled pickle after a bucket/table
+shape change (what was actually done for the 2026-09-27 gap-bucket
+rework):
+
+1. Load the pickle (`pickle.load`) -- top-level dict with `joint`
+ (key: `(depth, move_idx[0..15], old_gap[0..31], dumb[0..5])` ->
+ `[fh_count, tries]`, note **move_idx here is already 0-based,
+ pre-capped at collection time** -- do NOT subtract 1 again) and
+ `gap` (key: `(depth, move_raw[1..63], old_gap[0..31])` ->
+ `[fh_count, tries]`, note **this one IS 1-based raw move number**,
+ a genuinely different convention between the two dicts that cost a
+ real debugging cycle to catch -- verify which convention a given
+ dict uses before writing a regeneration script, don't assume they
+ match).
+2. Re-bucket the gap axis using whatever the CURRENT `GapCpToBucket`
+ boundaries are (old bucket `g`'s underlying range is
+ `[-200+50g, -200+50(g+1))`; map each old bucket to whichever new
+ bucket contains that range -- boundaries were deliberately chosen
+ as multiples of the old 50cp bin width so this mapping is exact
+ except for the old table's final overflow bin, which has no
+ defined upper edge and needs a documented judgment call).
+3. Sum `[fh, tries]` for every old cell that maps to the same new
+ `(depth, move, new_gap, dumb)` key.
+4. Emit C initializer text matching the existing array declarations
+ exactly (dimension order, `// move N` / `// depth N` comments for
+ readability), then splice it into data.c replacing the old array
+ body between its declaration line and matching closing `};` (brace-
+ depth-tracked, not a fixed line-count assumption -- table bodies
+ are tens of thousands of lines and shift size on every rebuild).
+5. Rebuild (`gmake clean && gmake -j5 GENETIC=1 PERF_COUNTERS=1 MP=1
+ SIXTYFOUR=1`) and re-run the `sd10` three-suite sanity check before
+ trusting the result -- solve counts should be stable (+/-1 on
+ `ecm_confident_quick` is normal suite noise) and the EFP candidate
+ audit block's real-FH% should still read ~0.000% at depth >=
+ `uEFPMaxPruneDepth`.
+
+To regenerate from a FRESH collection run instead of an existing
+pickle: re-run the same sharded, `--cpus 1`, `FH_TABLE_COLLECTION`-
+gated collection process described earlier in this document (16
+shards, wall-clock-bounded via `timeout`, mixed real-game/tactical
+position pool), reassemble each shard's LAST checkpoint block's
+`FHGAPRAW`/`FHJOINTRAW` lines into the same pickle shape, then follow
+steps 2-5 above.
+
+---
+
+# Update, 2026-09-28/29: phase 2 (graduated LMR), a real measurement
+# bug found and fixed, EFP's per-move check confirmed dead, and the
+# "deep prune" idea
+
+Picks up where the previous section left off (reduction as the
+likely next step after pruning's self-play verdict landed at "even").
+This is a companion, largely independent table build -- a fresh 4-axis
+`(depth, dumb, gap, late)` joint table baked from a new 60-90 minute
+16-shard `sd 16` collection run (see
+`lmr_testing/2026-09-28_four_axis_joint_table_plan.md` for the full
+collection/bake recipe), `g_uJointFHThousandths[12][11][11][15]` in
+`data.c`, thousandths-of-a-percent resolution, `0xFFFF` sentinel below
+a 500-tries-per-cell floor. Same underlying idea as the EFP work
+above, applied to reduction instead of pruning, and building its own
+independent measurement infrastructure along the way.
+
+## The mechanism: strictly additive over `GetLMRReduction`
+
+Deliberately the smallest first experiment: HEAD's existing flat
+`-ONE_PLY` reduction (`GetLMRReduction`, searchsup.c) is left
+completely unchanged and still decides whether a move gets reduced at
+all. The table is only ever consulted to add MORE plies on top of a
+move already reduced -- never less, never on a move `GetLMRReduction`
+wouldn't have touched anyway. `EFP is left untouched too (including
+its known bug, see below) -- this round is measuring the LMR
+augmentation in isolation, on purpose.
+
+Tier thresholds (tries-weighted percentiles of the table's predicted-
+FH% distribution, computed against the ACTUAL LMR-eligible population
+-- `uLegalMoves > 5`, real `uDepth > TWO_PLY`, dumb bucket 1 excluded
+as ~97% SEE-scored losing captures/underpromotions misfiled onto the
+quiet-move axis, same lesson as the EFP work's dumb-bucket-1 exclusion
+above):
+
+```
+FH% >= p75 (0.072%) : +0 (no extra reduction, baseline -1 ply only)
+FH% < p75 : +1 ply
+FH% < p25 (0.005%) : +2 plies
+FH% < p5 (0.001%) : +3 plies (max)
+```
+
+`JOINT_EXTRA_MIN_REMAINING_DEPTH` floor: currently `ONE_PLY` (the
+original, most aggressive setting) after a round-trip described below
+-- briefly raised to `FOUR_PLY` out of caution, then reverted once the
+apparent danger was shown to be a measurement artifact, not a real
+safety problem.
+
+Main move loop (`search.c`) only, same as the EFP work -- not wired
+into `split.c`'s `HelpSearch`, for the same reason (per-node snapshot
+arrays with no path into a helper thread's own context; every
+comparison protocol in this project already runs `--cpus 1`, so this
+costs nothing in what can currently be validated).
+
+## The null-move-bailout measurement bug (the big one this round)
+
+Built `JOINT_EXTRA_VERIFY` (shadow-search verification: after a real
+reduced search fails to raise beta, re-run at
+`uNextDepth + tier*ONE_PLY` and check if THAT would have failed high --
+same idea as the EFP-side "audit before trusting" discipline
+throughout this whole project) and `SILENT_MISS_TRACE` (log FEN/
+scores/moves on every detected miss). First numbers were alarming: up
+to 1-11% silent-miss rates across various configurations -- a
+"reduced search fails low, hiding a fail-high the real search would
+have found" rate that would have meant this mechanism was unsafe.
+
+**Root cause, found via live gdb stepping through an actual flagged
+case, not guessed**: `TryNullmovePruning`'s (searchsup.c) intentional
+Tord Romstad idea --
+
+```c
+// This is an idea from Tord Romstad on ccc: if we are below a
+// history reduced node and we try a nullmove and the nullmove
+// fails low and the move that refuted the nullmove involves
+// the same piece moved in the reduced move at ply-1 then bail
+// out of the reduced depth search now and research at full
+// depth. ...
+if ((mv.cFrom == (pi-1)->mv.cTo) && ((pi-1)->iExtensionAmount < 0))
+{
+ ...
+ *piNullScore = iAlpha - 1;
+ return TRUE;
+}
+```
+
+deliberately FABRICATES a score of `iAlpha - 1` and returns `TRUE`
+instead of doing a real re-search, specifically because that
+fabricated value negates up to exactly `iBeta+1` one level up --
+correctly triggering the REAL search's own existing
+re-search-on-fail-high machinery. This is correct, intentional, and
+safe in the real search path. It is NOT safe inside our bespoke,
+one-off shadow-verification `Search()` call, which has no surrounding
+wrapper to catch that "please re-search me" signal -- our own
+verification code misread the fabricated fail-high as a genuine one.
+
+**Fix**: `NEUTRALIZE_NULL_BAILOUT` (GNUmakefile flag) -- temporarily
+zero `pi->iExtensionAmount` (save/restore) around just our own shadow
+`Search()` call, disabling only this one precondition for our own
+verification, leaving every other occurrence of the real mechanism
+(anywhere else in the tree, on any other move) completely unaffected.
+
+**Result**: true silent-miss rate, measured correctly, is ~0.00056%
+(~1 in 178,000) even at the most aggressive `ONE_PLY` floor -- the
+80-99.8% of "misses" the earlier numbers showed were this measurement
+artifact, not real danger. This is why the floor was reverted from
+`FOUR_PLY` back to `ONE_PLY`: the conservative retreat had been a
+reaction to contaminated data.
+
+**Lesson for next time a shadow/audit `Search()` call is built
+anywhere in this codebase**: any node-local mechanism that
+communicates results by fabricating a boundary score and relying on a
+CALLER a level up to interpret it correctly (this null-move bailout,
+and structurally anything similar) is a landmine for exactly this
+kind of one-off internal verification call. Check for this class of
+mechanism specifically before trusting a shadow-search-based audit's
+raw miss rate.
+
+## Genuine (post-fix) misses: two distinct classes, neither cheaply catchable
+
+Investigated the ~handful of genuine misses surviving the fix by hand,
+one at a time, using a fresh, independent `sd1` vs `sd8`-through-`sd12`
+comparison per position (NOT the `SILENT_MISS_TRACE` hash-chain
+`shadowRefutation` guess, which was separately confirmed unreliable --
+`GetPonderMove`'s hash-chain walk can read stale/unrelated entries
+since the hash table is global, not scoped to the specific
+verification call; one hand-traced "refutation" turned out to be a
+completely different line than what an independent `sd12` search
+actually found). Two real, distinct patterns found:
+
+1. **Narrow-window boundary noise, not a real miss.** One traced
+ example showed `realScore`/`shadowScore` swinging wildly
+ (`-500` to `+144`) at the trace's logged `alpha=143 beta=144` --
+ but independently re-searching the SAME position freely (`sd1`
+ through `sd12`, no artificial window) showed a stable, consistent
+ assessment the whole way (-3.5 to -3.75), no swing at all. The
+ trace's narrow inherited PVS window means `shadowScore >= beta`
+ only proves "clears this specific tight ancestor-relative
+ threshold," not "the position is actually good" -- a real but
+ mundane form of alpha-beta boundary noise, not a blind spot a
+ heuristic could detect or avoid.
+2. **Genuine horizon-effect quiet tactics.** A second traced example
+ (`3r1rk1/1pq1bppp/p7/n2R4/P1N3Q1/1P6/1B3PPP/R5K1 b - - 3 0`, after
+ White's `Qd4`) was real: `sd1` likes `Qd4` (+2.06), but `sd8`
+ through `sd12` all agree it loses to `Bb6` (a quiet, non-capturing
+ deflection attacking the queen -- `Qxb6 Qxd7` nets Black the
+ exchange). `Bb6` isn't a capture or check, so it's entirely outside
+ `sd1`'s horizon; this is inherent to any bounded qsearch
+ (captures/checks only), not a specific bug.
+
+**Tried and abandoned, per direct experimentation with
+`QSearchFromCheckNoStandPat`'s (search.c) check-extension gate**:
+loosened the `+2 uQsearchCheckExtension` conditions three times in a
+row (narrowed the inner OR-conditions, then removed the
+`CountKingSafetyDefects(pos, ...) > 4` outer gate entirely), rebuilding
+and re-running the SAME automated `sd1`/`sd8` batch comparison
+(224 unique genuine-miss positions) after each change. **All three
+variants showed no measurable difference from baseline** (median
+swing 1.35, mean ~2.71-2.73, same top-10 largest-swing list/PVs, ~20.5%
+sd1==sd8 move-agreement rate, every time) -- this specific lever
+doesn't touch the class of miss the genuine examples actually show
+(the `Bb6` example isn't a check, so a check-generation-budget change
+was never going to catch it). Reverted to the original condition set.
+Conclusion, by mutual agreement: this is a quirk inherent to low-depth
+search, not a systematic, cheaply-fixable blind spot -- punt on
+chasing it further.
+
+## EFP's `ComputeMoveScore` per-move check confirmed dead, quantitatively
+
+Separately (but directly relevant to "should the table eventually
+replace EFP"), traced `ComputeMoveScore`/`DecodeMoveValueEstimate`
+(searchsup.c) end to end and confirmed by code reading (not just
+inference) that EFP's per-move sanity check --
+
+```c
+(ComputeMoveScore(ctx, mv, (x - 1)) < uFutilityMargin)
+```
+
+-- is a tautology given the gates already passed at that point in the
+`if`: every quiet (non-capture/promotion) move decodes to exactly `0`
+under `MOVE_SCORING_NORMAL`/`MOVE_SCORING_QSEARCH_*`, and
+`IS_CAPTURE_OR_PROMOTION(mv)` is already excluded earlier in the same
+condition -- so this check can never evaluate to anything but `0 <
+uFutilityMargin`, which is already guaranteed true by the
+`(uFutilityMargin)` gate a few lines up. It contributes nothing; EFP's
+real (and only) per-move selectivity has always come from the
+killer-adjacency and `GetMoveFailHighPercentage` history exemptions
+below it, confirming and extending the earlier (2026-09-27) finding in
+this same doc that "EFP's margin test was never smart about the
+specific move."
+
+## Quantifying what EFP is blind to, using the new table
+
+Queried `g_uJointFHThousandths` directly over EFP's actual typical
+operating envelope (1-2 plies remaining, gap 300-500cp below alpha --
+the `VALUE_KNIGHT`-to-`VALUE_ROOK` margin -- leftover moves,
+move-number bucket ~6-30+):
+
+- Overall range across that envelope: ~0.000% to ~0.37%, median
+ ~0.009% (p10-p90: 0.001%-0.046%).
+- **The `dumb` axis -- EFP is completely blind to this -- swings FH%
+ 15-30x within an otherwise-fixed cell** (e.g. depth=1, gap=4,
+ late=8: `dumb=0` -> 0.032%, `dumb=5` -> 0.002%, `dumb=9` -> 0.001%).
+- **The `gap` axis -- EFP's only real lever -- only swings FH% ~4x**
+ across its ENTIRE knight-to-rook operating range (gap=3 -> 0.068%,
+ gap=5 -> 0.017%).
+- The `late` axis (partially, structurally captured by EFP's
+ `uLegalMoves > 1` gate, but not gradedly) also swings ~4-5x
+ (late=5 -> 0.041%, late=14 -> 0.009%).
+
+**The axis EFP cannot see (dumb) has a bigger effect than the one axis
+it does look at (gap).** Direct, quantified evidence that the table
+carries real information EFP structurally cannot use, independent of
+the `ComputeMoveScore` bug above.
+
+## The "deep prune" idea: cut hopeless-looking branches much higher in the tree
+
+Scott's proposal, motivated by the above: rather than (or in addition
+to) EFP's high-volume, low-value pruning right at the horizon (1-2
+plies remaining, ~30-100 node subtrees, mostly qsearch), use the same
+table to prune a MUCH smaller number of branches much higher up (6-8
+plies remaining) where a real hopeless/late/dumb-material cell reads a
+genuinely evidence-backed (500+ tries per the shipped floor) exact
+`0.0000%` observed FH rate.
+
+**Order-of-magnitude check, from real measured numbers (self-play
+`ebf`~3.6, and an observed ~84% qsearch-node share in a real `sd8`
+trace)**: full-width-nodes-times-qsearch-multiplier estimate puts a
+1-2-ply subtree at ~30-100 nodes (matches EFP's actual footprint) vs.
+~20,000 (depth 6) to ~250,000 (depth 8) nodes for a deep subtree --
+several orders of magnitude more payoff per prune decision.
+
+**Confirmed the table actually has real, evidence-backed near-zero
+cells there**: at depth=6-8, gap=10 (>=901cp behind -- roughly a queen
+or more), late=14 (very late/leftover), dumb=6-9 (materially hanging),
+observed FH% reads exactly `0.0000%`, and because every non-sentinel
+cell already cleared the 500-tries collection floor, this is a real
+"watched 500+ times, never once failed high" statement, not a guess.
+The most extreme combination (`dumb=10` AND `gap=10` together) doesn't
+even have data yet -- too rare to have cleared the floor in the
+current (60-90 minute) collection run, which is itself part of the
+argument for a bigger rebuild (see next section).
+
+**This is explicitly the highest-conviction next direction discussed
+this session** -- higher potential payoff than either the LMR
+augmentation above or an EFP patch, but also acknowledged as the
+riskiest (an irreversible prune, firing deeper in the tree where a
+wrong call costs an exponentially bigger subtree than either EFP or
+this round's LMR work ever risked).
+
+## Agreed design requirements for the deep-prune mechanism, before it's built for real
+
+1. **One unified eligibility gate for ALL pruning/reduction
+ mechanisms** (EFP, this round's LMR augmentation, any future LMP,
+ and the deep prune) -- extend the existing `fPruneReduceEligible`
+ checklist (already shared infrastructure from the earlier EFP work)
+ to explicitly cover: no checks, no escaping check, leftover moves
+ only, not a killer (previous ply or this ply), no reason to extend
+ this node, no reason the LAST move by this side was extended
+ (EFP's existing gate has this piece already), `!fThreat`, not a
+ capture unless losing, `iAlpha < +NMATE`. Rationale, per Scott
+ directly: these preconditions define "unremarkable move," which is
+ also exactly the population the table itself was trained against --
+ using a different gate at consult time than at collection time is
+ the same population-mismatch mistake the EFP work already paid for
+ once (see "the population the data is measured over" above).
+
+2. **A rebuilt, larger, deeper table before trusting "confirmed
+ zero" for anything irreversible.** Current table: 60-90 minutes,
+ 16 shards, `sd 16`. Planned rebuild: overnight or longer (8-12+
+ hours/shard), deeper (`sd 18-20`+, to populate depth 10-13 with
+ real signal instead of the coarsened overflow bucket), bigger/more
+ varied position pool specifically to get real sample volume into
+ the deep+hopeless+late cells that are currently unpopulated (e.g.
+ `dumb=10`+`gap=10` combined). Blocked for now: this box doesn't
+ have spare cores to run a 16-shard `--cpus 1` collection run
+ alongside the ongoing 12-worker self-play match (each `match_play.py`
+ worker spawns 2 single-threaded engine processes, so 12 workers is
+ already ~24 concurrent processes) -- waiting for the self-play LLR
+ to resolve (cross either SPRT bound) before starting this.
+
+3. **"Certain" means literally `0.0%` observed, at real sample
+ volume** -- not a low-but-nonzero tier, per Scott's explicit
+ framing. A follow-on, not-yet-designed refinement: how much sample
+ volume is actually enough given how many times per game this gate
+ would fire (see the earlier EFP section's "how much statistical
+ confidence is actually needed" analysis -- same rule-of-three
+ reasoning applies here and needs re-deriving for whatever
+ application rate the deep-prune gate turns out to have).
+
+4. **A prune-specific shadow-verification harness, built BEFORE any
+ real pruning, same discipline as `JOINT_EXTRA_VERIFY` above but
+ answering a different question**: not "would a deeper search of
+ this reduced move have failed high" but "would searching this move
+ to real depth at all have failed high, given we were about to skip
+ it entirely." Must apply the (now understood) `NEUTRALIZE_NULL_
+ BAILOUT` fix from day one, and must dump FEN + full details to a
+ log on every actual miss found, for direct manual investigation
+ (matching how the two genuine-miss classes above were understood) --
+ agreed explicitly: any wrong guess gets looked at and reasoned
+ about, not just counted.
+
+5. **A dynamic, per-move-identity component is needed and is
+ currently MISSING from this round's design** -- `GetLMRReduction`'s
+ own existing pattern (require the specific move's own
+ `GetMoveFailHighPercentage` history to independently agree, not
+ just the static table cell) was flagged mid-session as something
+ the deep-prune gate must also have: require both the static table
+ cell to read genuine evidence-backed zero AND this exact move's own
+ accumulated in-search history to show zero fail-highs over some
+ minimum tries, before ever pruning for real. Countermove-conditioned
+ dynamic veto was raised as a further, explicitly UNTESTED idea for
+ later, not part of the first attempt.
+
+## Self-play status as of this write-up (2026-09-29, ongoing)
+
+First live test of the LMR-only augmentation (EFP left untouched,
+including its known `ComputeMoveScore` bug) against `head_reference`,
+`match_play.py`, `--st 4`, 12 workers, SPRT (`elo0=0, elo1=5`).
+
+**Pruning/reduction heuristic settings live for this entire match**
+(`search.c` constants, unchanged for the whole run -- see "the mechanism:
+strictly additive over `GetLMRReduction`" and "tier thresholds" sections
+above for how these were originally derived):
+
+```
+JOINT_EXTRA_TIER1_THOUSANDTHS = 72 // ~p75 of LMR-slice FH%: below this, +1 ply
+JOINT_EXTRA_TIER2_THOUSANDTHS = 5 // ~p25: below this, +2 plies
+JOINT_EXTRA_TIER3_THOUSANDTHS = 1 // ~p5: below this, +3 plies
+JOINT_EXTRA_MAX_PLIES = 3
+JOINT_EXTRA_MIN_REMAINING_DEPTH = ONE_PLY // most aggressive floor, post round-trip (see above)
+```
+
+i.e.: HEAD's own baseline `GetLMRReduction` (`-ONE_PLY`) is left untouched;
+on top of it, a gate-1-eligible move whose 4D joint-table predicted FH%
+falls under 0.072% gets +1 extra ply, under 0.005% gets +2, under 0.001%
+gets +3 (capped by `JOINT_EXTRA_MAX_PLIES` and by the
+`JOINT_EXTRA_MIN_REMAINING_DEPTH` floor so combined reduction never drops
+remaining depth below one ply). EFP was deliberately left untouched for
+this round (see below).
+
+| games | score | llr | avg depth (cand vs head) | ebf (cand vs head) | nps (cand vs head) |
+|---|---|---|---|---|---|
+| 110 | 0.532 | +0.16 | 12.90 > 12.77 | 3.561 < 3.664 | 1,625,612 > 1,570,311 |
+| 290 | 0.533 | +0.45 | 13.21 > 12.80 | 3.545 < 3.651 | 1,618,609 > 1,576,194 |
+| 730 | 0.510 | +0.28 | 13.30 > 12.99 | 3.546 < 3.622 | 1,621,970 > 1,589,133 |
+| 2212 (final, killed by hand) | 0.502 | -0.19 | 13.28 > 12.99 | 3.543 < 3.615 | 1,604,237 > 1,577,331 |
+
+Trending positive early (110-730 games) but the match was killed by hand
+at 2212 games once it settled back to dead-even: `SPRT=INCONCLUSIVE
+LLR=-0.189 BOUNDS=[-2.944,2.944] ELO=+1.4+/-14.5 LOWER95=0.4812`. At this
+sample size SPRT hadn't crossed only because `elo1=5` sits inside the ~12
+Elo-wide 95% confidence interval the match had already converged to --
+another 75 hours (the printed ETA) would have bought a label, not new
+information, so continuing to let it run was judged not worth it.
+
+**Caveat added 2026-10-01**: this entire match ran under the
+pre-unification gate (`uLegalMoves > 5`, the population the p75/p25/p5
+tier thresholds above were themselves derived against). EFP/LMR were
+unified onto `fPruneReduceEligible` (`uLegalMoves > 1`) afterward, which
+widens the live population these thresholds now classify without
+re-deriving or re-testing them. So this "even, no strength gain" verdict
+is evidence about the *original* p75/p25/p5 thresholds against the
+*narrower* pre-unification population -- it says nothing yet about
+their behavior (or the current hand-adjusted 150/5/1 thresholds, see
+`search.c`'s `JOINT_EXTRA_TIER*_THOUSANDTHS`) against the current,
+wider `uLegalMoves > 1` population. Needs its own self-play run before
+treating this verdict as settled for the current code.
+
+**Verdict, Scott's read, 2026-09-30**: the mechanism *works* in the narrow
+sense that it delivers real, consistent extra depth/speed (+0.3 ply,
+~+1.7% nps, leaner `ebf`) for no loss in measured playing strength -- but
+that 0.3 ply isn't enough on its own to show up as a score edge; the
+extra depth has to be large enough to let the engine see things HEAD
+doesn't. Two candidate next steps, not yet decided between:
+1. **Find more *sound* pruning/reduction opportunities** via the planned
+ larger/deeper table rebuild (below) -- current table has real
+ low-traffic cells (sparse or unpopulated deep+hopeless+late
+ combinations) that a bigger collection run should fill in, unlocking
+ additional reduction without cost.
+2. **Tune the existing curve to be a bit more aggressive as a starting
+ point.** Scott's specific framing: the current tier thresholds
+ (`p75`/`p25`/`p5` -> `+0`/`+1`/`+2`/`+3` plies, see above) aren't
+ hurting playing strength but also aren't buying enough depth/speed to
+ realize a gain -- worth pushing slightly harder on the SAME curve
+ shape before waiting on a full table rebuild.
+Leaning toward trying (2) as a cheap, fast experiment first (doesn't need
+the rebuild or its core budget) while (1)'s rebuild runs in parallel once
+the match's ~24 `match_play.py` engine processes are confirmed dead.
+
+**The ~3-4% nps and ~0.4-ply depth advantage are almost certainly the
+SAME underlying effect measured two ways, not two independent
+confirmations.** This is a fixed-wall-clock-per-move match (`--st 4`),
+so depth reached is a direct function of average cost per node. If
+extra reduction shifts the tree's node mix toward proportionally more
+(cheap) qsearch nodes and fewer (expensive) full-width nodes -- which
+the lower `ebf` for candidate at both checkpoints is directly
+consistent with -- that simultaneously raises the blended nodes/second
+figure AND lets iterative deepening reach one more ply in the same
+time budget. Confirmed as a real, structural effect (not process
+scheduling noise): stable over an hour-plus of real gameplay across
+~24 concurrent single-threaded engine processes on a shared,
+contended box, not a short or single-sample reading.
+
+## Build hygiene note (2026-09-29)
+
+Before this self-play run started, did a full audit + clean rebuild to
+confirm the shipped binary carries none of the investigation-only
+instrumentation: `JOINT_EXTRA_VERIFY`, `SILENT_MISS_TRACE`,
+`NEUTRALIZE_NULL_BAILOUT`, `DISABLE_EFP`, `DEPTH_ONLY_EXTRA_REDUCTION`
+are all `ifdef`-gated behind their own opt-in `GNUmakefile` flags, none
+part of the standard `GENETIC=1 PERF_COUNTERS=1 MP=1 SIXTYFOUR=1`
+profile; confirmed absent from a clean build's own
+`--command ""`-printed `Make profile used:` line. `QSearchFromCheckNo
+StandPat`'s three rounds of live manual experimentation (see above)
+were confirmed reverted to their original condition set via `git diff`.
+No stray debugger hooks (`BREAKPOINT`) remained in `search.c`. Modified
+files vs. HEAD for this whole effort: `GNUmakefile`, `chess.h`,
+`data.c`, `root.c`, `script.c`, `search.c`.
+
+## Status / open questions as of this write-up
+
+- LMR augmentation: live, in self-play, inconclusive but trending
+ positive (llr +0.45 at 290 games). No further code changes planned
+ until this resolves one way or the other -- can't run the planned
+ table rebuild concurrently with self-play on this box's core count.
+- EFP: left deliberately untouched (including the confirmed-dead
+ `ComputeMoveScore` check) for this round, on purpose, to keep the
+ LMR-only test interpretable. Two candidate next steps discussed and
+ NOT yet decided between: (a) a conservative "veto EFP's prune when
+ the table thinks this move is surprisingly dangerous" patch (small,
+ reversible, directly fixes the confirmed dead-check bug), or (b)
+ skip straight to the deep-prune mechanism and let IT eventually
+ subsume/retire EFP rather than patching EFP incrementally. Leaning
+ toward (b) as the real long-term direction, but agreed the
+ incremental-experiment discipline that's served this whole project
+ well should still apply -- not committing to skipping (a) outright.
+- Deep-prune mechanism: designed in discussion (this section), not yet
+ implemented. Blocked on: (1) the overnight/longer table rebuild
+ (itself blocked on self-play finishing, core-count constrained), (2)
+ building the prune-specific shadow-verification harness, (3) the
+ unified-gate refactor, (4) deciding on the dynamic per-move-identity
+ component's exact shape.
+- Open, explicitly undecided strategic question: does the table
+ eventually fully replace EFP (one mechanism, deep+shallow both
+ handled by table-driven decisions), or do EFP (shallow, high-volume,
+ patched) and a new deep-prune mechanism (deep, low-volume,
+ table-driven) coexist as two mechanisms answering the same
+ underlying question from different depths? Deferred until real
+ numbers exist from both the current self-play match and a future
+ deep-prune experiment.
diff --git a/src/root.c b/src/root.c
index 86d8b76..bc6e3be 100644
--- a/src/root.c
+++ b/src/root.c
@@ -469,11 +469,80 @@ Return value:
Trace("Null move cutoff rate: %5.3f percent.\n",
((n / d) * 100.0));
#ifdef PERF_COUNTERS
- Trace("Node-type surprises: PV failed high/low=%llu, ALL failed high=%llu, "
- "CUT failed low=%llu.\n",
- ctx->sCounters.tree.u64PVNodeSurprised,
- ctx->sCounters.tree.u64AllNodeFailedHigh,
- ctx->sCounters.tree.u64CutNodeFailedLow);
+ // 2026-10-03: percentages added, each against the count of nodes of
+ // THAT TYPE (u64NodeTypeCount, incremented in CommonSearchInit),
+ // not against total nodes -- dividing by total nodes understates
+ // every rate by whatever share of the tree is some other type.
+ //
+ // 2026-10-03: this line now measures what its name says. Three
+ // separate defects were fixed to get there, all of which had been
+ // inflating it:
+ // 1. Denominator was total nodes, not nodes of that type.
+ // 2. Qsearch was included (~84% of the tree) -- a qsearch
+ // stand-pat that beats beta looks like "ALL failed high" while
+ // being the most ordinary event there. Now excluded from both
+ // numerator (QSearch's copy of the counters was deleted) and
+ // denominator (u64NodeTypeCount is incremented in Search()
+ // only).
+ // 3. Nodes short-circuited by CommonSearchInit reached the
+ // counters without ever having been typed for that visit, and
+ // were scored against a stale eNodeType -- which produced an
+ // impossible "106.2% of PV nodes". Now guarded by
+ // fNodeTypedThisVisit.
+ // And a fourth in the fail-low test itself: it compared against the
+ // IN-PLACE-RAISED iAlpha rather than iInitialAlpha, so every node
+ // that successfully raised alpha was counted as a fail-low (the PV
+ // row read 99.1%). See search.c's comment at the counters.
+ {
+ double dPV = (double)ctx->sCounters.tree.u64NodeTypeCount[NODE_PV] + 1.0;
+ double dCUT = (double)ctx->sCounters.tree.u64NodeTypeCount[NODE_CUT] + 1.0;
+ double dALL = (double)ctx->sCounters.tree.u64NodeTypeCount[NODE_ALL] + 1.0;
+
+ Trace("Node-type surprises: PV failed high/low=%llu (%.1f%% of %.0f PV "
+ "nodes), ALL failed high=%llu (%.1f%% of %.0f ALL nodes), "
+ "CUT failed low=%llu (%.1f%% of %.0f CUT nodes).\n"
+ " (full-width nodes only; qsearch excluded from both "
+ "numerator and denominator)\n",
+ ctx->sCounters.tree.u64PVNodeSurprised,
+ 100.0 * (double)ctx->sCounters.tree.u64PVNodeSurprised / dPV, dPV - 1.0,
+ ctx->sCounters.tree.u64AllNodeFailedHigh,
+ 100.0 * (double)ctx->sCounters.tree.u64AllNodeFailedHigh / dALL, dALL - 1.0,
+ ctx->sCounters.tree.u64CutNodeFailedLow,
+ 100.0 * (double)ctx->sCounters.tree.u64CutNodeFailedLow / dCUT, dCUT - 1.0);
+
+ // Qsearch half, plus the PVS identity cross-check it exists to
+ // make possible: every CUT node that fails low forces its ALL
+ // parent to fail high, and that parent cannot have had an
+ // earlier child fail low (it would have cut off first), so the
+ // two should run ~1:1 ACROSS BOTH POPULATIONS. Any residual is
+ // ALL nodes resolved with no child searched at all --
+ // recognizer/EGTB fail-highs and null-move cutoffs.
+ {
+ double dQPV = (double)ctx->sCounters.tree.u64QNodeTypeCount[NODE_PV] + 1.0;
+ double dQCUT = (double)ctx->sCounters.tree.u64QNodeTypeCount[NODE_CUT] + 1.0;
+ double dQALL = (double)ctx->sCounters.tree.u64QNodeTypeCount[NODE_ALL] + 1.0;
+ UINT64 u64AllFH = ctx->sCounters.tree.u64AllNodeFailedHigh +
+ ctx->sCounters.tree.u64QNodeSurprise[NODE_ALL];
+ UINT64 u64CutFL = ctx->sCounters.tree.u64CutNodeFailedLow +
+ ctx->sCounters.tree.u64QNodeSurprise[NODE_CUT];
+
+ Trace(" qsearch half: PV=%llu (%.1f%% of %.0f), ALL fh=%llu "
+ "(%.1f%% of %.0f), CUT fl=%llu (%.1f%% of %.0f)\n",
+ ctx->sCounters.tree.u64QNodeSurprise[NODE_PV],
+ 100.0 * (double)ctx->sCounters.tree.u64QNodeSurprise[NODE_PV] / dQPV,
+ dQPV - 1.0,
+ ctx->sCounters.tree.u64QNodeSurprise[NODE_ALL],
+ 100.0 * (double)ctx->sCounters.tree.u64QNodeSurprise[NODE_ALL] / dQALL,
+ dQALL - 1.0,
+ ctx->sCounters.tree.u64QNodeSurprise[NODE_CUT],
+ 100.0 * (double)ctx->sCounters.tree.u64QNodeSurprise[NODE_CUT] / dQCUT,
+ dQCUT - 1.0);
+ Trace(" PVS identity (should be ~1:1): CUT-fails-low=%llu vs "
+ "ALL-fails-high=%llu ratio=%.2f\n",
+ u64CutFL, u64AllFH,
+ (double)u64AllFH / ((double)u64CutFL + 1.0));
+ }
+ }
#endif
if (ctx->sCounters.egtb.uProbes > 0)
{
@@ -1270,7 +1339,8 @@ Return value:
{
ULONG uNumRootFailLows = 0;
ULONG uDepth;
- ULONG uFailState = 0;
+ ULONG uFailStateLo = 0;
+ ULONG uFailStateHi = 0;
ULONG uColor;
ULONG u;
SCORE iAlpha = -INFINITY_SCORE;
@@ -1405,13 +1475,34 @@ Return value:
ASSERT(iScore == ctx->iRootScore);
ASSERT(mv.uMove);
ASSERT(SanityCheckMove(&ctx->sPosition, mv));
- iAlpha = iScore - INITIAL_HALF_WINDOW;
- iBeta = iScore + INITIAL_HALF_WINDOW;
+ //
+ // Don't center a cp-sized aspiration window on a mate
+ // score. A mate found at this depth may be a search
+ // artifact (a defense pruned away by LMR/null-move/
+ // futility) that evaporates one ply deeper, in which case
+ // the next iteration's score isn't a nearby mate value at
+ // all -- it's an ordinary eval thousands of points away.
+ // That swing blows straight past SKIP_GRADUAL_OPENING and
+ // forces an immediate jump to the infinite window (and a
+ // full re-search of every root move) anyway, so just go
+ // wide on purpose instead of narrow-then-blown-open.
+ //
+ if ((ULONG)abs(iScore) >= NMATE)
+ {
+ iAlpha = -INFINITY_SCORE;
+ iBeta = +INFINITY_SCORE;
+ }
+ else
+ {
+ iAlpha = iScore - INITIAL_HALF_WINDOW;
+ iBeta = iScore + INITIAL_HALF_WINDOW;
+ }
g_iRootScore[uColor] = iScore;
g_iRootScore[FLIP(uColor)] = -iScore;
g_MoveTimer.bvFlags &= ~TIMER_RESOLVING_ROOT_FH;
g_MoveTimer.bvFlags &= ~TIMER_RESOLVING_ROOT_FL;
- uFailState = 0;
+ uFailStateLo = 0;
+ uFailStateHi = 0;
(void)CheckTestSuiteMove(mv, iScore, uDepth);
break;
}
@@ -1429,7 +1520,7 @@ Return value:
{
g_MoveTimer.bvFlags |= TIMER_MANY_ROOT_FLS;
}
- _IterateWidenWindow(uColor, iScore, &iAlpha, &uFailState, -1);
+ _IterateWidenWindow(uColor, iScore, &iAlpha, &uFailStateLo, -1);
//
// Consider all moves again with wider window...
@@ -1451,7 +1542,7 @@ Return value:
ASSERT(mv.uMove);
ASSERT(SanityCheckMove(&ctx->sPosition, mv));
g_MoveTimer.bvFlags |= TIMER_RESOLVING_ROOT_FH;
- _IterateWidenWindow(uColor, iScore, &iBeta, &uFailState, +1);
+ _IterateWidenWindow(uColor, iScore, &iBeta, &uFailStateHi, +1);
}
}
while(1); // repeat until we run out of time or fall inside a..b
@@ -1507,6 +1598,59 @@ Return value:
g_Options.u64CMEvidenceTries[u] = ctx->sCounters.tree.u64CMEvidenceTries[u];
g_Options.u64CMEvidenceFH[u] = ctx->sCounters.tree.u64CMEvidenceFH[u];
}
+ for (u = 0; u < HANG_CP_BUCKETS; u++)
+ {
+ g_Options.u64HangEstimateTries[u] = ctx->sCounters.tree.u64HangEstimateTries[u];
+ g_Options.u64HangEstimateFH[u] = ctx->sCounters.tree.u64HangEstimateFH[u];
+ }
+ g_Options.u64EligibleLeftoverTries = ctx->sCounters.tree.u64EligibleLeftoverTries;
+ g_Options.u64EligibleLeftoverFH = ctx->sCounters.tree.u64EligibleLeftoverFH;
+ for (u = 0; u < GAP_CP_BUCKETS; u++)
+ {
+ g_Options.u64GapEstimateTries[u] = ctx->sCounters.tree.u64GapEstimateTries[u];
+ g_Options.u64GapEstimateFH[u] = ctx->sCounters.tree.u64GapEstimateFH[u];
+ }
+ for (u = 0; u < MOVE_NUM_BUCKETS; u++)
+ {
+ g_Options.u64MoveNumEstimateTries[u] = ctx->sCounters.tree.u64MoveNumEstimateTries[u];
+ g_Options.u64MoveNumEstimateFH[u] = ctx->sCounters.tree.u64MoveNumEstimateFH[u];
+ }
+ for (u = 0; u < SEARCH_DEPTH_BUCKETS; u++)
+ {
+ g_Options.u64DepthEstimateTries[u] = ctx->sCounters.tree.u64DepthEstimateTries[u];
+ g_Options.u64DepthEstimateFH[u] = ctx->sCounters.tree.u64DepthEstimateFH[u];
+ }
+ memcpy(g_Options.u64JointTries, ctx->sCounters.tree.u64JointTries,
+ sizeof(g_Options.u64JointTries));
+ memcpy(g_Options.u64JointFH, ctx->sCounters.tree.u64JointFH,
+ sizeof(g_Options.u64JointFH));
+ memcpy(g_Options.u64JointExtraTries, ctx->sCounters.tree.u64JointExtraTries,
+ sizeof(g_Options.u64JointExtraTries));
+ memcpy(g_Options.u64JointExtraResearch, ctx->sCounters.tree.u64JointExtraResearch,
+ sizeof(g_Options.u64JointExtraResearch));
+ g_Options.u64LMPPruned = ctx->sCounters.tree.u64LMPPruned;
+#ifdef PRUNE_VERIFY
+ memcpy(g_Options.u64PruneVerifyTries, ctx->sCounters.tree.u64PruneVerifyTries,
+ sizeof(g_Options.u64PruneVerifyTries));
+ memcpy(g_Options.u64PruneVerifyFailHigh, ctx->sCounters.tree.u64PruneVerifyFailHigh,
+ sizeof(g_Options.u64PruneVerifyFailHigh));
+ memcpy(g_Options.u64PruneVerifyMarginSum, ctx->sCounters.tree.u64PruneVerifyMarginSum,
+ sizeof(g_Options.u64PruneVerifyMarginSum));
+ memcpy(g_Options.u64PruneVerifyPredSum, ctx->sCounters.tree.u64PruneVerifyPredSum,
+ sizeof(g_Options.u64PruneVerifyPredSum));
+ memcpy(g_Options.u64PruneVerifyDyn, ctx->sCounters.tree.u64PruneVerifyDyn,
+ sizeof(g_Options.u64PruneVerifyDyn));
+ memcpy(g_Options.u64PruneVerifyDynMiss, ctx->sCounters.tree.u64PruneVerifyDynMiss,
+ sizeof(g_Options.u64PruneVerifyDynMiss));
+#endif
+#ifdef JOINT_EXTRA_VERIFY
+ memcpy(g_Options.u64JointExtraVerifyTries, ctx->sCounters.tree.u64JointExtraVerifyTries,
+ sizeof(g_Options.u64JointExtraVerifyTries));
+ memcpy(g_Options.u64JointExtraVerifySilentMiss, ctx->sCounters.tree.u64JointExtraVerifySilentMiss,
+ sizeof(g_Options.u64JointExtraVerifySilentMiss));
+ memcpy(g_Options.u64JointExtraVerifyNullBailoutSignature, ctx->sCounters.tree.u64JointExtraVerifyNullBailoutSignature,
+ sizeof(g_Options.u64JointExtraVerifyNullBailoutSignature));
+#endif
#ifdef TEST_NULL
for (u = 0; u < NC_TOTAL_BUCKETS; u++)
{
diff --git a/src/script.c b/src/script.c
index f18ab7b..219825c 100644
--- a/src/script.c
+++ b/src/script.c
@@ -64,6 +64,52 @@ typedef struct _SUITE_COUNTERS
UINT64 u64TotalLeftoverFH;
UINT64 u64TotalCMEvidenceTries[CM_EVIDENCE_BUCKETS];
UINT64 u64TotalCMEvidenceFH[CM_EVIDENCE_BUCKETS];
+ // See chess.h's HANG_CP_BUCKETS comment. Same accumulation/print
+ // pattern as u64TotalCMEvidenceTries above -- only meaningful
+ // gathered single-threaded (--cpus 1), same as that one (not
+ // wired into split.c's MP merge).
+ UINT64 u64TotalHangEstimateTries[HANG_CP_BUCKETS];
+ UINT64 u64TotalHangEstimateFH[HANG_CP_BUCKETS];
+ // The real comparison baseline for the buckets above -- same
+ // fPruneReduceEligible population, not just fIsLeftoverMove.
+ UINT64 u64TotalEligibleLeftoverTries;
+ UINT64 u64TotalEligibleLeftoverFH;
+ // The "down axis" -- (iAlpha - iEval) calibration. Same population
+ // and comparison baseline as the hang-estimate buckets above.
+ UINT64 u64TotalGapEstimateTries[GAP_CP_BUCKETS];
+ UINT64 u64TotalGapEstimateFH[GAP_CP_BUCKETS];
+ // The "late axis" -- this move's own ordinal position calibration.
+ // Same population and comparison baseline as the other two axes.
+ UINT64 u64TotalMoveNumEstimateTries[MOVE_NUM_BUCKETS];
+ UINT64 u64TotalMoveNumEstimateFH[MOVE_NUM_BUCKETS];
+ // The "depth axis" -- remaining search depth calibration. Same
+ // population and comparison baseline as the other three axes.
+ UINT64 u64TotalDepthEstimateTries[SEARCH_DEPTH_BUCKETS];
+ UINT64 u64TotalDepthEstimateFH[SEARCH_DEPTH_BUCKETS];
+ // Joint 4D table, marginal (uncoarsened) resolution -- reassembled
+ // offline by the bake script via the FOURAXISRAW dump below, never
+ // printed as a human-readable table here (21,780+ shipped cells,
+ // 99,825 at this raw resolution -- far too many lines).
+ UINT64 u64TotalJointTries[SEARCH_DEPTH_BUCKETS][HANG_CP_BUCKETS][GAP_CP_BUCKETS][MOVE_NUM_BUCKETS];
+ UINT64 u64TotalJointFH[SEARCH_DEPTH_BUCKETS][HANG_CP_BUCKETS][GAP_CP_BUCKETS][MOVE_NUM_BUCKETS];
+ // Phase 2 -- joint-table-driven graduated LMR calibration (see
+ // search.c's JOINT_EXTRA_TIER*_THOUSANDTHS comment).
+ UINT64 u64TotalJointExtraTries[JOINT_EXTRA_TIER_COUNT];
+ UINT64 u64TotalJointExtraResearch[JOINT_EXTRA_TIER_COUNT];
+ UINT64 u64TotalLMPPruned;
+#ifdef PRUNE_VERIFY
+ UINT64 u64TotalPruneVerifyTries[PRUNE_VERIFY_BANDS][SHIP_DEPTH_BUCKETS];
+ UINT64 u64TotalPruneVerifyFailHigh[PRUNE_VERIFY_BANDS][SHIP_DEPTH_BUCKETS];
+ UINT64 u64TotalPruneVerifyMarginSum[PRUNE_VERIFY_BANDS][SHIP_DEPTH_BUCKETS];
+ UINT64 u64TotalPruneVerifyPredSum[PRUNE_VERIFY_BANDS][SHIP_DEPTH_BUCKETS];
+ UINT64 u64TotalPruneVerifyDyn[8][8];
+ UINT64 u64TotalPruneVerifyDynMiss[8][8];
+#endif
+#ifdef JOINT_EXTRA_VERIFY
+ UINT64 u64TotalJointExtraVerifyTries[JOINT_EXTRA_TIER_COUNT][POST_REDUCE_DEPTH_BUCKETS];
+ UINT64 u64TotalJointExtraVerifySilentMiss[JOINT_EXTRA_TIER_COUNT][POST_REDUCE_DEPTH_BUCKETS];
+ UINT64 u64TotalJointExtraVerifyNullBailoutSignature[JOINT_EXTRA_TIER_COUNT];
+#endif
#ifdef TEST_NULL
// Null-move precision/recall calibration, summed across every
// position in the suite -- see _NullCalibBucketIndex's comment in
@@ -408,6 +454,112 @@ _ProcessSuiteLine(char *szLine, POSITION *pos)
g_SuiteCounters.u64TotalCMEvidenceFH[v] +=
g_Options.u64CMEvidenceFH[v];
}
+ for (v = 0; v < HANG_CP_BUCKETS; v++)
+ {
+ g_SuiteCounters.u64TotalHangEstimateTries[v] +=
+ g_Options.u64HangEstimateTries[v];
+ g_SuiteCounters.u64TotalHangEstimateFH[v] +=
+ g_Options.u64HangEstimateFH[v];
+ }
+ g_SuiteCounters.u64TotalEligibleLeftoverTries +=
+ g_Options.u64EligibleLeftoverTries;
+ g_SuiteCounters.u64TotalEligibleLeftoverFH +=
+ g_Options.u64EligibleLeftoverFH;
+ for (v = 0; v < GAP_CP_BUCKETS; v++)
+ {
+ g_SuiteCounters.u64TotalGapEstimateTries[v] +=
+ g_Options.u64GapEstimateTries[v];
+ g_SuiteCounters.u64TotalGapEstimateFH[v] +=
+ g_Options.u64GapEstimateFH[v];
+ }
+ for (v = 0; v < MOVE_NUM_BUCKETS; v++)
+ {
+ g_SuiteCounters.u64TotalMoveNumEstimateTries[v] +=
+ g_Options.u64MoveNumEstimateTries[v];
+ g_SuiteCounters.u64TotalMoveNumEstimateFH[v] +=
+ g_Options.u64MoveNumEstimateFH[v];
+ }
+ for (v = 0; v < SEARCH_DEPTH_BUCKETS; v++)
+ {
+ g_SuiteCounters.u64TotalDepthEstimateTries[v] +=
+ g_Options.u64DepthEstimateTries[v];
+ g_SuiteCounters.u64TotalDepthEstimateFH[v] +=
+ g_Options.u64DepthEstimateFH[v];
+ }
+ {
+ ULONG a, b, c;
+ for (a = 0; a < SEARCH_DEPTH_BUCKETS; a++)
+ {
+ for (b = 0; b < HANG_CP_BUCKETS; b++)
+ {
+ for (c = 0; c < GAP_CP_BUCKETS; c++)
+ {
+ for (v = 0; v < MOVE_NUM_BUCKETS; v++)
+ {
+ g_SuiteCounters.u64TotalJointTries[a][b][c][v] +=
+ g_Options.u64JointTries[a][b][c][v];
+ g_SuiteCounters.u64TotalJointFH[a][b][c][v] +=
+ g_Options.u64JointFH[a][b][c][v];
+ }
+ }
+ }
+ }
+ }
+ for (v = 0; v < JOINT_EXTRA_TIER_COUNT; v++)
+ {
+ g_SuiteCounters.u64TotalJointExtraTries[v] +=
+ g_Options.u64JointExtraTries[v];
+ g_SuiteCounters.u64TotalJointExtraResearch[v] +=
+ g_Options.u64JointExtraResearch[v];
+ }
+ g_SuiteCounters.u64TotalLMPPruned +=
+ g_Options.u64LMPPruned;
+ #ifdef PRUNE_VERIFY
+ {
+ ULONG w;
+ for (v = 0; v < PRUNE_VERIFY_BANDS; v++)
+ {
+ for (w = 0; w < SHIP_DEPTH_BUCKETS; w++)
+ {
+ g_SuiteCounters.u64TotalPruneVerifyTries[v][w] +=
+ g_Options.u64PruneVerifyTries[v][w];
+ g_SuiteCounters.u64TotalPruneVerifyFailHigh[v][w] +=
+ g_Options.u64PruneVerifyFailHigh[v][w];
+ g_SuiteCounters.u64TotalPruneVerifyMarginSum[v][w] +=
+ g_Options.u64PruneVerifyMarginSum[v][w];
+ g_SuiteCounters.u64TotalPruneVerifyPredSum[v][w] +=
+ g_Options.u64PruneVerifyPredSum[v][w];
+ }
+ }
+ for (v = 0; v < 8; v++)
+ {
+ for (w = 0; w < 8; w++)
+ {
+ g_SuiteCounters.u64TotalPruneVerifyDyn[v][w] +=
+ g_Options.u64PruneVerifyDyn[v][w];
+ g_SuiteCounters.u64TotalPruneVerifyDynMiss[v][w] +=
+ g_Options.u64PruneVerifyDynMiss[v][w];
+ }
+ }
+ }
+#endif
+#ifdef JOINT_EXTRA_VERIFY
+ {
+ ULONG w;
+ for (v = 0; v < JOINT_EXTRA_TIER_COUNT; v++)
+ {
+ for (w = 0; w < POST_REDUCE_DEPTH_BUCKETS; w++)
+ {
+ g_SuiteCounters.u64TotalJointExtraVerifyTries[v][w] +=
+ g_Options.u64JointExtraVerifyTries[v][w];
+ g_SuiteCounters.u64TotalJointExtraVerifySilentMiss[v][w] +=
+ g_Options.u64JointExtraVerifySilentMiss[v][w];
+ }
+ g_SuiteCounters.u64TotalJointExtraVerifyNullBailoutSignature[v] +=
+ g_Options.u64JointExtraVerifyNullBailoutSignature[v];
+ }
+ }
+#endif
}
#ifdef TEST_NULL
{
@@ -513,6 +665,360 @@ _PrintSuiteReport(double dSuiteStart)
}
}
+ // EstimateHangingMaterialCp calibration -- does "how much material
+ // does this leftover move look like it hangs" actually predict
+ // FH%? See chess.h's HANG_CP_BUCKETS comment for the bucket shape
+ // (bucket 0 == not dumb; 1..9 are 100cp-wide bins 1-900cp; 10 is
+ // the 901cp+ overflow).
+ {
+ Trace("\nhanging-material estimate calibration (captures + quiet "
+ "moves, both gated on fPruneReduceEligible):\n");
+ Trace(" [baseline] eligible : %5.2f%% FH (%"
+ COMPILER_LONGLONG_UNSIGNED_FORMAT " tries)\n",
+ (100.0 * (double)g_SuiteCounters.u64TotalEligibleLeftoverFH /
+ ((double)g_SuiteCounters.u64TotalEligibleLeftoverTries + 1.0)),
+ g_SuiteCounters.u64TotalEligibleLeftoverTries);
+ Trace(" not dumb (0cp) : %5.2f%% FH (%"
+ COMPILER_LONGLONG_UNSIGNED_FORMAT " tries)\n",
+ (100.0 * (double)g_SuiteCounters.u64TotalHangEstimateFH[0] /
+ ((double)g_SuiteCounters.u64TotalHangEstimateTries[0] + 1.0)),
+ g_SuiteCounters.u64TotalHangEstimateTries[0]);
+ for (v = 1; v < HANG_CP_BUCKETS; v++)
+ {
+ if (v < HANG_CP_BUCKETS - 1)
+ {
+ Trace(" %4u - %4u cp lost : %5.2f%% FH (%"
+ COMPILER_LONGLONG_UNSIGNED_FORMAT " tries)\n",
+ 1 + (v - 1) * HANG_CP_BUCKET_WIDTH,
+ v * HANG_CP_BUCKET_WIDTH,
+ (100.0 * (double)g_SuiteCounters.u64TotalHangEstimateFH[v] /
+ ((double)g_SuiteCounters.u64TotalHangEstimateTries[v] + 1.0)),
+ g_SuiteCounters.u64TotalHangEstimateTries[v]);
+ }
+ else
+ {
+ Trace(" %4u+ cp lost : %5.2f%% FH (%"
+ COMPILER_LONGLONG_UNSIGNED_FORMAT " tries)\n",
+ 1 + (v - 1) * HANG_CP_BUCKET_WIDTH,
+ (100.0 * (double)g_SuiteCounters.u64TotalHangEstimateFH[v] /
+ ((double)g_SuiteCounters.u64TotalHangEstimateTries[v] + 1.0)),
+ g_SuiteCounters.u64TotalHangEstimateTries[v]);
+ }
+ }
+ }
+
+ // The "down axis" -- (iAlpha - iEval) calibration. See chess.h's
+ // GAP_CP_BUCKETS comment for the bucket shape (bucket 0 == gap
+ // <= 0, our own static eval already meets/beats alpha; 1..9 are
+ // 100cp-wide bins for a positive gap of 1-900cp; 10 is the
+ // 901cp+ overflow). Same [baseline] eligible line above applies
+ // here too -- same population, same fPruneReduceEligible gate.
+ {
+ Trace("\ngap-to-alpha estimate calibration (captures + quiet "
+ "moves, both gated on fPruneReduceEligible):\n");
+ Trace(" gap <= 0 (eval>=alpha): %5.2f%% FH (%"
+ COMPILER_LONGLONG_UNSIGNED_FORMAT " tries)\n",
+ (100.0 * (double)g_SuiteCounters.u64TotalGapEstimateFH[0] /
+ ((double)g_SuiteCounters.u64TotalGapEstimateTries[0] + 1.0)),
+ g_SuiteCounters.u64TotalGapEstimateTries[0]);
+ for (v = 1; v < GAP_CP_BUCKETS; v++)
+ {
+ if (v < GAP_CP_BUCKETS - 1)
+ {
+ Trace(" %4u - %4u cp below alpha : %5.2f%% FH (%"
+ COMPILER_LONGLONG_UNSIGNED_FORMAT " tries)\n",
+ 1 + (v - 1) * GAP_CP_BUCKET_WIDTH,
+ v * GAP_CP_BUCKET_WIDTH,
+ (100.0 * (double)g_SuiteCounters.u64TotalGapEstimateFH[v] /
+ ((double)g_SuiteCounters.u64TotalGapEstimateTries[v] + 1.0)),
+ g_SuiteCounters.u64TotalGapEstimateTries[v]);
+ }
+ else
+ {
+ Trace(" %4u+ cp below alpha : %5.2f%% FH (%"
+ COMPILER_LONGLONG_UNSIGNED_FORMAT " tries)\n",
+ 1 + (v - 1) * GAP_CP_BUCKET_WIDTH,
+ (100.0 * (double)g_SuiteCounters.u64TotalGapEstimateFH[v] /
+ ((double)g_SuiteCounters.u64TotalGapEstimateTries[v] + 1.0)),
+ g_SuiteCounters.u64TotalGapEstimateTries[v]);
+ }
+ }
+ }
+
+ // The "late axis" -- this move's own 1-based ordinal position
+ // among moves actually tried at this node. See chess.h's
+ // MOVE_NUM_BUCKETS comment -- direct, unpaired buckets, move
+ // number (MOVE_NUM_BUCKETS-2)+ collapses into one overflow
+ // bucket. Same [baseline] eligible line as the other two axes
+ // applies here too.
+ {
+ Trace("\nmove-number estimate calibration (captures + quiet "
+ "moves, both gated on fPruneReduceEligible):\n");
+ for (v = 1; v < MOVE_NUM_BUCKETS; v++)
+ {
+ if (v < MOVE_NUM_BUCKETS - 1)
+ {
+ Trace(" move %4u : %5.2f%% FH (%"
+ COMPILER_LONGLONG_UNSIGNED_FORMAT " tries)\n",
+ v,
+ (100.0 * (double)g_SuiteCounters.u64TotalMoveNumEstimateFH[v] /
+ ((double)g_SuiteCounters.u64TotalMoveNumEstimateTries[v] + 1.0)),
+ g_SuiteCounters.u64TotalMoveNumEstimateTries[v]);
+ }
+ else
+ {
+ Trace(" move %4u+ : %5.2f%% FH (%"
+ COMPILER_LONGLONG_UNSIGNED_FORMAT " tries)\n",
+ v,
+ (100.0 * (double)g_SuiteCounters.u64TotalMoveNumEstimateFH[v] /
+ ((double)g_SuiteCounters.u64TotalMoveNumEstimateTries[v] + 1.0)),
+ g_SuiteCounters.u64TotalMoveNumEstimateTries[v]);
+ }
+ }
+ }
+
+ // The "depth axis" -- remaining search depth at this node (uDepth
+ // / ONE_PLY, whole plies, before this move's own extension/
+ // reduction). Direct, unpaired buckets, index 0 legitimately used
+ // (a heavily-reduced move can arrive at remaining depth 0). Same
+ // [baseline] eligible line as the other axes applies here too.
+ {
+ Trace("\ndepth estimate calibration (captures + quiet moves, "
+ "both gated on fPruneReduceEligible):\n");
+ for (v = 0; v < SEARCH_DEPTH_BUCKETS; v++)
+ {
+ if (v < SEARCH_DEPTH_BUCKETS - 1)
+ {
+ Trace(" depth %4u : %5.2f%% FH (%"
+ COMPILER_LONGLONG_UNSIGNED_FORMAT " tries)\n",
+ v,
+ (100.0 * (double)g_SuiteCounters.u64TotalDepthEstimateFH[v] /
+ ((double)g_SuiteCounters.u64TotalDepthEstimateTries[v] + 1.0)),
+ g_SuiteCounters.u64TotalDepthEstimateTries[v]);
+ }
+ else
+ {
+ Trace(" depth %4u+ : %5.2f%% FH (%"
+ COMPILER_LONGLONG_UNSIGNED_FORMAT " tries)\n",
+ v,
+ (100.0 * (double)g_SuiteCounters.u64TotalDepthEstimateFH[v] /
+ ((double)g_SuiteCounters.u64TotalDepthEstimateTries[v] + 1.0)),
+ g_SuiteCounters.u64TotalDepthEstimateTries[v]);
+ }
+ }
+ }
+
+ // Phase 2 -- joint-table-driven graduated LMR calibration: for each
+ // tier (0 == GetLMRReduction's baseline only, 1..3 == that many
+ // extra plies the joint table added), what fraction of moves
+ // assigned that tier triggered the existing re-search-on-fail-high
+ // path? This is the actual scale-calibration mechanism (not the
+ // offline collection data) -- if low-predicted-FH tiers (2, 3) show
+ // an EQUAL OR HIGHER research rate than tier 0/1, that's the
+ // distribution-shift failure this project's history keeps
+ // producing, and the tier thresholds (or the whole approach) need
+ // rethinking before any self-play run. A rising research rate with
+ // tier is the expected, healthy shape.
+ {
+ Trace("\njoint-table extra-reduction tier calibration "
+ "(re-search rate per tier):\n");
+ for (v = 0; v < JOINT_EXTRA_TIER_COUNT; v++)
+ {
+ Trace(" tier %u (+%u extra ply%s) : %5.2f%% research-rate (%"
+ COMPILER_LONGLONG_UNSIGNED_FORMAT " tries)\n",
+ v, v, (1 == v) ? "" : "s",
+ (100.0 * (double)g_SuiteCounters.u64TotalJointExtraResearch[v] /
+ ((double)g_SuiteCounters.u64TotalJointExtraTries[v] + 1.0)),
+ g_SuiteCounters.u64TotalJointExtraTries[v]);
+ }
+#ifdef PRUNE_VERIFY
+ // See chess.h's PRUNE_VERIFY comment. The calibration test is
+ // MEASURED vs PREDICTED on exactly the flagged population --
+ // never measured-vs-threshold, which is a ceiling on what gets
+ // flagged rather than the typical value of what does (SPEC.md
+ // records getting that wrong once). A band whose measured rate
+ // tracks its predicted rate is a band the gate can be trusted
+ // in; one where measured >> predicted is model error localized
+ // there, which no amount of Wilson/sampling conservatism fixes.
+ {
+ static const char *szBand[PRUNE_VERIFY_BANDS] =
+ { "==0", "1", "2", "3-5", "6-10", "11-50", "51-500", ">500" };
+ ULONG w;
+ Trace("\ndeep-prune shadow verification (AUDIT ONLY -- nothing "
+ "was actually pruned):\n");
+ Trace(" predFH flagged actual-FH measured%% "
+ "predicted%% mean-margin\n");
+ for (v = 0; v < PRUNE_VERIFY_BANDS; v++)
+ {
+ UINT64 u64T = 0, u64F = 0, u64M = 0, u64P = 0;
+ for (w = 0; w < SHIP_DEPTH_BUCKETS; w++)
+ {
+ u64T += g_SuiteCounters.u64TotalPruneVerifyTries[v][w];
+ u64F += g_SuiteCounters.u64TotalPruneVerifyFailHigh[v][w];
+ u64M += g_SuiteCounters.u64TotalPruneVerifyMarginSum[v][w];
+ u64P += g_SuiteCounters.u64TotalPruneVerifyPredSum[v][w];
+ }
+ if (0 == u64T) continue;
+ Trace(" %-8s %10" COMPILER_LONGLONG_UNSIGNED_FORMAT
+ " %10" COMPILER_LONGLONG_UNSIGNED_FORMAT
+ " %8.5f%% %8.5f%% %10.1f\n",
+ szBand[v], u64T, u64F,
+ 100.0 * (double)u64F / (double)u64T,
+ ((double)u64P / (double)u64T) / 1000.0,
+ u64F ? (double)u64M / (double)u64F : 0.0);
+ }
+ Trace(" (by remaining-depth bucket, flagged/actual-FH -- the COST "
+ "of a wrong\n prune scales with depth even where the RATE "
+ "does not)\n");
+ for (w = 0; w < SHIP_DEPTH_BUCKETS; w++)
+ {
+ UINT64 u64T = 0, u64F = 0;
+ for (v = 0; v < PRUNE_VERIFY_BANDS; v++)
+ {
+ u64T += g_SuiteCounters.u64TotalPruneVerifyTries[v][w];
+ u64F += g_SuiteCounters.u64TotalPruneVerifyFailHigh[v][w];
+ }
+ if (0 == u64T) continue;
+ Trace(" depth bucket %2u: %10" COMPILER_LONGLONG_UNSIGNED_FORMAT
+ " / %" COMPILER_LONGLONG_UNSIGNED_FORMAT
+ " (%7.5f%%)\n", w, u64T, u64F,
+ 100.0 * (double)u64F / (double)u64T);
+ }
+ // Dynamic-veto cost/benefit, prunable bands only. For any
+ // rule "refuse when attempts >= A and rate > B", sum the
+ // Dyn cells it covers (sound prunes lost) against the
+ // DynMiss cells (real misses caught).
+ {
+ static const char *szA[8] =
+ { "0", "1", "2", "3-4", "5-9", "10-24", "25-99", "100+" };
+ ULONG u;
+ Trace("\n dynamic signals over PRUNABLE-band candidates "
+ "(flagged / of-those-missed),\n rows = own-FH attempts, "
+ "cols = own-FH rate in thousandths of a percent:\n");
+ Trace(" attempts 0 1-100 101-400 401-900 "
+ "901-1900 1901-4900 4901-9900 9901+\n");
+ for (u = 0; u < 8; u++)
+ {
+ UINT64 u64RowT = 0;
+ for (v = 0; v < 8; v++)
+ u64RowT += g_SuiteCounters.u64TotalPruneVerifyDyn[u][v];
+ if (0 == u64RowT) continue;
+ Trace(" %-8s", szA[u]);
+ for (v = 0; v < 8; v++)
+ {
+ Trace(" %7" COMPILER_LONGLONG_UNSIGNED_FORMAT
+ "/%-2" COMPILER_LONGLONG_UNSIGNED_FORMAT,
+ g_SuiteCounters.u64TotalPruneVerifyDyn[u][v],
+ g_SuiteCounters.u64TotalPruneVerifyDynMiss[u][v]);
+ }
+ Trace("\n");
+ }
+ }
+ }
+#endif
+ Trace("\nlate-move pruning (overt skip, not reduction): %"
+ COMPILER_LONGLONG_UNSIGNED_FORMAT " moves pruned\n",
+ g_SuiteCounters.u64TotalLMPPruned);
+
+ }
+
+#ifdef JOINT_EXTRA_VERIFY
+ // "Silent miss" shadow-verification calibration: of the extra-
+ // reduced moves whose real score never triggered a re-search (the
+ // population the report block above CAN'T see into), how many
+ // would have failed high anyway if just the extra plies were
+ // removed? This is the direct answer to "how much unverified
+ // playing-strength loss is this mechanism causing" -- a real-
+ // search-cost-doubling measurement, JOINT_EXTRA_VERIFY builds only.
+ {
+ UINT64 u64TierTries, u64TierMiss;
+ ULONG w;
+
+ Trace("\njoint-table extra-reduction SILENT MISS calibration "
+ "(shadow re-search, extra-plies-removed):\n");
+ for (v = 1; v < JOINT_EXTRA_TIER_COUNT; v++)
+ {
+ u64TierTries = 0;
+ u64TierMiss = 0;
+ for (w = 0; w < POST_REDUCE_DEPTH_BUCKETS; w++)
+ {
+ u64TierTries += g_SuiteCounters.u64TotalJointExtraVerifyTries[v][w];
+ u64TierMiss += g_SuiteCounters.u64TotalJointExtraVerifySilentMiss[v][w];
+ }
+ Trace(" tier %u (+%u extra ply%s) : %5.2f%% silent-miss (%"
+ COMPILER_LONGLONG_UNSIGNED_FORMAT " tries), of which %5.2f%% "
+ "(%" COMPILER_LONGLONG_UNSIGNED_FORMAT ") match the null-move "
+ "bailout signature (iShadowScore == beta+1 exactly)\n",
+ v, v, (1 == v) ? "" : "s",
+ (100.0 * (double)u64TierMiss / ((double)u64TierTries + 1.0)),
+ u64TierTries,
+ (100.0 * (double)g_SuiteCounters.u64TotalJointExtraVerifyNullBailoutSignature[v] /
+ ((double)u64TierMiss + 1.0)),
+ g_SuiteCounters.u64TotalJointExtraVerifyNullBailoutSignature[v]);
+ }
+
+ // Breakdown by EFP's own two-tier margin-schedule boundaries
+ // (search.c's _PostReduceDepthBucketEfpAligned) rather than
+ // plain whole-ply buckets -- tests whether silent misses
+ // concentrate specifically inside EFP's tier windows (a
+ // different pruning mechanism behaving differently there) or
+ // are spread uniformly by remaining depth alone.
+ {
+ static const char *rgszBucketLabel[POST_REDUCE_DEPTH_BUCKETS] = {
+ "< 1 ply (should not happen)",
+ "EFP tier1 (selective futility)",
+ "EFP tier2 (extended futility)",
+ "no EFP, nearby",
+ "no EFP, comfortably deep"
+ };
+ Trace("\n ...broken down by EFP-tier-aligned remaining "
+ "depth window (is this an EFP-tier-window effect?):\n");
+ for (v = 1; v < JOINT_EXTRA_TIER_COUNT; v++)
+ {
+ for (w = 0; w < POST_REDUCE_DEPTH_BUCKETS; w++)
+ {
+ Trace(" tier %u, %-32s : %5.2f%% silent-miss (%"
+ COMPILER_LONGLONG_UNSIGNED_FORMAT " tries)\n",
+ v, rgszBucketLabel[w],
+ (100.0 * (double)g_SuiteCounters.u64TotalJointExtraVerifySilentMiss[v][w] /
+ ((double)g_SuiteCounters.u64TotalJointExtraVerifyTries[v][w] + 1.0)),
+ g_SuiteCounters.u64TotalJointExtraVerifyTries[v][w]);
+ }
+ }
+ }
+ }
+#endif
+
+ // Joint 4D raw dump for offline reassembly/baking -- one line per
+ // populated cell, marginal (uncoarsened) indices, this checkpoint's
+ // cumulative totals (never summed across checkpoints by the bake
+ // script -- last checkpoint per shard is authoritative, matching
+ // every prior FHGAPRAW/FHJOINTRAW collection run in this project).
+ {
+ ULONG a, b, c;
+ for (a = 0; a < SEARCH_DEPTH_BUCKETS; a++)
+ {
+ for (b = 0; b < HANG_CP_BUCKETS; b++)
+ {
+ for (c = 0; c < GAP_CP_BUCKETS; c++)
+ {
+ for (v = 0; v < MOVE_NUM_BUCKETS; v++)
+ {
+ if (g_SuiteCounters.u64TotalJointTries[a][b][c][v] > 0)
+ {
+ Trace("FOURAXISRAW %lu %lu %lu %lu %"
+ COMPILER_LONGLONG_UNSIGNED_FORMAT " %"
+ COMPILER_LONGLONG_UNSIGNED_FORMAT "\n",
+ a, b, c, v,
+ g_SuiteCounters.u64TotalJointFH[a][b][c][v],
+ g_SuiteCounters.u64TotalJointTries[a][b][c][v]);
+ }
+ }
+ }
+ }
+ }
+ }
+
#ifdef TEST_NULL
// Null-move precision/recall calibration, summed across the whole
// suite (see _NullCalibBucketIndex's comment in search.c for the
@@ -676,6 +1182,18 @@ Return value:
FILE *p;
static CHAR szLine[SMALL_STRING_LEN_CHAR];
double dSuiteStart;
+ ULONG uGoCount = 0;
+ // 2026-09-28: a wall-clock `timeout`-killed shard (e.g. a 90-minute
+ // sharded joint-table collection run) never reaches true end-of-
+ // script, so _PrintSuiteReport's single end-of-suite call would
+ // never fire and a killed shard would contribute zero data. Print
+ // the same (cumulative, non-resetting) report every
+ // CHECKPOINT_GO_INTERVAL positions too -- the FOURAXISRAW dump (and
+ // every other line in the report) always reflects the full running
+ // total since suite start, so the *last* checkpoint printed before
+ // a kill is a complete, valid snapshot on its own; a bake script
+ // just needs to find and use the last one, never sum across them.
+#define CHECKPOINT_GO_INTERVAL (5)
if (argc < 2)
{
@@ -696,11 +1214,21 @@ Return value:
while(fgets(szLine, ARRAY_LENGTH(szLine), p))
{
_ProcessSuiteLine(szLine, pos);
+ if (!STRNCMPI(szLine, "go", 2))
+ {
+ uGoCount++;
+ if (0 == (uGoCount % CHECKPOINT_GO_INTERVAL))
+ {
+ Trace("\nCHECKPOINT after %lu positions:\n", uGoCount);
+ _PrintSuiteReport(dSuiteStart);
+ }
+ }
if (TRUE == g_fExitProgram) break;
}
fclose(p);
_PrintSuiteReport(dSuiteStart);
}
+#undef CHECKPOINT_GO_INTERVAL
}
diff --git a/src/search.c b/src/search.c
index 8f4779e..713d35e 100644
--- a/src/search.c
+++ b/src/search.c
@@ -76,10 +76,168 @@ extern FLAG g_fCanSplit[MAX_PLY_PER_SEARCH];
#define TRY_GENERATED_MOVES (3)
// EFP's fail-high-history exemption: below EFP_FH_MIN_SAMPLES
-// observations, GetMoveFailHighPercentage's result isn't trusted
+// observations, GetMoveFailHighPctThousandths's result isn't trusted
// enough to override the static-eval-based decision either way.
-#define EFP_FH_MIN_SAMPLES (5)
-#define EFP_FH_PRUNE_THRESHOLD (10)
+// THOUSANDTHS, not whole percent, since 2026-10-02's rename -- 100
+// thousandths == 10%, unchanged in effect from the old whole-percent
+// EFP_FH_PRUNE_THRESHOLD of 10.
+#define EFP_FH_MIN_SAMPLES (5)
+#define EFP_FH_PRUNE_THRESHOLD_PCT_THOUSANDTHS (10000) // 10%
+
+// 2026-10-02: the joint-table-driven graduated-LMR decision itself
+// (bucket helpers, _JointTablePredictedFHThousandths/_JointTableExtraPlies,
+// JOINT_EXTRA_TIER*_THOUSANDTHS, the floor/cap clamp) moved into
+// GetLMRReduction (searchsup.c) -- this file used to make that
+// decision as post-processing on top of GetLMRReduction's own
+// baseline, split across two files/two inconsistent dynamic checks
+// purely to minimize the original diff against HEAD's LMR. The move
+// loop below now just calls GetLMRReduction once and uses its result.
+//
+// EstimateHangingMaterialCp/HangCpBucket/GapCpBucket/MoveNumBucket/
+// DepthBucket moved there too but are no longer file-static -- the
+// measurement/collection block further below (gated on
+// fPruneReduceEligible alone, independent of the LMR decision -- this
+// is what's still generating the next round of g_uJointFHThousandths
+// data) still needs them, so they're declared in chess.h and called
+// from here unchanged in behavior, just by their new (un-underscored)
+// public names.
+
+#if (defined(PERF_COUNTERS) && defined(PRUNE_VERIFY)) || defined(PRUNE_LIVE)
+// Band a deep-prune candidate by the predicted FH% the decision was
+// made on (thousandths of a percent; post-Wilson-re-bake that value
+// already encodes the cell's own sample confidence -- data.c header).
+// Banding by the decision variable is what lets one run answer "is
+// 'prune if predicted <= K' sound" for every candidate K at once.
+#if defined(PERF_COUNTERS) && defined(PRUNE_VERIFY)
+static ULONG
+PruneVerifyBand(ULONG uPredicted)
+{
+ if (uPredicted == 0) return 0;
+ if (uPredicted == 1) return 1;
+ if (uPredicted == 2) return 2;
+ if (uPredicted <= 5) return 3;
+ if (uPredicted <= 10) return 4;
+ if (uPredicted <= 50) return 5;
+ if (uPredicted <= 500) return 6;
+ return 7;
+}
+#endif
+
+// Mirror of searchsup.c's file-static _ShipDepthBucket. Duplicated
+// rather than exported because this is calibration-only code that
+// should leave no trace in a normal build; if PRUNE_VERIFY ever
+// graduates to something permanent, hoist the real one into chess.h
+// instead of keeping two copies.
+static ULONG
+ShipDepthBucketPublic(ULONG uRawDepthBucket)
+{
+ if (uRawDepthBucket <= 9) return uRawDepthBucket;
+ if (uRawDepthBucket <= 13) return 10;
+ return 11;
+}
+#define JOINT_TABLE_SENTINEL_PUBLIC (0xFFFF)
+
+static ULONG
+ShipLateBucketPublic(ULONG uRawLateBucket)
+{
+ if ((uRawLateBucket >= 2) && (uRawLateBucket <= 13)) return uRawLateBucket - 2;
+ if (uRawLateBucket <= 21) return 12;
+ if (uRawLateBucket <= 31) return 13;
+ return 14;
+}
+
+// Local mirror of searchsup.c's _JointTablePredictedFHThousandths,
+// including its dumb-bucket-1 sentinel rule (that bucket is ~97%
+// SEE-scored losing captures/underpromotions misfiled onto the quiet
+// axis, so its literal low value is not a real "safe" signal).
+// Local mirrors of searchsup.c's file-static AttemptsBucket/RateBucket
+// (calibration-only; see ShipDepthBucketPublic's note on duplication).
+#if defined(PERF_COUNTERS) && defined(PRUNE_VERIFY)
+static ULONG
+PvAttBucket(ULONG uAttempts)
+{
+ if (0 == uAttempts) return 0;
+ if (1 == uAttempts) return 1;
+ if (2 == uAttempts) return 2;
+ if (uAttempts <= 4) return 3;
+ if (uAttempts <= 9) return 4;
+ if (uAttempts <= 24) return 5;
+ if (uAttempts <= 99) return 6;
+ return 7;
+}
+
+static ULONG
+PvRateBucket(ULONG uPct)
+{
+ if (0 == uPct) return 0;
+ if (uPct <= 100) return 1;
+ if (uPct <= 400) return 2;
+ if (uPct <= 900) return 3;
+ if (uPct <= 1900) return 4;
+ if (uPct <= 4900) return 5;
+ if (uPct <= 9900) return 6;
+ return 7;
+}
+#endif
+
+#define PRUNE_LIVE_SENTINEL JOINT_TABLE_SENTINEL_PUBLIC
+#define PruneLivePredict PruneVerifyPredict
+static ULONG
+PruneVerifyPredict(IN SEARCHER_THREAD_CONTEXT *ctx, IN MOVE mv,
+ IN ULONG uMoveNum, IN ULONG uLegalMoves,
+ IN SCORE iAlpha, IN SCORE iEval, IN ULONG uDepth,
+ IN BITBOARD bbMinor[2], IN BITBOARD bbRook[2],
+ OUT ULONG *puShipDepth)
+{
+ SCORE iHangCp = EstimateHangingMaterialCp(ctx, mv, uMoveNum, bbMinor, bbRook);
+ ULONG uDumb = HangCpBucket(iHangCp);
+ ULONG uShipD = ShipDepthBucketPublic(DepthBucket(uDepth / ONE_PLY));
+
+ *puShipDepth = uShipD;
+ if (1 == uDumb)
+ {
+ return JOINT_TABLE_SENTINEL_PUBLIC;
+ }
+ return g_uJointFHThousandths[uShipD][uDumb]
+ [GapCpBucket((SCORE)(iAlpha - iEval))]
+ [ShipLateBucketPublic(MoveNumBucket(uLegalMoves))];
+}
+#endif
+
+#ifdef JOINT_EXTRA_VERIFY
+// 2026-09-28: buckets the FULLY (base + extra) reduced search's actual
+// remaining depth by EFP's own two-tier margin-schedule boundaries
+// (search.c's "selective futility"/"extended futility" tiers, keyed to
+// ONE_PLY+THREE_QUARTERS_PLY and TWO_PLY+THREE_QUARTERS_PLY -- see that
+// code's own comment) rather than plain whole-ply buckets. The
+// depth-bucketed silent-miss calibration showed a spike specifically
+// at whole-ply-bucket "remaining depth 2", which straddles both EFP
+// tier boundaries -- this re-buckets on the ACTUAL boundaries to test
+// whether the spike is really an EFP-tier-window effect (a different
+// pruning mechanism behaving differently in that specific band) rather
+// than "shallower is chaotic" in the simple monotonic sense.
+static ULONG
+_PostReduceDepthBucketEfpAligned(ULONG uNextDepthRaw)
+{
+ if (uNextDepthRaw < ONE_PLY)
+ {
+ return 0; // shouldn't happen given the JOINT_EXTRA_MIN_REMAINING_DEPTH floor -- safety net
+ }
+ if (uNextDepthRaw <= (ONE_PLY + THREE_QUARTERS_PLY))
+ {
+ return 1; // EFP "selective futility" tier active
+ }
+ if (uNextDepthRaw <= (TWO_PLY + THREE_QUARTERS_PLY))
+ {
+ return 2; // EFP "extended futility" tier active
+ }
+ if (uNextDepthRaw <= (TWO_PLY + THREE_QUARTERS_PLY + TWO_PLY))
+ {
+ return 3; // no EFP, but still nearby
+ }
+ return 4; // no EFP, comfortably deeper
+}
+#endif
// EXPERIMENT: is history+continuation evidence predictive of a
// countermove match's own FH%? See chess.h's CM_EVIDENCE_BUCKETS
@@ -301,9 +459,72 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
ULONG uNextDepth;
ULONG uLegalMoves = 0;
FLAG fIsLeftoverMove = FALSE;
+ // Stamped once below, right after this node's own Eval() call and
+ // before any MakeMove in the move loop changes pos's live
+ // signature out from under it -- see EstimateHangingMaterialCp's
+ // comment (above _CMEvidenceBucket) for why this matters: bbMinor
+ // Attacks/bbRookAttacks are only trustworthy when they were
+ // written by a full, non-lazy Eval() call for THIS exact position.
+ FLAG fParentAttackTablesAreFresh;
+ // 2026-09-28: snapshotted into LOCAL (stack) storage at the same
+ // point fParentAttackTablesAreFresh is captured -- pos->bbMinor
+ // Attacks/bbRookAttacks themselves are NOT safe to read later in
+ // the move loop, because every recursive Search() call made while
+ // trying an earlier sibling move also calls Eval() on ITS OWN
+ // position, overwriting these same shared pos-> fields, and
+ // UnmakeMove does not restore them (they're eval scratch space,
+ // not real position state -- unlike u64NonPawnSig/u64PawnSig,
+ // which UnmakeMove DOES restore, which is exactly why the
+ // signature check above kept reporting "fresh" long after the
+ // underlying bitboards had been clobbered by a completely
+ // unrelated node deep in an earlier sibling's subtree). Confirmed
+ // empirically: every single real fail-high sampled in the queen-
+ // attacked-by-rook bucket turned out to have NO actual rook
+ // attacking the destination square once checked by hand -- this
+ // is why. Local copies can't be touched by recursion.
+ BITBOARD bbMinorAttacksSnapshot[2];
+ BITBOARD bbRookAttacksSnapshot[2];
+ // 2026-09-30: single shared "would we ever consider pruning or
+ // reducing this move" eligibility gate -- the unified gate EFP and
+ // LMR both consult now (formerly two separately-coded, slightly
+ // different checklists), plus the losing-capture admission rule
+ // the FH-table work has been testing (the old, separate gates
+ // excluded ALL captures/promotions unconditionally; this
+ // deliberately admits losing ones, via the SEE sign generate.c
+ // already cached on the move, matching the population the joint
+ // table was actually trained on -- see lmr_testing/SPEC.md's
+ // "gate-1" section). Computed ONCE per move, unconditionally (not
+ // just under PERF_COUNTERS -- it now gates live search behavior in
+ // every build profile), so a move that should never actually be
+ // pruned or reduced (a check, a check-escape, a PV node, an
+ // already-extended move, a node-wide threat, a mate-magnitude
+ // alpha) is excluded up front rather than patched around
+ // downstream. Note this deliberately does NOT include LMR's old
+ // uLegalMoves > 5 floor -- that requirement is dropped as part of
+ // unifying onto this gate, per direct instruction, since it was
+ // LMR-specific rather than part of the shared "unremarkable move"
+ // definition gate-1 describes.
+ FLAG fPruneReduceEligible = FALSE;
#ifdef PERF_COUNTERS
+ // See the node-type counter comment below: guards the surprise
+ // counters at end: so a node short-circuited by CommonSearchInit
+ // (and therefore never typed for this visit) cannot be scored
+ // against a stale eNodeType left by a previous node at this ply.
+ FLAG fNodeTypedThisVisit = FALSE;
FLAG fThisMoveIsCountermoveMatch = FALSE;
ULONG uCMEvidenceBucket = 0;
+ ULONG uThisMoveHangBucket = 0;
+ ULONG uThisMoveGapBucket = 0;
+ ULONG uThisMoveNumBucket = 0;
+ ULONG uThisMoveDepthBucket = 0;
+ // 2026-09-28: which joint-extra-reduction tier (0 == none, the
+ // GetLMRReduction baseline only) fired for THIS move, if any --
+ // stashed at decision time (LMR call site) and consulted again
+ // later, at the re-search-on-fail-high check, purely for the
+ // calibration counters below. Never influences search behavior by
+ // itself.
+ ULONG uThisMoveJointExtraTier = 0;
+ FLAG fThisMoveJointExtraEligible = FALSE;
#endif
HASH_ENTRY *pHash;
FLAG fThreat;
@@ -367,14 +588,42 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
{
goto end;
}
+
+#ifdef PERF_COUNTERS
+ // 2026-10-03: denominator for the node-type surprise line, counted
+ // HERE rather than inside CommonSearchInit, for two reasons that
+ // were both silently corrupting that metric:
+ //
+ // 1. FULL-WIDTH ONLY. CommonSearchInit is the single funnel both
+ // Search() and QSearch() pass through, so counting there swept
+ // in qsearch -- ~84% of the tree -- and a qsearch node whose
+ // stand-pat already beats beta returns iBestScore >= iBeta
+ // immediately. Typed ALL, that lands in "ALL failed high",
+ // making the headline number mostly a measure of stand-pat
+ // frequency. The question the line's name asks is about
+ // full-width node-type prediction, so qsearch is excluded from
+ // both numerator (below) and denominator (here).
+ // 2. NEVER STALE. Every early-exit path in CommonSearchInit (hash
+ // cutoff, repetition draw, ply/depth limit, EGTB hit) returns
+ // TRUE *before* pi->eNodeType is assigned, and the caller's
+ // `goto end` lands squarely on the surprise counters -- which
+ // then scored the node against whatever type the PREVIOUS node
+ // at this ply happened to leave behind. That is what produced
+ // a "PV failed high/low = 106.2% of PV nodes" reading: more
+ // surprises than nodes of that type, which is impossible
+ // unless some were attributed to a type the node never had.
+ // Reaching this line means CommonSearchInit returned FALSE,
+ // i.e. the type really was assigned for THIS visit.
+ //
+ // NOTE: the NODE_TYPE enum and every behavioral consumer of it
+ // (all of which only ever test against NODE_PV) are untouched.
+ fNodeTypedThisVisit = TRUE;
+ ASSERT((ULONG)pi->eNodeType < 3);
+ INC(ctx->sCounters.tree.u64NodeTypeCount[(ULONG)pi->eNodeType]);
+#endif
+
DTEnterNode(ctx, uDepth, FALSE, iAlpha, iBeta);
iInitialAlpha = iAlpha;
- // pi->eNodeType is set centrally by CommonSearchInit (see its own
- // comment) -- covers both Search() and QSearch() from one place.
- // Safe default until the real move-generation/scoring call below
- // sets this to whatever scheme actually ran (generate.c's four
- // _Score* functions and RescoreMovesViaSearch are the authoritative
- // setters -- see MOVE_SCORING_SCHEME's own comment in chess.h).
pi->eMoveScoringScheme = MOVE_SCORING_NORMAL;
ASSERT((IS_CHECKING_MOVE(mvLast) && (TRUE == pi->fInCheck)) ||
(!IS_CHECKING_MOVE(mvLast) && (FALSE == pi->fInCheck)));
@@ -504,6 +753,28 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
{
iImprovement = (iEval - ctx->sPlyInfo[ctx->uPly - 2].iEval);
}
+ // Must be captured HERE, before any MakeMove in the loop below
+ // changes pos's live signature out from under it.
+ fParentAttackTablesAreFresh =
+ (pi->u64FullEvalSig == (pos->u64NonPawnSig ^ pos->u64PawnSig));
+
+ // Snapshot into local storage RIGHT HERE too, same reasoning --
+ // see this array's declaration comment. If not fresh, zero them
+ // rather than leave garbage; EstimateHangingMaterialCp still
+ // checks fParentAttackTablesAreFresh first and won't consult
+ // these when it's FALSE, but zeroing avoids depending on that.
+ if (TRUE == fParentAttackTablesAreFresh)
+ {
+ bbMinorAttacksSnapshot[WHITE] = pos->bbMinorAttacks[WHITE];
+ bbMinorAttacksSnapshot[BLACK] = pos->bbMinorAttacks[BLACK];
+ bbRookAttacksSnapshot[WHITE] = pos->bbRookAttacks[WHITE];
+ bbRookAttacksSnapshot[BLACK] = pos->bbRookAttacks[BLACK];
+ }
+ else
+ {
+ bbMinorAttacksSnapshot[WHITE] = bbMinorAttacksSnapshot[BLACK] = 0;
+ bbRookAttacksSnapshot[WHITE] = bbRookAttacksSnapshot[BLACK] = 0;
+ }
GENERATE_NO_MOVES;
@@ -655,6 +926,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
GenerateMoves(ctx, mvHash, GENERATE_ESCAPES);
if (MOVE_COUNT(ctx, ctx->uPly))
{
+ // TODO: Experiment with removing these extensions.
if (NUM_CHECKING_PIECES(ctx, ctx->uPly) > 1)
{
iOrigExtend += QUARTER_PLY;
@@ -762,28 +1034,17 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
(ctx->sPlyInfo[ctx->uPly - 2].iExtensionAmount <= 0))
{
ASSERT(uDepth >= THREE_QUARTERS_PLY);
+ ASSERT(iAlpha + 1 == iBeta);
if (iEval < iAlpha)
{
- // 2026-09-17: tried folding + pi->iPositional[
- // pos->uToMove] into both conditions below (same
- // role as QSearch's FUTILITY_BASE_MARGIN_BY_
- // SOURCE[tier] + iPositional[side]) -- rolled
- // back, untested in isolation, after a bundled
- // self-play run (also carrying the margin_a/
- // margin_b percentile switch, the QSearch
- // futility margin recalibration, the _EvalKing
- // fix, and the drawish-in-both-lazy-tiers change)
- // came back flat/slightly negative with no way
- // to attribute the result to any one change. See
- // TODO (EFP_BASE_MARGIN_BY_SOURCE entry) -- the
- // idea itself isn't rejected, just never
- // validated on its own. PLY_INFO.iPositional
- // (chess.h) and the Eval() call populating it
- // (just above, in this function) are left in
- // place -- useful per-ply infrastructure
- // regardless of whether this exact gate ends up
- // using it.
+ // TODO: EFP is 100% based on the "gap" dimension: i.e.,
+ // the distance between iEval and iAlpha makes a FH under
+ // this move unlikely. However, there are other good
+ // signals that we are ignoring here. Such as: does the
+ // move seem to hang material? If so, we should prune
+ // more willingly. If not, we should be less eager to
+ // prune here (maybe).
if ((uDepth <= ONE_PLY + THREE_QUARTERS_PLY) &&
(iEval + VALUE_KNIGHT + iImprovement <= iAlpha))
{
@@ -805,6 +1066,7 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
if (x < ctx->sMoveStack.uEnd[ctx->uPly])
{
ASSERT(x >= ctx->sMoveStack.uBegin[ctx->uPly]);
+
// Always fully select the best remaining move,
// regardless of tier -- retired the old
// NumLeftoverMovesToSelect budget (a depth-indexed
@@ -835,6 +1097,19 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
// never classifies an IID-rescored move as a
// leftover (matches pre-retirement behavior).
fIsLeftoverMove = FALSE;
+#ifdef PERF_COUNTERS
+ // Reset every iteration, BEFORE the LMR decision
+ // block (below) can set it for this move -- without
+ // this, a move that skips the extra-reduction
+ // branch entirely (e.g. GetLMRReduction's own
+ // baseline didn't fire) would silently inherit and
+ // re-report a PRIOR move's stale tier at the
+ // re-search-check site later in this same
+ // iteration... except stale across ITERATIONS, not
+ // within one -- this reset must happen before this
+ // iteration's own LMR decision, not after.
+ fThisMoveJointExtraEligible = FALSE;
+#endif
if (MOVE_SCORING_IID_RESCORED == pi->eMoveScoringScheme)
{
SelectBestNoHistory(ctx, x);
@@ -850,12 +1125,12 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
MVF_MOVE_SEARCHED));
ctx->sMoveStack.mvf[x].bvFlags |= MVF_MOVE_SEARCHED;
#endif
+#ifdef PERF_COUNTERS
// Countermove evidence calibration -- ongoing check
// that COUNTERMOVE_EVIDENCE_THRESHOLD (chess.h) is
// still well-calibrated: log every countermove-
// matched move tried, bucketed by its own
// accumulated history+continuation evidence.
-#ifdef PERF_COUNTERS
fThisMoveIsCountermoveMatch = FALSE;
if ((!IS_CAPTURE_OR_PROMOTION(mv)) &&
(ctx->uPly > 0) &&
@@ -1032,17 +1307,64 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
// sole extension-runaway guard now; see root.c's construction
// of that table.
+ // See this flag's declaration comment above. Computed
+ // unconditionally now -- both EFP and LMR gate live
+ // pruning/reduction decisions on it, not just the
+ // PERF_COUNTERS instrumentation that used to be its only
+ // consumer.
+ //
+ // Note: no separate (x != 0) check -- x is only ever 0
+ // during the TRY_HASH_MOVE stage's single iteration, which
+ // is always the very first move tried at this node
+ // (uLegalMoves == 0 going in, per that stage's own ASSERT),
+ // so uLegalMoves can be at most 1 while x == 0. The
+ // (uLegalMoves > 1) test below already rules that out.
+ ASSERT((uLegalMoves > 1) ? (x != 0) : TRUE);
+ fPruneReduceEligible =
+ (uLegalMoves > 1) &&
+ (NODE_PV != pi->eNodeType) &&
+ (TRUE == fIsLeftoverMove) &&
+ (iExtend <= 0) &&
+ (!IS_ESCAPING_CHECK(mv)) &&
+ (!IS_CHECKING_MOVE(mv)) &&
+ (!fThreat) &&
+ (iAlpha < +NMATE) &&
+ ((!IS_CAPTURE_OR_PROMOTION(mv)) ||
+ (!(ctx->sMoveStack.mvf[x - 1].bvFlags & MVF_SEE_NONNEGATIVE)));
+
+ FLAG fThisMoveLMPPruned = FALSE;
+ if (TRUE == fPruneReduceEligible)
+ {
+ fThisMoveLMPPruned = ShouldLatePrune(
+ iEval, iAlpha, ctx, uDepth,
+ uLegalMoves, mv, (x - 1),
+ bbMinorAttacksSnapshot,
+ bbRookAttacksSnapshot,
+ NULL);
+#ifdef PERF_COUNTERS
+ if (TRUE == fThisMoveLMPPruned)
+ {
+ INC(ctx->sCounters.tree.u64LMPPruned);
+ }
+#endif
+ }
+
// Decide how much (if any) to reduce this move's depth --
- // graded LMR.
+ // graded LMR. Gated on the unified fPruneReduceEligible now
+ // (2026-09-30) instead of its own separately-coded
+ // checklist -- note this deliberately drops the old
+ // uLegalMoves > 5 floor (fPruneReduceEligible only requires
+ // > 1), and newly admits losing captures (fPruneReduceEligible's
+ // SEE-sign rule) where the old checklist excluded all
+ // captures unconditionally. uDepth > TWO_PLY stays as LMR's
+ // own extra threshold on top -- not part of the shared gate.
+ // Skipped for an LMP-pruned move -- no point grading a
+ // reduction for a move that's not being searched at all.
if ((uDepth > TWO_PLY) &&
- (pi->eNodeType != NODE_PV) &&
- (uLegalMoves > 5) &&
- (fIsLeftoverMove) &&
- (0 == iExtend) &&
- (!IS_ESCAPING_CHECK(mv)) &&
- (!IS_CAPTURE_OR_PROMOTION(mv)) &&
- (!IS_CHECKING_MOVE(mv)))
+ (TRUE == fPruneReduceEligible) &&
+ (FALSE == fThisMoveLMPPruned))
{
+ ULONG uExtraTier = 0;
INT iLMR = GetLMRReduction(iEval,
iAlpha,
iBeta,
@@ -1052,62 +1374,86 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
uLegalMoves,
mv,
(x - 1), // Note: x==0 if hash
- iExtend);
+ iExtend,
+ bbMinorAttacksSnapshot,
+ bbRookAttacksSnapshot,
+ &uExtraTier);
if (iLMR < 0)
{
ASSERT(iExtend == 0);
iExtend = iLMR;
+#ifdef PERF_COUNTERS
+ uThisMoveJointExtraTier = uExtraTier;
+ fThisMoveJointExtraEligible = TRUE;
+ INC(ctx->sCounters.tree.u64JointExtraTries[uExtraTier]);
+#endif
}
}
// Extended futility pruning -- per-move checklist. EFP
// pruning a fail-high is unrecoverable (unlike an LMR
- // reduction, which only delays discovery), so this is
- // deliberately stricter than GetLMRReduction's own
- // checklist, not just a copy of it: explicit capture/
- // promotion/checking-move exemptions (not just an ASSERT
- // that they can't reach here, which is all the old code
- // had), a killer-adjacency exemption (ply-1 and ply-3,
- // borrowed from GetLMRReduction), a well-evidenced
- // fail-high-history exemption (GetMoveFailHighPercentage,
- // requiring at least EFP_FH_MIN_SAMPLES observations before
- // trusting the percentage either way), an en-prise-escape
- // exemption, and a node-wide suppression when this node's
- // own null-move probe raised fThreat. See
- // lmr_testing/RESULTS.md for the individual experiments
- // that arrived at this checklist.
+ // reduction, which only delays discovery), so on top of the
+ // unified fPruneReduceEligible gate this keeps its own,
+ // stricter downstream checks: a killer-adjacency exemption
+ // (ply-1 and ply-3, borrowed from GetLMRReduction), a
+ // well-evidenced fail-high-history exemption
+ // (GetMoveFailHighPctThousandths, requiring at least
+ // EFP_FH_MIN_SAMPLES observations before trusting the
+ // rate either way). See lmr_testing/RESULTS.md for
+ // the individual experiments that arrived at this
+ // checklist.
//
- // Explicit leftover-only gate (fIsLeftoverMove, this move's
- // own iValue < GOOD_MOVE): every high-performer move
- // (winning/even capture, killer, killer-mate, sufficiently-
- // evidenced countermove match) is excluded from pruning
- // consideration by construction, not just as a side effect
- // of the capture/check/killer exemptions above happening to
- // cover the same ground. Belt-and-suspenders on purpose --
- // this is the one thing that must never be true of a move
- // we skip outright.
- fThisMoveEFPPruned = FALSE;
- if ((x != 0) &&
- (uLegalMoves > 1) &&
- (uFutilityMargin) &&
- (TRUE == fIsLeftoverMove) &&
- (iExtend <= 0) &&
- (!IS_ESCAPING_CHECK(mv)) &&
- (!IS_CAPTURE_OR_PROMOTION(mv)) &&
- (!IS_CHECKING_MOVE(mv)) &&
- (!fThreat) &&
- (ComputeMoveScore(ctx, mv, (x - 1)) < uFutilityMargin))
+ // Gated on fPruneReduceEligible now (2026-09-30) instead of
+ // its own separately-coded checklist -- this newly admits
+ // losing captures (fPruneReduceEligible's SEE-sign rule)
+ // where the old checklist excluded all captures
+ // unconditionally, matching the population the joint table
+ // was actually trained on (see lmr_testing/SPEC.md's
+ // "gate-1" section).
+ //
+ // No separate ComputeMoveScore(...) < uFutilityMargin test
+ // -- verified always true given fPruneReduceEligible, not
+ // just an ASSERT of convenience: fPruneReduceEligible's
+ // (!IS_ESCAPING_CHECK(mv)) already rules out
+ // MOVE_SCORING_ESCAPE (generate.c's _ScoreAllEscapes sets
+ // MOVE_FLAG_ESCAPING_CHECK unconditionally on every move it
+ // scores, so a node whose own side is in check can never
+ // reach here), leaving only MOVE_SCORING_NORMAL live at
+ // this call site. Under that scheme, DecodeMoveValueEstimate
+ // (searchsup.c) returns MIN0(iRawValue) for every leftover
+ // move -- an ordinary quiet leftover's iValue (PSQT only,
+ // 0..1000, generate.c's _ScoreAllMoves) is always < SORT_
+ // THESE_FIRST so it clamps to exactly 0, and a losing
+ // capture's iValue is already negative (SEE's own result,
+ // never bumped into the winning-capture SORT_THESE_FIRST
+ // tier) so it passes through unchanged -- either way <= 0.
+ // uFutilityMargin is always > 0 here (the truthy check just
+ // above). So (<=0) < (>0) always, for this call site only --
+ // this equivalence is NOT claimed for any other caller of
+ // ComputeMoveScore.
+ if ((FALSE == fThisMoveEFPPruned) &&
+ (TRUE == fPruneReduceEligible) &&
+ (uFutilityMargin))
{
ULONG uFHAttempts = 0;
- ULONG uFHPct = GetMoveFailHighPercentage(mv, &uFHAttempts);
+ ULONG uFHPctThousandths = GetMoveFailHighPctThousandths(mv, &uFHAttempts);
fThisMoveEFPPruned =
((uFHAttempts < EFP_FH_MIN_SAMPLES) ||
- (uFHPct <= EFP_FH_PRUNE_THRESHOLD)) &&
+ (uFHPctThousandths <= EFP_FH_PRUNE_THRESHOLD_PCT_THOUSANDTHS)) &&
(!IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-1][0])) &&
(!IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-1][1])) &&
((ctx->uPly < 3) ||
(!IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-3][0]) &&
!IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-3][1])));
+#ifdef DISABLE_EFP
+ // Audit-only: everything above still runs (so
+ // uFutilityMargin, the FH-history lookup, etc. are all
+ // still exercised/measurable), just never actually
+ // prunes -- isolates the joint-table LMR's own
+ // behavior from EFP's single-axis margin pruning. See
+ // GNUmakefile's DISABLE_EFP comment.
+ fThisMoveEFPPruned = FALSE;
+#endif
}
if (TRUE == fThisMoveEFPPruned)
{
@@ -1121,6 +1467,84 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
if (TRUE == fIsLeftoverMove)
{
INC(ctx->sCounters.tree.u64LeftoverTries);
+ // 2026-09-28: gated on fPruneReduceEligible now,
+ // not just fIsLeftoverMove -- excludes checks,
+ // check-escapes, PV nodes, already-extended moves,
+ // threat nodes, and mate-magnitude alpha up front,
+ // matching HEAD's own EFP/LMR eligibility gates
+ // (see that flag's declaration comment). Captures
+ // and quiet moves both flow into the SAME buckets
+ // again -- back to measuring the combined signal,
+ // now with the contaminating populations removed
+ // instead of split apart to isolate them. Computed
+ // here (before UnmakeMove) and reused at the
+ // FH-side increment below -- calling this again
+ // after UnmakeMove would evaluate the WRONG
+ // (restored, side-to-move-flipped-back) position.
+ // 2026-10-03: this whole 4D-collection block is now
+ // gated on FH_TABLE_COLLECTION rather than riding
+ // along with PERF_COUNTERS. It is COLLECTION
+ // instrumentation -- it exists to build
+ // g_uJointFHThousandths, not to report on a normal
+ // search -- and CLAUDE.md already says
+ // FH_TABLE_COLLECTION is deliberately kept out of
+ // the standard profile precisely because this
+ // measurably costs nps. This block escaped that
+ // gating.
+ //
+ // It is not a counter increment: it calls
+ // EstimateHangingMaterialCp per eligible move, which
+ // does real attack-bitboard work. It was also being
+ // computed TWICE per move in any build that also had
+ // a prune gate enabled, since that gate needs the
+ // same hang bucket -- which inflated the candidate's
+ // nps deficit in the PRUNE_LIVE self-play match
+ // (-6.0% vs head) and made that match mildly unfair
+ // to the candidate.
+#ifdef FH_TABLE_COLLECTION
+ if (TRUE == fPruneReduceEligible)
+ {
+ SCORE iHangCp;
+
+ INC(ctx->sCounters.tree.u64EligibleLeftoverTries);
+ iHangCp = EstimateHangingMaterialCp(
+ ctx, mv, x - 1,
+ bbMinorAttacksSnapshot,
+ bbRookAttacksSnapshot);
+ uThisMoveHangBucket = HangCpBucket(iHangCp);
+ INC(ctx->sCounters.tree.u64HangEstimateTries[uThisMoveHangBucket]);
+
+ // "Down axis": how far below alpha is this
+ // node's own static eval? Same eligibility
+ // gate as the dumb axis -- iAlpha/iEval are
+ // both plain node-local SCOREs (not shared,
+ // mutable pos-> fields like bbRookAttacks
+ // was), so no snapshot/staleness concern here.
+ uThisMoveGapBucket = GapCpBucket(
+ (SCORE)(iAlpha - iEval));
+ INC(ctx->sCounters.tree.u64GapEstimateTries[uThisMoveGapBucket]);
+
+ // "Late axis": this move's own 1-based ordinal
+ // position among moves actually tried here.
+ uThisMoveNumBucket = MoveNumBucket(uLegalMoves);
+ INC(ctx->sCounters.tree.u64MoveNumEstimateTries[uThisMoveNumBucket]);
+
+ // "Depth axis": remaining search depth at
+ // this node, before this move's own
+ // extension/reduction.
+ uThisMoveDepthBucket = DepthBucket(uDepth / ONE_PLY);
+ INC(ctx->sCounters.tree.u64DepthEstimateTries[uThisMoveDepthBucket]);
+
+ // Joint 4D cell, same four bucket indices, in
+ // addition to (not instead of) the marginals
+ // above -- keeps the marginals live this run as
+ // a cheap cross-check that the joint reduces to
+ // what we already understand.
+ INC(ctx->sCounters.tree.u64JointTries
+ [uThisMoveDepthBucket][uThisMoveHangBucket]
+ [uThisMoveGapBucket][uThisMoveNumBucket]);
+ }
+#endif
}
if (TRUE == fThisMoveIsCountermoveMatch)
{
@@ -1179,10 +1603,252 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
// Research deeper if history pruning failed
if ((iExtend < 0) && (iScore >= iBeta))
{
+#ifdef PERF_COUNTERS
+ // Calibration for the joint-extra-reduction tiers
+ // above: did the tier this move was assigned (0 ==
+ // GetLMRReduction's baseline only, 1-3 == extra
+ // plies the table added) actually predict a
+ // re-search here? If low-predicted-FH tiers show an
+ // ELEVATED research rate rather than a lower one,
+ // that's the distribution-shift failure this
+ // project's history keeps producing, caught here
+ // (a suite run) instead of in a self-play match.
+ if (TRUE == fThisMoveJointExtraEligible)
+ {
+ INC(ctx->sCounters.tree.u64JointExtraResearch[uThisMoveJointExtraTier]);
+ }
+#endif
uNextDepth -= iExtend; // undo the full reduction, whatever its magnitude
pi->iExtensionAmount = 0;
iScore = -Search(ctx, -iBeta, -iAlpha, uNextDepth);
}
+#if defined(PERF_COUNTERS) && defined(JOINT_EXTRA_VERIFY)
+ // "Silent miss" shadow verification -- only reachable
+ // when the branch above did NOT fire, i.e. this move's
+ // extra-reduced score was < beta and so triggered no
+ // real re-search. Would removing just the EXTRA plies
+ // (restoring the depth HEAD's own baseline -ONE_PLY
+ // reduction alone would have used) have found a
+ // fail-high we're about to silently miss? Full
+ // coverage, every extra-reduced move that reaches here
+ // -- see GNUmakefile's JOINT_EXTRA_VERIFY comment for
+ // why this is calibration-only, never a normal build.
+ else if ((TRUE == fThisMoveJointExtraEligible) &&
+ (uThisMoveJointExtraTier > 0))
+ {
+ ULONG uShadowDepth =
+ uNextDepth + (uThisMoveJointExtraTier * ONE_PLY);
+ SCORE iShadowScore;
+#ifdef NEUTRALIZE_NULL_BAILOUT
+ // 2026-09-29: searchsup.c's null-move "history-
+ // reduced bailout" branch (Tord Romstad idea) reads
+ // (pi-1)->iExtensionAmount -- OUR OWN reduction on
+ // mv -- to decide whether to fabricate a "please
+ // re-search me" placeholder score instead of a
+ // real one. In a real move-loop search that's
+ // correct: the fabricated score negates up to
+ // exactly iBeta+1 one level up, which search.c's
+ // own re-search-on-fail-high machinery correctly
+ // interprets and acts on. Our shadow verification
+ // is a bespoke one-off Search() call with no such
+ // wrapper, so we'd misread that signal as a
+ // genuinely-computed fail-high. Temporarily zero
+ // OUR OWN pi->iExtensionAmount so this one specific
+ // check can't fire for mv -- everything deeper in
+ // the shadow subtree still goes through the normal
+ // move loop (with its wrapper intact) if the same
+ // mechanism legitimately fires there for some
+ // other, unrelated reduced move.
+ INT iSavedExtensionAmount = pi->iExtensionAmount;
+ pi->iExtensionAmount = 0;
+#endif
+ iShadowScore =
+ -Search(ctx, -iBeta, -iAlpha, uShadowDepth);
+#ifdef NEUTRALIZE_NULL_BAILOUT
+ pi->iExtensionAmount = iSavedExtensionAmount;
+#endif
+#ifdef SILENT_MISS_TRACE
+ // UpdatePV is only ever called on the iScore >
+ // iAlpha branch that does NOT also fail high
+ // (search.c's main move loop) -- a beta cutoff
+ // jumps straight to "end" without touching the PV
+ // array at all, since a fail-high score is only a
+ // lower bound, not a real line. Since our whole
+ // diagnostic signal IS a fail-high (iShadowScore >=
+ // iBeta), WalkPV would always come back empty here
+ // -- not useful. What DOES get written on a cutoff
+ // is the hash table (StoreLowerBound, just before
+ // that same "end" jump), so walk a short hash
+ // chain instead, the same side-effect-free idiom
+ // FinishPVTailFromHash (util.c) already uses for
+ // exactly this purpose.
+ CHAR szShadowPV[SMALL_STRING_LEN_CHAR] = "";
+ {
+ MOVE mvChain[8];
+ ULONG uChainLen = 0;
+ MOVE mvNext;
+
+ while ((uChainLen < 8) &&
+ (0 != (mvNext = GetPonderMove(pos)).uMove))
+ {
+ strncat(szShadowPV, MoveToSan(mvNext, pos),
+ ARRAY_LENGTH(szShadowPV) - strlen(szShadowPV) - 1);
+ strncat(szShadowPV, " ",
+ ARRAY_LENGTH(szShadowPV) - strlen(szShadowPV) - 1);
+ mvChain[uChainLen++] = mvNext;
+ if (FALSE == MakeMove(ctx, mvNext))
+ {
+ uChainLen--;
+ break;
+ }
+ }
+ while (uChainLen > 0)
+ {
+ UnmakeMove(ctx, mvChain[--uChainLen]);
+ }
+ }
+#endif
+ // EFP-tier-aligned bucket for the FULLY (base +
+ // extra) reduced search's actual remaining depth --
+ // see _PostReduceDepthBucketEfpAligned's comment.
+ ULONG uPostReduceDepthBucket =
+ _PostReduceDepthBucketEfpAligned(uNextDepth);
+
+ INC(ctx->sCounters.tree.u64JointExtraVerifyTries
+ [uThisMoveJointExtraTier][uPostReduceDepthBucket]);
+ if (iShadowScore >= iBeta)
+ {
+ INC(ctx->sCounters.tree.u64JointExtraVerifySilentMiss
+ [uThisMoveJointExtraTier][uPostReduceDepthBucket]);
+ if (iShadowScore == (iBeta + 1))
+ {
+ INC(ctx->sCounters.tree.u64JointExtraVerifyNullBailoutSignature
+ [uThisMoveJointExtraTier]);
+ }
+#ifdef SILENT_MISS_TRACE
+ // pos currently reflects the position AFTER mv
+ // (MakeMove already ran earlier this
+ // iteration, UnmakeMove hasn't run yet) --
+ // pi->sPosition (the pre-move parent) is
+ // DEBUG-only, not available in a release
+ // build, so print the POST-move FEN plus the
+ // move in coordinate notation (MoveToIcs takes
+ // just the move, no pre-move disambiguation
+ // context needed) rather than SAN.
+ Trace("SILENT_MISS_TRACE tier=%u bucket=%u "
+ "uNextDepth=%lu uShadowDepth=%lu "
+ "alpha=%d beta=%d "
+ "realScore=%d shadowScore=%d "
+ "move=%s postMoveFen=\"%s\" "
+ "shadowRefutation=\"%s\"\n",
+ uThisMoveJointExtraTier,
+ uPostReduceDepthBucket,
+ uNextDepth, uShadowDepth,
+ iAlpha, iBeta,
+ iScore, iShadowScore,
+ MoveToIcs(mv),
+ PositionToFen(pos),
+ szShadowPV);
+#endif
+ }
+ }
+#endif
+#if defined(PERF_COUNTERS) && defined(PRUNE_VERIFY)
+ // The audited move has now had its ordinary search. If
+ // it failed high, the deep-prune gate would have thrown
+ // away a move that actually raised beta -- a silent
+ // miss, attributed to the band/depth the decision was
+ // made in. The margin is accumulated so that
+ // boundary-noise clears (a score that only just beats a
+ // tight inherited PVS window, JOINT_EXTRA_VERIFY's
+ // documented false-positive class) can be separated
+ // from real misses afterwards rather than hand-traced.
+ if (TRUE == fVerifyFlagged)
+ {
+ if (iScore >= iBeta)
+ {
+ INC(ctx->sCounters.tree.u64PruneVerifyFailHigh
+ [uVerifyBand][uVerifyDepthB]);
+ if (uVerifyBand <= 4)
+ {
+ INC(ctx->sCounters.tree.u64PruneVerifyDynMiss
+ [uVerifyAttB][uVerifyRateB]);
+ }
+ ctx->sCounters.tree.u64PruneVerifyMarginSum
+ [uVerifyBand][uVerifyDepthB] +=
+ (UINT64)(iScore - iBeta);
+#ifdef SILENT_MISS_TRACE
+ // pos is the POST-move position here (UnmakeMove
+ // has not run yet) -- same convention as the
+ // JOINT_EXTRA_VERIFY trace above.
+ // 2026-10-03: log the DYNAMIC signals alongside
+ // the static prediction, so a real miss can be
+ // interrogated for "what would have saved us, and
+ // at what threshold". Motivated by the band-0
+ // miss at
+ // 6k1/p4p1p/3p2q1/2p1rP2/2P3p1/P7/1P1Qn1PK/3R1R2,
+ // Qg6 -- a move whose queen only LOOKS hung
+ // (White is losing too badly to take it; best
+ // line grabs the e2 knight and White is -14 at
+ // depth 19), i.e. a genuine structural
+ // mispredict of the dumbness axis, which no
+ // amount of sampling conservatism in the static
+ // table can catch.
+ //
+ // fhpct/fhatt: this exact move identity's own
+ // observed rate and sample count
+ // (GetMoveFailHighPctThousandths) -- answers
+ // "what GetMoveFailHigh threshold would have
+ // vetoed this".
+ // cm: countermove-table level -- 0 none, 1 slot
+ // 0 match, 2 slot 1 match, against the move
+ // played two plies back (the index
+ // generate.c's _ScoreAllMoves uses).
+ // hist/cont: accumulated history and
+ // continuation-history evidence for this
+ // [piece][to].
+ // killer: 1 if this ply's killer, 2 if ply-3's.
+ {
+ ULONG uDynAtt = 0;
+ ULONG uDynPct = GetMoveFailHighPctThousandths(mv, &uDynAtt);
+ ULONG uCmLevel = 0;
+ ULONG uHist = g_HistoryCounters[mv.pMoved][mv.cTo];
+ ULONG uKiller = 0;
+
+ if (ctx->uPly >= 2)
+ {
+ MOVE mvPrev = ctx->sPlyInfo[ctx->uPly - 2].mv;
+ if (0 != mvPrev.uMove)
+ {
+ ULONG uCi = MOVE_TO_INDEX(mvPrev);
+ if (IS_SAME_MOVE(mv, ctx->mvCounter[uCi][0])) uCmLevel = 1;
+ else if (IS_SAME_MOVE(mv, ctx->mvCounter[uCi][1])) uCmLevel = 2;
+ }
+ }
+ if (IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-1][0]) ||
+ IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-1][1]))
+ {
+ uKiller = 1;
+ }
+ else if ((ctx->uPly >= 3) &&
+ (IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-3][0]) ||
+ IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-3][1])))
+ {
+ uKiller = 2;
+ }
+ Trace("PRUNEMISS pred=%u band=%u depthb=%u "
+ "alpha=%d beta=%d score=%d margin=%d "
+ "fhpct=%u fhatt=%u cm=%u hist=%u killer=%u "
+ "mv=%s postfen=%s\n",
+ uVerifyPred, uVerifyBand, uVerifyDepthB,
+ iAlpha, iBeta, iScore, (iScore - iBeta),
+ uDynPct, uDynAtt, uCmLevel, uHist, uKiller,
+ MoveToIcs(mv), PositionToFen(pos));
+ }
+#endif
+ }
+ }
+#endif
UnmakeMove(ctx, mv);
pf->iCumulativeExtend -= iExtend;
ASSERT(PositionsAreEquivalent(pos, &pi->sPosition));
@@ -1209,6 +1875,24 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
if (TRUE == fIsLeftoverMove)
{
INC(ctx->sCounters.tree.u64LeftoverFH);
+ // Gated on fPruneReduceEligible now,
+ // matching the tries side -- must
+ // match exactly, or this would reuse a
+ // stale bucket left over from a
+ // different (eligible) move for one
+ // that never got a bucket computed
+ // this iteration.
+ if (TRUE == fPruneReduceEligible)
+ {
+ INC(ctx->sCounters.tree.u64EligibleLeftoverFH);
+ INC(ctx->sCounters.tree.u64HangEstimateFH[uThisMoveHangBucket]);
+ INC(ctx->sCounters.tree.u64GapEstimateFH[uThisMoveGapBucket]);
+ INC(ctx->sCounters.tree.u64MoveNumEstimateFH[uThisMoveNumBucket]);
+ INC(ctx->sCounters.tree.u64DepthEstimateFH[uThisMoveDepthBucket]);
+ INC(ctx->sCounters.tree.u64JointFH
+ [uThisMoveDepthBucket][uThisMoveHangBucket]
+ [uThisMoveGapBucket][uThisMoveNumBucket]);
+ }
}
if (TRUE == fThisMoveIsCountermoveMatch)
{
@@ -1374,9 +2058,29 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx,
ASSERT(IS_VALID_SCORE(iBestScore) || WE_SHOULD_STOP_SEARCHING);
ASSERT(PositionsAreEquivalent(pos, &pi->sPosition));
#ifdef PERF_COUNTERS
- if ((!WE_SHOULD_STOP_SEARCHING) && IS_VALID_SCORE(iBestScore))
+ // fNodeTypedThisVisit: a node short-circuited by CommonSearchInit
+ // (hash cutoff, repetition draw, ply/depth limit, EGTB hit) returns
+ // here via `goto end` WITHOUT pi->eNodeType having been assigned for
+ // this visit, so scoring it would attribute the outcome to whatever
+ // type the previous node at this ply left behind. That is what made
+ // this line report "PV failed high/low = 106.2% of PV nodes" --
+ // more surprises than nodes of that type.
+ if ((!WE_SHOULD_STOP_SEARCHING) && IS_VALID_SCORE(iBestScore) &&
+ (TRUE == fNodeTypedThisVisit))
{
- FLAG fFailedLow = (iBestScore <= iAlpha);
+ // 2026-10-03: compare against iInitialAlpha, NOT iAlpha.
+ // search.c:2048 raises iAlpha IN PLACE as moves improve the
+ // score, so by the time we reach end: a node that SUCCESSFULLY
+ // raised alpha has iAlpha == iBestScore -- and the old
+ // (iBestScore <= iAlpha) test then read TRUE, scoring the
+ // ordinary success case as a "fail low". That is why the PV row
+ // reported 99.1% surprised: it was measuring "alpha was raised"
+ // rather than "the node-type prediction was wrong".
+ // iInitialAlpha is the node's alpha on entry (stashed at
+ // search.c:626 and already used for exactly this distinction at
+ // search.c:2105), which is what a fail-low must be judged
+ // against.
+ FLAG fFailedLow = (iBestScore <= iInitialAlpha);
FLAG fFailedHigh = (iBestScore >= iBeta);
switch (pi->eNodeType)
{
@@ -2080,7 +2784,27 @@ QSearchFromCheckNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx,
uQsearchCheckExtension = 2;
INC(ctx->sCounters.extension.uQExtend);
}
- ctx->sPlyInfo[ctx->uPly].iExtensionAmount = uQsearchCheckExtension;
+ // 2026-10-03: was `= uQsearchCheckExtension` (0 or 2), a
+ // UNIT MISMATCH. uQsearchCheckExtension counts qsearch
+ // CHECK PLIES (it is added to pf->uQsearchCheckDepth), but
+ // iExtensionAmount is ONE_PLY-scaled everywhere else in the
+ // codebase -- root.c writes HALF_PLY (32), split.c writes
+ // iLMR (a negative multiple of 64), search.c writes
+ // iExtend. Writing 2 here meant 2/64 of a ply in this
+ // field's own units.
+ //
+ // Benign as it stood, because both readers only test sign
+ // (searchsup.c's Tord Romstad null-move bailout tests
+ // `< 0`; search.c's EFP gate tests `<= 0`) and 2 behaves
+ // like any positive extension. But this field is already a
+ // documented stale-state hazard (see the "Record the
+ // extension/reduction actually applied to THIS move"
+ // comment in the main move loop), so a latent 32x error
+ // sitting in it is not worth keeping. Scaled to the field's
+ // real units; the sign, and therefore every current
+ // reader's behavior, is unchanged.
+ ctx->sPlyInfo[ctx->uPly].iExtensionAmount =
+ (INT)(uQsearchCheckExtension * ONE_PLY);
}
}
@@ -2128,6 +2852,24 @@ QSearchFromCheckNoStandPat(IN SEARCHER_THREAD_CONTEXT *ctx,
iEval = Eval(ctx, iAlpha, iBeta, &rgiPositional);
ASSERT(ctx->sPlyInfo[ctx->uPly].uLastPositionalSource <
EVAL_POSITIONAL_SOURCE_COUNT);
+ // 2026-10-03: publish it. This function used to keep the
+ // score purely local, so pi->iEval retained whatever a
+ // PREVIOUS visit to this ply slot left behind for every
+ // in-check qsearch node. The one reader
+ // (iImprovement, from sPlyInfo[uPly-2].iEval in Search)
+ // should never see a qsearch ply given that Search sits
+ // strictly above qsearch -- but that invariant is held
+ // by layering alone, and iImprovement feeds null-move
+ // and EFP decisions, so a stale value here is not worth
+ // leaving around once we have paid for the Eval anyway.
+ //
+ // NOTE this is still only set when Eval actually RAN.
+ // The common in-check path never calls Eval at all (no
+ // stand-pat is offered, so there is nothing to compute
+ // it for), and those nodes still leave pi->iEval stale.
+ // Making that safe properly means invalidating it on
+ // entry, which is a separate change.
+ ctx->sPlyInfo[ctx->uPly].iEval = iEval;
fEvalComputed = TRUE;
}
@@ -2259,6 +3001,15 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx,
#endif
FLAG fIncludeChecks;
FLAG fOrigStandPat = ctx->sSearchFlags.fCouldStandPat[pos->uToMove];
+#ifdef PERF_COUNTERS
+ // Entry alpha, for the qsearch node-type accounting at end:. QSearch
+ // raises iAlpha IN PLACE (the stand-pat branch and the move loop
+ // both do), so a fail-low test against the live iAlpha would score
+ // every successful alpha-raise as a fail-low -- the same defect that
+ // made Search()'s PV row read 99.1%.
+ SCORE iQEntryAlpha = iAlpha;
+ FLAG fQNodeTyped = FALSE;
+#endif
static ULONG _WhatToGen[] =
{
GENERATE_CAPTURES_PROMS,
@@ -2283,6 +3034,13 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx,
{
goto end;
}
+#ifdef PERF_COUNTERS
+ // Reaching here means CommonSearchInit returned FALSE, i.e. it
+ // really assigned pi->eNodeType for THIS visit.
+ fQNodeTyped = TRUE;
+ ASSERT((ULONG)pi->eNodeType < 3);
+ INC(ctx->sCounters.tree.u64QNodeTypeCount[(ULONG)pi->eNodeType]);
+#endif
DTEnterNode(ctx, 0, TRUE, iAlpha, iBeta);
// Probe interior node recognizers. Qsearch runs at the deepest,
@@ -2354,21 +3112,54 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx,
// its other uses: deciding whether a *whole line* looks forcing
// enough to justify extra qsearch depth/breadth), not gating
// per-node stand-pat correctness.
- if (0 != ValueOfMaterialInTroubleDespiteMove(ctx, pos->uToMove))
{
- iBestScore = iAlpha;
- }
- else
- {
- ctx->sSearchFlags.fCouldStandPat[pos->uToMove] = TRUE;
- if (iBestScore > iAlpha)
+ SCORE iInTrouble = ValueOfMaterialInTroubleDespiteMove(ctx, pos->uToMove);
+
+ if (0 != iInTrouble)
{
- iAlpha = iBestScore;
- ASSERT(ctx->sPlyInfo[ctx->uPly].PV[ctx->uPly].uMove == 0);
- ASSERT(pi->mvBest.uMove == 0);
- if (iBestScore >= iBeta)
+ // REVERTED 2026-10-03, same day it was tried. The change was
+ // `iBestScore = MAX(iAlpha, iEval - iInTrouble)`, motivated by
+ // a real observation: the beta test below lives only in the
+ // else-branch, so a node with iEval far ABOVE beta and hanging
+ // material returns a fail-low instead of cutting off.
+ //
+ // But the fix was unsound AND expensive, for the same reason.
+ // Work the signs: a parent searching this node passed window
+ // (-iBeta_p, -iAlpha_p), so iAlpha here == -iBeta_p. The parent
+ // fails high iff -V >= iBeta_p, i.e. iff V <= iAlpha. So
+ // returning exactly iAlpha is not a crude placeholder -- it is
+ // precisely the value that HANDS THE PARENT ITS CUTOFF. That is
+ // the standard fail-low idiom: refuse the stand-pat, start at
+ // alpha, and if nothing beats it report a fail-low.
+ //
+ // Returning MAX(iAlpha, iEval - iInTrouble) instead reports a
+ // better-than-alpha value, denying the parent that cutoff --
+ // measured as ~0.45 ply of lost search depth in self-play
+ // (candidate 13.10 vs 13.55 for the same tree without this
+ // change, against a comparable head). And it asserts the node
+ // is worth at least iEval - iInTrouble WITHOUT SEARCHING
+ // ANYTHING, i.e. a stand-pat credit at a discount -- exactly
+ // what this branch exists to refuse. If every move from here is
+ // terrible, the true value can be far below that.
+ //
+ // The original observation is still valid and still unfixed: a
+ // sound fix needs a lower bound the node has actually EARNED,
+ // which iEval - iInTrouble is not. A beta test on a
+ // search-established bound would be the way, not a static one.
+ iBestScore = iAlpha;
+ }
+ else
+ {
+ ctx->sSearchFlags.fCouldStandPat[pos->uToMove] = TRUE;
+ if (iBestScore > iAlpha)
{
- goto end;
+ iAlpha = iBestScore;
+ ASSERT(ctx->sPlyInfo[ctx->uPly].PV[ctx->uPly].uMove == 0);
+ ASSERT(pi->mvBest.uMove == 0);
+ if (iBestScore >= iBeta)
+ {
+ goto end;
+ }
}
}
}
@@ -2418,11 +3209,46 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx,
// and the other side has not yet been able to stand pat yet, and
// we have material OR we have hanging pieces, generate checks
// here too. Checks are a "good way" to escape from "trouble".
+ // 2026-10-03: the opponent-stand-pat half of the first clause is
+ // waived at a PV node.
+ //
+ // The suppression exists because of a sound argument: if the
+ // opponent was able to stand pat at an ancestor, then once their
+ // stand-pat score has raised alpha there, no line below worth less
+ // than that score can change what they return -- so resolving
+ // whether this subtree is mate-in-10 or merely loses a queen is
+ // wasted work. That reasoning covers every case in a NULL-WINDOW
+ // search: even where their stand-pat did NOT raise alpha, the node
+ // is heading for a fail-low, and a fail-low here is precisely the
+ // parent's beta cutoff -- any score at or below alpha answers the
+ // question identically, so extra precision buys nothing.
+ //
+ // It does not cover a WIDE-WINDOW node. There the exact score
+ // propagates toward the root, so "mate-in-10" and "drops a queen"
+ // are a different PV and a different reported score -- and finding
+ // forced mates from the early plies of qsearch is the whole reason
+ // this check-generation machinery exists. Neither this gate nor
+ // QSearchFromCheckNoStandPat's escape-futility gate consulted node
+ // type, so that case was suppressed along with the rest.
+ //
+ // Hence: at NODE_PV, drop the fCouldStandPat[opponent] requirement
+ // but KEEP the material requirement (an independent sanity check on
+ // whether checks are worth generating at all, not a suppression).
+ // pi->eNodeType is read, never written -- CommonSearchInit remains
+ // the only assigner, and it has already run for this visit (an
+ // early exit there would have jumped to end: long before here), so
+ // the value cannot be stale. Using eNodeType rather than recomputing
+ // (iBeta != iAlpha + 1) matters: iAlpha may have been RAISED by the
+ // stand-pat above, which would make a recomputed test read narrow
+ // for a node that is still genuinely PV.
fIncludeChecks = (
(pf->uQsearchDepth < pf->uQsearchCheckDepth) &&
(
(
- (pf->fCouldStandPat[FLIP(pos->uToMove)] == FALSE) &&
+ (
+ (pf->fCouldStandPat[FLIP(pos->uToMove)] == FALSE) ||
+ (NODE_PV == pi->eNodeType)
+ ) &&
(pos->uNonPawnMaterial[pos->uToMove] > (VALUE_KING + VALUE_BISHOP))
)
||
@@ -2566,34 +3392,60 @@ QSearch(IN SEARCHER_THREAD_CONTEXT *ctx,
ASSERT(IS_VALID_SCORE(iBestScore) || WE_SHOULD_STOP_SEARCHING);
DTLeaveNode(ctx, TRUE, iBestScore, pi->mvBest);
#ifdef PERF_COUNTERS
- // See Search()'s own "end:" label for the full rationale.
- if ((!WE_SHOULD_STOP_SEARCHING) && IS_VALID_SCORE(iBestScore))
+ // Qsearch half of the node-type accounting -- see chess.h's
+ // u64QNodeTypeCount comment for why this is counted separately
+ // rather than folded into the full-width counters (it used to be
+ // folded in, which made those rates mostly a measure of stand-pat
+ // frequency) and why dropping it entirely broke the CUT-FL/ALL-FH
+ // identity.
+ if ((!WE_SHOULD_STOP_SEARCHING) && IS_VALID_SCORE(iBestScore) &&
+ (TRUE == fQNodeTyped))
{
- FLAG fFailedLow = (iBestScore <= iAlpha);
- FLAG fFailedHigh = (iBestScore >= iBeta);
+ FLAG fQLow = (iBestScore <= iQEntryAlpha);
+ FLAG fQHigh = (iBestScore >= iBeta);
+
switch (pi->eNodeType)
{
case NODE_PV:
- if (fFailedLow || fFailedHigh)
+ if (fQLow || fQHigh)
{
- INC(ctx->sCounters.tree.u64PVNodeSurprised);
+ INC(ctx->sCounters.tree.u64QNodeSurprise[NODE_PV]);
}
break;
case NODE_ALL:
- if (fFailedHigh)
+ if (fQHigh)
{
- INC(ctx->sCounters.tree.u64AllNodeFailedHigh);
+ INC(ctx->sCounters.tree.u64QNodeSurprise[NODE_ALL]);
}
break;
case NODE_CUT:
- if (fFailedLow)
+ if (fQLow)
{
- INC(ctx->sCounters.tree.u64CutNodeFailedLow);
+ INC(ctx->sCounters.tree.u64QNodeSurprise[NODE_CUT]);
}
break;
}
}
#endif
+// 2026-10-03: QSearch's copy of the node-type surprise counters was
+// MOVED to its own slots (above), not deleted. CommonSearchInit types qsearch nodes too (it is the one
+// funnel both Search() and QSearch() pass through), and qsearch is ~84%
+// of the tree -- so these counters were dominated by it. Specifically: a
+// qsearch node whose stand-pat score already beats beta returns
+// iBestScore >= iBeta immediately, and if that node happened to be typed
+// ALL it was scored as "ALL failed high". A stand-pat cutoff is the most
+// ordinary event in qsearch, not a node-type misprediction, which is why
+// the line reported ~47% of all nodes as ALL-failed-high and looked
+// absurd. The question the line's name asks is about FULL-WIDTH
+// node-type prediction, so qsearch is now excluded from both the
+// numerator (here) and the denominator (u64NodeTypeCount, incremented in
+// Search() only).
+//
+// The NODE_TYPE enum itself is deliberately unchanged -- adding a
+// NODE_QNODE value was considered and rejected, because eNodeType feeds
+// eParentType chaining in CommonSearchInit and every behavioral consumer
+// tests against NODE_PV; excluding qsearch from the instrumentation gets
+// the same signal with zero risk to any of that.
// Note: iBestScore can be +INFINITY_SCORE or -INFINITY_SCORE here even in the
// absence of a legitimate mate detected if we disallowed stand
diff --git a/src/searchsup.c b/src/searchsup.c
index 0201219..58ae61b 100644
--- a/src/searchsup.c
+++ b/src/searchsup.c
@@ -223,6 +223,413 @@ ThreadUnderTerminatingSplit(IN SEARCHER_THREAD_CONTEXT *ctx)
}
+// 2026-10-02: moved here from search.c, along with the rest of the
+// joint-table-driven graduated-LMR machinery below -- this used to be
+// split across two files purely to minimize the original diff against
+// HEAD's LMR (GetLMRReduction decided only the baseline -ONE_PLY,
+// search.c's move loop did all the bucket/joint-table post-processing
+// on top). That split made it easy to reduce FAR less than intended:
+// the baseline decision and the "how much more" decision were made
+// from two different, inconsistently-strict dynamic signals. Now one
+// function makes the whole call.
+//
+// EstimateHangingMaterialCp/HangCpBucket/GapCpBucket/MoveNumBucket/
+// DepthBucket are no longer file-static -- search.c's own joint-table
+// COLLECTION instrumentation (the measurement block gated on
+// fPruneReduceEligible alone, independent of whether uDepth>TWO_PLY or
+// any reduction is actually applied -- this is what's still generating
+// the next round of g_uJointFHThousandths data, a live, in-progress
+// collection run as of this writing) still needs them, so they're
+// declared in chess.h and shared rather than duplicated.
+SCORE
+EstimateHangingMaterialCp(IN SEARCHER_THREAD_CONTEXT *ctx,
+ IN MOVE mv,
+ IN ULONG uMoveNum,
+ IN BITBOARD bbMinorAttacksSnapshot[2],
+ IN BITBOARD bbRookAttacksSnapshot[2])
+/**
+
+Routine description:
+
+ Does this quiet (or losing-capture) move look like it hangs
+ material? Used as one axis ("dumb") of the joint fail-high
+ prediction table. "Strictly cheaper" only (an equal-value trade is
+ routinely fine, not a blunder) and generalized over every piece
+ type instead of hand-picking specific attacker/victim pairs.
+
+Return value:
+
+ SCORE : estimated cp loss (>= 0), or 0 if nothing looks hung.
+
+**/
+{
+ POSITION *pos = &ctx->sPosition; // already reflects the position
+ // AFTER mv (MakeMove already ran)
+ ULONG uOpponent = pos->uToMove; // side to move now == opponent of
+ // whoever just moved
+ BITBOARD bbTo;
+ SCORE iMoveScore;
+
+ ASSERT(ctx->uPly >= 1);
+
+ if (IS_CAPTURE_OR_PROMOTION(mv))
+ {
+#ifdef DEBUG
+ if (mv.pPromoted) {
+ ASSERT(pos->rgSquare[mv.cTo].pPiece == mv.pPromoted);
+ }
+ else
+ {
+ ASSERT(pos->rgSquare[mv.cTo].pPiece == mv.pMoved);
+ }
+#endif
+ iMoveScore = ComputeMoveScore(ctx, mv, uMoveNum);
+ ASSERT(iMoveScore < 0);
+ return -iMoveScore;
+ }
+#ifdef DEBUG
+ else
+ {
+ ASSERT(pos->rgSquare[mv.cTo].pPiece == mv.pMoved);
+ }
+#endif
+
+ // A king can't "hang material" in the sense this function measures
+ // -- it's never capturable, so PIECE_VALUE(KING) (== INFINITY_SCORE)
+ // has no business feeding into a cp-loss estimate at all.
+ if (IS_KING(mv.pMoved))
+ {
+ return 0;
+ }
+
+ // Any non-pawn moved onto a square an enemy pawn attacks --
+ // always unconditionally bad, a pawn is the cheapest possible
+ // attacker there is.
+ if ((!IS_PAWN(mv.pMoved)) &&
+ (0 != (g_PawnAttackOriginBB[uOpponent][mv.cTo] & pos->bbPawns[uOpponent])))
+ {
+ return (SCORE)(PIECE_VALUE(mv.pMoved) - VALUE_PAWN);
+ }
+
+ // Enemy KING adjacency, checked fresh (real board state, no
+ // scratch-bitboard involved either way). A king can only capture
+ // on a square its own side doesn't leave it in check on, so king
+ // "attacking" our destination square means nothing by itself --
+ // only flag this when OUR side has zero defenders of the square.
+ // Pawns excluded, same reasoning as the pawn-attack check above.
+ bbTo = COOR_TO_BB(mv.cTo);
+ if ((!IS_PAWN(mv.pMoved)) &&
+ (0 != (g_KingAttacksBB[mv.cTo] & COOR_TO_BB(KingPosition(pos, uOpponent)))))
+ {
+ ULONG uMover = FLIP(uOpponent);
+ BITBOARD bbOurDefenders =
+ _WhoAttacksSquareBB(pos, mv.cTo, uMover, pos->bbOccupied) |
+ (g_PawnAttackOriginBB[uMover][mv.cTo] & pos->bbPawns[uMover]);
+
+ if (0 == bbOurDefenders)
+ {
+ return (SCORE)PIECE_VALUE(mv.pMoved);
+ }
+ // Defended -- the king alone isn't a threat here. Fall through
+ // to the minor/rook checks below.
+ }
+
+ // Anything worth more than a minor, moved onto a square an enemy
+ // minor attacks. Snapshotted locally by the caller -- NOT read
+ // from pos->bbMinorAttacks directly, which gets clobbered by every
+ // recursive Eval() call made while searching an earlier sibling
+ // move and is never restored by UnmakeMove.
+ if ((PIECE_VALUE(mv.pMoved) > VALUE_KNIGHT) &&
+ (0 != (bbMinorAttacksSnapshot[uOpponent] & bbTo)))
+ {
+ return (SCORE)(PIECE_VALUE(mv.pMoved) - VALUE_KNIGHT);
+ }
+
+ // Anything worth more than a rook, moved onto a square an enemy
+ // rook attacks. Same snapshot reasoning as the minor check above.
+ if ((PIECE_VALUE(mv.pMoved) > VALUE_ROOK) &&
+ (0 != (bbRookAttacksSnapshot[uOpponent] & bbTo)))
+ {
+ return (SCORE)(PIECE_VALUE(mv.pMoved) - VALUE_ROOK);
+ }
+ return 0;
+}
+
+// See chess.h's HANG_CP_BUCKETS comment for the bucket shape.
+ULONG
+HangCpBucket(SCORE iHangCp)
+{
+ ULONG u;
+
+ ASSERT(iHangCp >= 0);
+ if (0 == iHangCp)
+ {
+ return 0;
+ }
+ u = 1 + (((ULONG)iHangCp - 1) / HANG_CP_BUCKET_WIDTH);
+ return MINU(u, HANG_CP_BUCKETS - 1);
+}
+
+// See chess.h's GAP_CP_BUCKETS comment for the bucket shape. Unlike
+// HangCpBucket, the input can legitimately be negative or zero (this
+// node's own eval already meets or beats alpha) -- that whole
+// non-positive range collapses into bucket 0, same as HANG's "not
+// dumb".
+ULONG
+GapCpBucket(SCORE iGapCp)
+{
+ ULONG u;
+
+ if (iGapCp <= 0)
+ {
+ return 0;
+ }
+ u = 1 + (((ULONG)iGapCp - 1) / GAP_CP_BUCKET_WIDTH);
+ return MINU(u, GAP_CP_BUCKETS - 1);
+}
+
+// See chess.h's MOVE_NUM_BUCKETS comment. uMoveNum is 1-based (the
+// caller passes uLegalMoves, right after MakeMove succeeded) -- index
+// 0 is never produced.
+ULONG
+MoveNumBucket(ULONG uMoveNum)
+{
+ ASSERT(uMoveNum >= 1);
+ return MINU(uMoveNum, MOVE_NUM_BUCKETS - 1);
+}
+
+// See chess.h's SEARCH_DEPTH_BUCKETS comment. uCandDepth is uDepth /
+// ONE_PLY, whole plies, legitimately 0 or above.
+ULONG
+DepthBucket(ULONG uCandDepth)
+{
+ return MINU(uCandDepth, SEARCH_DEPTH_BUCKETS - 1);
+}
+
+// Maps this project's raw, marginal-resolution bucket indices (exactly
+// what DepthBucket/MoveNumBucket produce, and what the 2026-09-28
+// collection run counted against) down to the coarser, cache-shaped
+// indices g_uJointFHThousandths was actually baked at. dumb and gap
+// pass through unchanged (shipped shape kept them at full marginal
+// resolution -- see the plan doc's table). Must stay in exact lockstep
+// with bake_joint.py's ship_depth()/ship_late() -- these are the same
+// mapping, just re-derived in C for the live lookup path. File-private
+// -- only the prediction lookup just below needs the shipped shape;
+// search.c's own collection instrumentation counts at full raw
+// resolution and never calls these.
+static ULONG
+_ShipDepthBucket(ULONG uRawDepthBucket)
+{
+ if (uRawDepthBucket <= 9) return uRawDepthBucket;
+ if (uRawDepthBucket <= 13) return 10;
+ return 11;
+}
+
+static ULONG
+_ShipLateBucket(ULONG uRawLateBucket)
+{
+ // Raw indices 0 and 1 are never produced: uMoveNum is 1-based (so 0
+ // is out), and the collection/live eligibility gate itself
+ // (fPruneReduceEligible, search.c, requires uLegalMoves > 1) means
+ // the raw value handed to MoveNumBucket is never less than 2
+ // either. Shift the real range (2..) down by two so the shipped
+ // array wastes no slot -- confirmed against the 2026-09-30
+ // collection run, which showed ship_late bucket 0 permanently empty
+ // under the old (-1) shift.
+ if ((uRawLateBucket >= 2) && (uRawLateBucket <= 13)) return uRawLateBucket - 2;
+ if (uRawLateBucket <= 21) return 12;
+ if (uRawLateBucket <= 31) return 13;
+ return 14;
+}
+
+#define JOINT_TABLE_SENTINEL (0xFFFF)
+
+// Looks up this move's predicted FH% (thousandths of a percent) from
+// the baked joint table, given the four RAW bucket indices (as
+// HangCpBucket/GapCpBucket/MoveNumBucket/DepthBucket produce them).
+// Returns JOINT_TABLE_SENTINEL both for a genuine below-sample-floor
+// cell AND for dumb bucket 1 (1-100cp "hung") -- traced to ~97%
+// SEE-scored losing captures/underpromotions misfiled into the
+// quiet-move axis (2026-09-28 plan doc), not a real "this move looks
+// safe" signal. Treating it as "no data" rather than trusting its
+// literal (low) value means it can never justify extra reduction, nor
+// withhold it -- it just falls back to whatever the other checks say.
+static USHORT
+_JointTablePredictedFHThousandths(IN ULONG uRawDepthBucket,
+ IN ULONG uRawDumbBucket,
+ IN ULONG uRawGapBucket,
+ IN ULONG uRawLateBucket)
+{
+ if (1 == uRawDumbBucket)
+ {
+ return JOINT_TABLE_SENTINEL;
+ }
+ return g_uJointFHThousandths[_ShipDepthBucket(uRawDepthBucket)]
+ [uRawDumbBucket]
+ [uRawGapBucket]
+ [_ShipLateBucket(uRawLateBucket)];
+}
+
+// 2026-10-02: reverted from the DELIBERATE EXTREME EXPERIMENT values
+// (9000/8300/1000) back to the original p75/p25/p5-derived calibration
+// (72/5/1) -- that experiment was measured while the dynamic gate
+// below still had the old fake-100%-decaying-to-10% behavior, itself
+// loose and slow to release. Now that the dynamic gate is honest,
+// stacking the most aggressive static multiplier on top of it risks
+// double-counting the same conservatism twice. Re-measure node
+// savings/solve rate with both changes together before drawing any
+// conclusion about either one in isolation.
+// 2026-10-02: tried loosening to 150/20/5 (cap 4) in the hope that
+// Crafty's much larger LMR magnitudes at comparable remaining depth
+// (5-10 plies: see lmr_testing discussion) would translate here too.
+// Measured sd10 ringers node count instead went UP (23.0M -> 27.7M,
+// WORSE than even HEAD's 26.9M) with the tier-1 bucket specifically
+// showing a re-search rate (4.13%) HIGHER than tier 0's (2.73%) --
+// exactly the "distribution-shift failure" this file's own research-
+// rate-calibration comment warns about (low-predicted-FH tiers should
+// show a RISING research rate with tier, never an inversion at tier
+// 1). Reverted to the original p75/p25/p5-derived 72/5/1/cap-3 --
+// MAGNITUDE loosening hits a re-search-tax wall fast; the admission
+// widening above (LMR_STATIC_ADMIT_PCT_THOUSANDTHS) is the lever that
+// actually worked without this failure mode, because it answers "do
+// we reduce at all" (base -1 ply, cheap even when wrong) rather than
+// "how much" (large cuts, expensive when wrong).
+#define JOINT_EXTRA_TIER1_PCT_THOUSANDTHS (72) // ~p75 of the live
+ // tries-weighted
+ // distribution: +1 ply
+#define JOINT_EXTRA_TIER2_PCT_THOUSANDTHS (5) // ~p25: +2 plies
+#define JOINT_EXTRA_TIER3_PCT_THOUSANDTHS (1) // ~p5: below this (the
+ // rare, best-predicted
+ // traffic), +3 plies --
+ // the max cut
+// 2026-10-02: increasing MAGNITUDE again, but narrower than the
+// 150/20/5 attempt above -- that one widened TIER1's ADMISSION
+// boundary (which moves qualify at all) and broke (tier-1 research
+// rate inverted above tier-0's). This time: leave all three admission
+// boundaries (72/5/1) untouched, only raise the PAYOUT for tiers 2/3
+// -- the stricter, already-proven-safe buckets (their research rate
+// was LOWER than tier 0's even before this change, i.e. headroom, not
+// a crack). Tier 1 keeps its original +1 payout unchanged since it's
+// the bucket closest to the population that just proved fragile.
+#define JOINT_EXTRA_MAX_PLIES (5)
+
+// 2026-10-02: ADMISSION is a separate question from MAGNITUDE.
+// JOINT_EXTRA_TIER1_PCT_THOUSANDTHS above was previously doing double duty
+// as both "is the base -ONE_PLY even allowed" AND "does this move
+// additionally earn +1 extra ply" -- a threshold calibrated for the
+// latter (the top ~25% of traffic) is far too strict a gate for the
+// former. The overnight self-play evidence (old dodgy thresholds,
+// effectively a no-op admission gate at this layer) shipped even-or-
+// better strength at 0.9-1.0 ply deeper than HEAD -- direct evidence
+// that broad admission, not a narrow "only the provably safest moves"
+// gate, is the right call here. Set this wide open: the static table
+// only vetoes the base reduction when it has a real opinion AND that
+// opinion is actively bad (worse than the whole-population baseline
+// rate), never merely "not in the top tier."
+// 2026-10-02: max-admission (1000, a no-op gate) measured a real,
+// consistent sn5M solve-count loss on all three curated suites vs.
+// HEAD (ringers -2, confident_quick -2, hard_quick -1) despite being
+// deeper/lower-EBF than the 500 setting below -- unlike sd10 (where a
+// solve dip from less search is expected and uninteresting), a dip at
+// FIXED NODE BUDGET means the extra depth bought by over-wide
+// admission isn't paying for itself yet. 500 was flat-to-noise on all
+// three at sn5M while still gaining +0.1/+0.4/+0.5 ply over HEAD --
+// this is the safer base to build further levers (e.g. LMP) on top of.
+// 2026-10-03: LEFT AT 500 across the Wilson-upper-bound re-bake of
+// g_uJointFHThousandths (see data.c's header). The re-bake raised
+// 19,649 of 21,780 cells, which cut the admitting-CELL count at 500
+// from 21,047 to 15,415 -- so it was briefly raised to 1000 to
+// compensate. That was wrong: cell counts are not traffic-weighted.
+// Measured against the real eligible population (87.8M tries from the
+// 2026-10-03 sd10 collection), admit<500 on the NEW table admits
+// 92.57% of LMR traffic versus 92.88% measured on the old one, while
+// 1000 admits 95.62% -- i.e. 500 is the value that holds LMR behavior
+// constant, and the raised cells turn out to be overwhelmingly
+// low-traffic ones. Lesson: size any threshold change against
+// tries-weighted traffic, never against cell counts.
+#define LMR_STATIC_ADMIT_PCT_THOUSANDTHS (500) // 0.500%
+#ifdef DEPTH_ONLY_EXTRA_REDUCTION
+// See GNUmakefile's DEPTH_ONLY_EXTRA_REDUCTION comment -- the trigger
+// threshold below (6 plies) already guarantees the full R=3 extra plus
+// the base -1 ply (4 total cut) leaves at least this much remaining, so
+// this floor is smaller than the axis-gated version's -- it's a pure
+// safety net here, not doing the real gating work (the depth trigger
+// is).
+#define JOINT_EXTRA_MIN_REMAINING_DEPTH (TWO_PLY)
+#define DEPTH_ONLY_TRIGGER_DEPTH (6 * ONE_PLY)
+#else
+// 2026-09-29: ONE_PLY -- the most aggressive floor. NEUTRALIZE_NULL_
+// BAILOUT (GNUmakefile) showed the earlier FOUR_PLY floor's
+// justification (a measured silent-miss rate) was overwhelmingly
+// (80-99.8%) a measurement artifact of shadow-verification
+// methodology, not genuine reduction danger -- the true false-negative
+// rate at FOUR_PLY, once properly measured, was already near zero.
+#define JOINT_EXTRA_MIN_REMAINING_DEPTH (ONE_PLY)
+#endif
+
+// 2026-10-02: g_iLMRQuietReduction (dynamic.c/InitLMRTable) is a
+// Crafty/Ethereal-shaped flat log(depth)*log(moves) table, built at
+// startup, that NOTHING has ever read -- a prior session built the
+// simple mechanism and then went and built the joint-table/dynamic-FH
+// system instead without ever wiring this in. It encodes the FULL
+// reduction (not an "extra" on top of a base), in ONE_PLY-scaled
+// ticks. Checked against Crafty's actual LMR[depth][order] table at
+// the remaining-depth range our own suites exercise (5-10 plies):
+// d=5,m=8 gives ~2.1 plies here vs Crafty's 2.3; d=10,m=8 gives ~2.7
+// here vs Crafty's 3.0 -- same ballpark, same shape, no per-bucket
+// static-table judgment to be selectively wrong about (the failure
+// mode that just broke the joint-table magnitude tiers above). Used
+// here ONLY for magnitude (how much EXTRA beyond the existing -ONE_PLY
+// base) -- admission (whether to reduce at all) stays exactly as
+// GetLMRReduction already computes it (static/dynamic agreement,
+// killer/countermove vetoes); this only replaces the fragile tier
+// payout with Crafty's actual mechanism.
+static ULONG
+_FlatTableExtraPlies(IN ULONG uRemainingDepth, IN ULONG uMoveNum)
+{
+ ULONG uDepthIdx = MINU(uRemainingDepth / ONE_PLY, MAX_PLY_PER_SEARCH);
+ ULONG uMoveIdx = MINU(uMoveNum, LMR_TABLE_MAX_MOVES);
+ SCORE iTotalTicks = g_iLMRQuietReduction[uDepthIdx][uMoveIdx];
+ SCORE iExtraTicks = iTotalTicks - ONE_PLY;
+
+ if (iExtraTicks <= 0)
+ {
+ return 0;
+ }
+ return (ULONG)(iExtraTicks / ONE_PLY);
+}
+
+// data.c:1209's "Overall eligible FH%: 0.400%" -- the tries-weighted
+// fail-high rate of the WHOLE fPruneReduceEligible population across
+// the 500B-node joint-table collection run. Used two ways: (1) as the
+// universal backstop -- a move identity whose own honest observed
+// rate already exceeds what an AVERAGE leftover move does is never
+// reduction-safe, regardless of context; (2) folded into the same
+// decision below, additionally compared against the joint table's own
+// bucket-specific prediction for the exact (depth, hang, gap, late)
+// situation this move is in right now -- a strictly tighter,
+// context-aware version of the same idea.
+// 2026-10-03 unit unification: was (4) in per-mille, which is the SAME
+// nominal 0.4% this is now expressed as directly. Not quite a no-op
+// though, and the difference is worth knowing: the old comparison was
+// `floor(fh*1000/attempts) > 4`, which can only become true once
+// fh/attempts >= 0.005 -- i.e. truncation made the old gate behave like
+// 0.5%, not the 0.400% it documented. 400 here is faithful to the
+// documented intent and is a hair stricter than what actually shipped.
+// The affected band is fh/attempts in [0.401%, 0.5%), which requires
+// >= 200 observations of one move identity to even express and is
+// essentially unpopulated per chess.h's u64FHJointHist (the dynamic
+// rate is bimodal -- ~71% at exactly 0, with the next real mass not
+// starting until ~1%). So: intended behavior preserved, shipped
+// behavior changed by an immeasurably small amount.
+#define LMR_FH_BASELINE_PCT_THOUSANDTHS (400) // 0.400%
+
+// 2026-10-02, one-off diagnostic support for chess.h's u64FHJointHist:
+// 8 attempts bands (0,1,2,3-4,5-9,10-24,25-99,100+) and 8 rate bands in
+// thousandths (0,1,2-4,5-9,10-19,20-49,50-99,100+ -- the last band
+// starts exactly at the old HEAD-style 10% ceiling).
+
+
INT
GetLMRReduction(IN SCORE iEval,
IN SCORE iAlpha,
@@ -233,41 +640,489 @@ GetLMRReduction(IN SCORE iEval,
IN ULONG uLegalMoves,
IN MOVE mv,
IN ULONG uMoveNum,
- IN INT iExtend)
+ IN INT iExtend,
+ IN BITBOARD bbMinorAttacksSnapshot[2],
+ IN BITBOARD bbRookAttacksSnapshot[2],
+ OUT ULONG *puExtraTier)
/**
Routine description:
- True pre-LMR/pre-counter-move baseline: original binary history
- pruning (fixed -ONE_PLY), no graded table, no PV-parent guard, no
- counter-move exemption. Kept under the GetLMRReduction name so
- search.c/split.c's call sites need no changes.
+ 2026-10-02: consolidated here -- this used to be two decisions in
+ two files (this function decided only a baseline -ONE_PLY;
+ search.c's move loop separately consulted the joint FH% table and
+ piled more reduction on top as post-processing), kept split
+ originally just to minimize the diff against HEAD's LMR. That
+ split let the baseline decision and the "how much more" decision
+ run off two different, inconsistently-strict dynamic signals. Now
+ one function, one combined decision:
+
+ 1. Killer-adjacency exemption (this ply's own killers, ply-2
+ back's killers) -- unchanged from the original baseline.
+ 2. Countermove-table match exemption -- a bare match (any
+ evidence at all, unlike generate.c's evidence-gated tier
+ promotion) against the move played immediately before mv
+ means mv has fail-high'd/been-best here before in this exact
+ context; withhold reduction.
+ 3. Dynamic fail-high-rate baseline: GetMoveFailHighPctThousandths
+ must have real samples (uAttempts > 0) and a rate at or below
+ the table-wide average leftover move's (data.c's 0.400%).
+ 4. If all of the above pass, look up the joint table's own
+ bucket-specific prediction for this move's actual situation
+ (depth/hang/gap/late) and apply the STRICTER version of check
+ 3 against it specifically: if this move's own rate already
+ exceeds what's predicted for moves in this exact situation,
+ veto the whole reduction, not just the extra plies. No data
+ for this bucket (JOINT_TABLE_SENTINEL) is treated as no claim
+ either way, not a veto.
+ 5. Only once still eligible after (4): convert the bucket
+ prediction into 0-3 extra whole plies on top of the baseline,
+ floored so the combined reduction never leaves less than
+ JOINT_EXTRA_MIN_REMAINING_DEPTH remaining.
Note: the move has already been made by the time we get here.
+Parameters:
+
+ ...
+ IN BITBOARD bbMinorAttacksSnapshot[2],
+ IN BITBOARD bbRookAttacksSnapshot[2] : EstimateHangingMaterialCp's
+ snapshot inputs -- see that function's own comment for why
+ they're snapshotted rather than read live. split.c's HelpSearch
+ call site has no such snapshot available (deliberately never
+ wired up -- see this file's/search.c's "Main move loop
+ (search.c) only" history) and passes all-zero arrays instead;
+ EstimateHangingMaterialCp degrades safely on zeros (every
+ "attacked by X" check becomes unconditionally false), the same
+ already-accepted degradation search.c itself uses when its own
+ snapshot is stale (see search.c's fParentAttackTablesAreFresh).
+ OUT ULONG *puExtraTier : optional (may be NULL, e.g. split.c, which
+ has no use for the joint-extra-tier re-search/calibration
+ instrumentation keyed on this). Receives the extra-ply tier
+ (0-3) actually applied, 0 whenever this function returns 0 OR
+ returns only the baseline -ONE_PLY.
+
Return value:
- INT : 0 if no reduction, else -ONE_PLY.
+ INT : 0 if no reduction, else a negative multiple of ONE_PLY (the
+ FULL combined baseline+extra reduction).
**/
{
+ ULONG uAttempts;
+ ULONG uFHPctThousandths;
+ FLAG fCounterMoveMatch;
+ ULONG uExtraPlies = 0;
+
ASSERT(ctx->uPly > 0);
ASSERT(mv.uMove);
ASSERT((uMoveNum > 0) || (uLegalMoves == 0));
+ if (NULL != puExtraTier)
+ {
+ *puExtraTier = 0;
+ }
+#ifdef DISABLE_LMR
+ // See GNUmakefile's DISABLE_LMR comment. Bail before any signal is
+ // consulted so the no-LMR arm is a clean control: no reduction, and
+ // also none of the table/history lookups that feed it.
+ return(0);
+#endif
+
+ // 2026-10-02: countermove-table veto. mv has ALREADY been made by
+ // the time we get here (this function's own doc comment), so mv's
+ // own ply is ctx->uPly-1 (same convention the killer checks below
+ // already use) -- the move the opponent played immediately BEFORE
+ // mv, which is what the countermove table is keyed on (see
+ // generate.c's _ScoreAllMoves, which computes this same index at
+ // ctx->sPlyInfo[uPly-1].mv for a node at ply uPly, i.e. one more
+ // ply back than mv's own), is therefore ctx->sPlyInfo[ctx->uPly-2].mv.
+ // Careful: this is TWO plies back from here, not one.
+ //
+ // generate.c's own use of a countermove match (COUNTERMOVE_TIER_
+ // BONUS) is evidence-gated -- a bare match with no accumulated
+ // history+continuation evidence gets no promotion there. This is
+ // a deliberately looser, separate question: a match ALONE, however
+ // thin its evidence, means mv has fail-high'd (or been the node's
+ // final best move) at least once immediately after this exact
+ // previous move somewhere in this search -- a finer-grained,
+ // context-specific memory than GetMoveFailHighPctThousandths' bare
+ // per-move-identity rate (which doesn't know what came before mv)
+ // can offer by itself. Treat a match as sufficient reason to
+ // withhold reduction even if it wasn't strong enough to promote
+ // mv's tier in generate.c.
+ fCounterMoveMatch = FALSE;
+ if (ctx->uPly >= 2)
+ {
+ MOVE mvPrev = ctx->sPlyInfo[ctx->uPly - 2].mv;
+ if (0 != mvPrev.uMove)
+ {
+ ULONG uCounterIdx = MOVE_TO_INDEX(mvPrev);
+ fCounterMoveMatch =
+ IS_SAME_MOVE(mv, ctx->mvCounter[uCounterIdx][0]) ||
+ IS_SAME_MOVE(mv, ctx->mvCounter[uCounterIdx][1]);
+ }
+ }
+
+ // 2026-10-02: g_FailHighs now starts genuinely empty (0/0) per
+ // move identity, not seeded to a faked 100% (see
+ // ClearDynamicMoveOrdering, dynamic.c) -- this is now a real,
+ // honest observed fail-high rate, not a decaying fake prior.
+ // 1. uAttempts == 0: we have no dynamic evidence on this exact
+ // move identity at all (yet) -- play it safe, don't reduce.
+ // Not a tuned floor; any real observation at all is treated
+ // as evidence, consistent with the bucket-specific check
+ // (below) being the thing that actually judges whether that
+ // evidence is reassuring.
+ // 2. uFHPctThousandths > LMR_FH_BASELINE_PCT_THOUSANDTHS: this move
+ // identity's own lifetime rate is already worse than an
+ // AVERAGE leftover move's -- never reduction-safe on that
+ // basis alone, independent of context.
+ uFHPctThousandths = GetMoveFailHighPctThousandths(mv, &uAttempts);
+
+// 2026-10-03: REMOVED two one-off PERF_COUNTERS diagnostics that lived
+// here, both of which did real per-decision WORK rather than just
+// incrementing a counter, in the hottest decision point in the search:
+//
+// 1. The HEAD-style-replay diagnostic (u64HeadStyleEligible /
+// u64HeadStyleWouldReduce). It called GetMoveFailHighRawCounts and
+// then did an integer division to reconstruct HEAD's old
+// fake-1-FH/1-attempt gate, for every LMR decision. Its own comment
+// said "Not meant for any other caller; remove once the
+// HEAD-vs-candidate reduction-rate comparison this supports is
+// done." That comparison is done -- it is what established that the
+// candidate admits 92-94% of eligible decisions against HEAD-style's
+// 70-75%, recorded in lmr_testing/GET_LMR_REDUCTION.md section 10.2.
+//
+// 2. The (attempts x rate) joint histogram (u64FHJointHist), also
+// marked "one-off". It served its purpose: it is what showed the
+// per-move-identity dynamic rate is BIMODAL rather than clustered
+// around the 0.400% population average (~71% at exactly 0%, a
+// separate well-evidenced population at 1-10%, ~7% pure cold) --
+// see section 4 of that document.
+//
+// Both were listed for removal in that file's own "next steps" item 5.
+// PERF_COUNTERS is supposed to be counter increments; these were
+// lookups plus divisions per decision, and PERF_COUNTERS measured at
+// ~7.2% nps against a no-counters build. AttemptsBucket/RateBucket
+// (the band helpers they used) went with them.
+
+ // 2026-10-02: REDESIGNED (second pass) -- three populations, not
+ // two. Empirically (chess.h's u64FHJointHist diagnostic) the
+ // per-move-identity dynamic rate is bimodal, not clustered around
+ // data.c's 0.400% population average: ~71% of eligible decisions
+ // sit at a genuine, often well-evidenced 0% own rate; a separate,
+ // real, often well-evidenced (100+ attempts) chunk sits durably
+ // at 1-10%; and ~7% are pure cold (zero observations this game).
+ // Treating "elevated own rate" as a hard veto (old design) throws
+ // away information for exactly the wrong population -- a move
+ // with real, well-evidenced personal danger in a static-safe
+ // situation isn't necessarily UNSAFE to reduce, it's just not
+ // safe enough to pile extra plies onto. So:
+ // - cold (uAttempts == 0): no personal history to judge at all
+ // -- defer entirely to the static table (its job: no game
+ // warmup needed). Was unconditionally refused before.
+ // - warm + safe (own rate <= baseline, agrees with a static
+ // "safe" read): full normal eligibility, base + extra tiers
+ // -- both signals agree, lean into it.
+ // - warm + dangerous (own rate > baseline, even though static
+ // says safe): still reduce, but by the MINIMUM only (base
+ // -ONE_PLY, extra forced to 0) -- the static table's safe
+ // read for this general situation doesn't get silently
+ // overridden to a full veto by one move's specific bad
+ // personal record, but that record still caps how far we're
+ // willing to go on its word.
+ //
+ // Identity vetoes (killer-adjacency, countermove match) are
+ // unchanged and still unconditional -- they're a different kind
+ // of signal (an exact, specific antecedent/ply match) than "this
+ // move's own aggregate rate looks elevated", and still zero out
+ // the whole decision regardless of what either FH signal says.
if ((!IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-1][0])) &&
(!IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-1][1])) &&
((ctx->uPly < 3) ||
(!IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-3][0]) &&
!IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-3][1]))) &&
- (GetMoveFailHighPercentage(mv, NULL) <= 10))
+ (FALSE == fCounterMoveMatch))
{
+ ULONG uRawDepthB, uRawDumbB, uRawGapB, uRawLateB;
+ USHORT uPredictedFH;
+ INT iLocalExtend;
+ FLAG fBaseEligible;
+ FLAG fDynamicDanger = (uAttempts > 0) &&
+ (uFHPctThousandths > LMR_FH_BASELINE_PCT_THOUSANDTHS);
+
ASSERT(!InCheck(&ctx->sPosition, ctx->sPosition.uToMove));
- return(-ONE_PLY);
+
+#ifdef DEPTH_ONLY_EXTRA_REDUCTION
+ // Bypass the axis-based table entirely -- see GNUmakefile's
+ // DEPTH_ONLY_EXTRA_REDUCTION comment. No static prediction is
+ // computed in this branch, so base eligibility is simply
+ // "survived the identity vetoes above"; a dynamic-danger
+ // reading still caps the flat extra bonus to 0 even here.
+ fBaseEligible = TRUE;
+ uExtraPlies = (!fDynamicDanger && (uRemainingDepth >= DEPTH_ONLY_TRIGGER_DEPTH)) ?
+ JOINT_EXTRA_MAX_PLIES : 0;
+#else
+ SCORE iHangCp = EstimateHangingMaterialCp(
+ ctx, mv, uMoveNum,
+ bbMinorAttacksSnapshot,
+ bbRookAttacksSnapshot);
+ uRawDepthB = DepthBucket(uRemainingDepth / ONE_PLY);
+ uRawDumbB = HangCpBucket(iHangCp);
+ uRawGapB = GapCpBucket((SCORE)(iAlpha - iEval));
+ uRawLateB = MoveNumBucket(uLegalMoves);
+ uPredictedFH = _JointTablePredictedFHThousandths(
+ uRawDepthB, uRawDumbB, uRawGapB, uRawLateB);
+
+ if (JOINT_TABLE_SENTINEL == uPredictedFH)
+ {
+ // No static data for this exact bucket -- absence of
+ // data is not evidence of danger. Per the user's
+ // explicit prescription: moves with no history (static
+ // OR dynamic) should still get the base reduction unless
+ // the DYNAMIC signal has actively flagged this exact
+ // move identity as dangerous. Admit at base-only
+ // whenever not dynamically dangerous, regardless of
+ // whether uAttempts is 0 (cold) or warm+safe.
+ fBaseEligible = (FALSE == fDynamicDanger);
+ }
+ else
+ {
+ // Static table has an opinion: admission is now a wide-
+ // open, separate threshold from the extra-tier magnitude
+ // grading below (LMR_STATIC_ADMIT_PCT_THOUSANDTHS, not
+ // JOINT_EXTRA_TIER1_PCT_THOUSANDTHS) -- only veto the base
+ // reduction when the static table itself thinks this
+ // bucket is actively bad. A dynamic-danger reading does
+ // NOT veto this either -- it only caps uExtraPlies to 0
+ // below.
+ fBaseEligible = (uPredictedFH < LMR_STATIC_ADMIT_PCT_THOUSANDTHS);
+ }
+ uExtraPlies = (fBaseEligible && (FALSE == fDynamicDanger)) ?
+ _FlatTableExtraPlies(uRemainingDepth, uLegalMoves) : 0;
+#endif
+
+ if (FALSE == fBaseEligible)
+ {
+ return(0);
+ }
+
+ // Combine with the baseline -ONE_PLY and floor the total so it
+ // never leaves less than JOINT_EXTRA_MIN_REMAINING_DEPTH
+ // remaining -- never rely on the move loop's own unsigned-
+ // wraparound clamp to do this implicitly.
+ iLocalExtend = iExtend + (-ONE_PLY);
+ if (uExtraPlies > 0)
+ {
+ INT iMaxExtra =
+ ((INT)uRemainingDepth - (INT)ONE_PLY + iLocalExtend -
+ (INT)JOINT_EXTRA_MIN_REMAINING_DEPTH) /
+ (INT)ONE_PLY;
+ if (iMaxExtra < 0)
+ {
+ iMaxExtra = 0;
+ }
+ if ((INT)uExtraPlies > iMaxExtra)
+ {
+ uExtraPlies = (ULONG)iMaxExtra;
+ }
+ if (uExtraPlies > JOINT_EXTRA_MAX_PLIES)
+ {
+ uExtraPlies = JOINT_EXTRA_MAX_PLIES;
+ }
+ iLocalExtend -= (INT)(uExtraPlies * ONE_PLY);
+ }
+ if (NULL != puExtraTier)
+ {
+ *puExtraTier = uExtraPlies;
+ }
+ return(iLocalExtend);
}
return(0);
}
+// 2026-10-02: Crafty-style late move pruning (LMP) -- an overt skip,
+// not a reduction. search.c/crafty-25.2's LMP has no static table, no
+// dynamic veto, no per-move judgment at all: it hammers everything
+// past a per-depth move-count threshold, close to the leaves, and
+// relies on iterative deepening (at/near the root) plus the sheer
+// branching-factor win to make this pay off in aggregate -- exactly
+// the thesis this session's overnight self-play run (0.9-1.0 ply
+// deeper, even-or-better strength, from aggressive-even-if-unsound
+// reduction) is built on. Unlike LMR, a pruned move has NO re-search
+// safety net -- if it was actually good, this search never finds out,
+// this iteration, at this node. That irrecoverability is exactly why
+// pruning is reserved here for a much narrower population than
+// GetLMRReduction's reduction-admission gate: only the tail where
+// BOTH static and dynamic signals agree the move is as safe as
+// anything in the whole eligible population gets this treatment.
+//
+// Deliberately does NOT touch GetLMRReduction's own admission gate or
+// tiers -- this is a second, independent lever, applied (by the
+// caller, search.c) as a check ahead of GetLMRReduction: if this
+// returns TRUE, the move is skipped entirely and GetLMRReduction is
+// never even called for it.
+// 2026-10-02: KNOWN ISSUE, not yet fixed -- flagged during review,
+// documented here rather than patched since LMP is disabled at its
+// call site (search.c) and not worth further churn right now. This
+// function's LMP_MAX_REMAINING_DEPTH ceiling (below) only permits
+// pruning near the leaf, mirroring Crafty's FP_depth/LMP_depth
+// near-leaf-only restriction -- but our own joint table
+// (g_uJointFHThousandths) shows predicted FH% DECLINING as remaining
+// depth grows (checked directly, dumb=0/gap=0/late=0: depth-bucket 0
+// predicts 17119 thousandths, bucket 5 predicts 4033, bucket 11
+// predicts 617 -- a ~25x drop). So this ceiling only permits pruning
+// in the HIGHEST-FH region and forbids it in the LOWEST-FH region --
+// backwards relative to our own evidence, not a safety margin.
+// Crafty's version isn't contradicting itself the same way: it has no
+// static table at all, so the near-leaf restriction IS its entire
+// risk model (a mistake near the leaf costs little re-exploration).
+// We inherited Crafty's structural depth gate without inheriting its
+// justification, on top of a mechanism that already has a better,
+// contradicting signal.
+//
+// The real asymmetry (user's framing): the COST of a wrong prune does
+// legitimately grow with remaining depth (a missed tactic under a
+// 10-ply-remaining subtree is worse than one under a 2-ply one) -- but
+// that should tighten the CONFIDENCE BAR required, not forbid the
+// decision outright past an arbitrary cutoff. The principled fix,
+// per lmr_testing/SPEC.md's "how much statistical confidence is
+// actually needed" section: gate on the STATIC table's own per-cell
+// sample count via a rule-of-three bound (N ~= 3 * applications_per_game
+// / target_misses_per_game), NOT on remaining depth and NOT on a
+// per-move-identity dynamic sample count. SPEC.md derives N ~= 14.4M
+// for a 1/1000-games miss target (no deep-zone zero-FH cell clears
+// this), and a later, explicitly-endorsed loosening to 1/100 games
+// gives N ~= 1.44M (largest real cell: 1.34M, close) or 1/20 games
+// gives N ~= 288K (16 deep-zone zero-FH cells clear this today).
+// **Blocker**: `g_uJointFHThousandths` (data.c) ships only the rate
+// per cell, not the sample count behind it (cells need >=500 tries to
+// ship at all, per data.c:1208's comment, but a 500-try cell and a
+// 1.34M-try cell are indistinguishable once shipped) -- applying this
+// bound correctly requires re-baking the table with a companion
+// per-cell count array (bake_joint.py + data.c), not yet done. Until
+// then, this function's ceiling/dynamic-sample-floor below is a
+// placeholder, not a calibrated bound.
+#define LMP_MAX_REMAINING_DEPTH (6 * ONE_PLY) // mirrors Crafty's
+ // FP_depth=7 (prunes
+ // in the last 6
+ // plies before qsearch)
+#define LMP_BASE_MOVENUM (12) // never prune before this many
+ // legal moves already tried
+#define LMP_PER_PLY_MOVENUM (4) // + this many moves of extra
+ // slack per remaining ply
+#define LMP_STATIC_MAX_PCT_THOUSANDTHS JOINT_EXTRA_TIER3_PCT_THOUSANDTHS // the
+ // strictest static tier only
+ // (~p5 of real traffic)
+#define LMP_DYNAMIC_MIN_SAMPLES (10) // well-evidenced, not a guess
+// 0.100% -- near-zero observed rate. 2026-10-03 unit unification: was
+// (1) in per-mille, i.e. the same nominal 0.1%. Same truncation caveat
+// as LMR_FH_BASELINE_PCT_THOUSANDTHS above (the old `> 1` could only
+// fire at fh/attempts >= 0.002, so the shipped gate behaved like 0.2%);
+// moot in practice since LMP is disabled at its call site
+// (search.c:1228). Note this constant and LMP_STATIC_MAX_PCT_THOUSANDTHS
+// used to be the two reasons this unit mismatch was dangerous: both
+// were literally (1), 100x apart in meaning, four lines apart in this
+// same block.
+#define LMP_DYNAMIC_MAX_PCT_THOUSANDTHS (100)
+
+FLAG
+ShouldLatePrune(IN SCORE iEval,
+ IN SCORE iAlpha,
+ IN SEARCHER_THREAD_CONTEXT *ctx,
+ IN ULONG uRemainingDepth,
+ IN ULONG uLegalMoves,
+ IN MOVE mv,
+ IN ULONG uMoveNum,
+ IN BITBOARD bbMinorAttacksSnapshot[2],
+ IN BITBOARD bbRookAttacksSnapshot[2],
+ OUT ULONG *puPredictedFH)
+{
+ ULONG uAttempts, uFHPctThousandths, uMoveNumFloor;
+ ULONG uRawDepthB, uRawDumbB, uRawGapB, uRawLateB;
+ USHORT uPredictedFH;
+ SCORE iHangCp;
+
+ ASSERT(ctx->uPly > 0);
+ ASSERT(mv.uMove);
+
+ if (uRemainingDepth > LMP_MAX_REMAINING_DEPTH)
+ {
+ return FALSE;
+ }
+
+ uMoveNumFloor = LMP_BASE_MOVENUM +
+ (LMP_PER_PLY_MOVENUM * (uRemainingDepth / ONE_PLY));
+ if (uLegalMoves <= uMoveNumFloor)
+ {
+ return FALSE;
+ }
+
+ // Never prune a move with any killer-adjacency or countermove-
+ // match history -- same identity vetoes GetLMRReduction uses,
+ // unconditional here too.
+ if (IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-1][0]) ||
+ IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-1][1]) ||
+ ((ctx->uPly >= 3) &&
+ (IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-3][0]) ||
+ IS_SAME_MOVE(mv, ctx->mvKiller[ctx->uPly-3][1]))))
+ {
+ return FALSE;
+ }
+ if (ctx->uPly >= 2)
+ {
+ MOVE mvPrev = ctx->sPlyInfo[ctx->uPly - 2].mv;
+ if (0 != mvPrev.uMove)
+ {
+ ULONG uCounterIdx = MOVE_TO_INDEX(mvPrev);
+ if (IS_SAME_MOVE(mv, ctx->mvCounter[uCounterIdx][0]) ||
+ IS_SAME_MOVE(mv, ctx->mvCounter[uCounterIdx][1]))
+ {
+ return FALSE;
+ }
+ }
+ }
+
+ // Dynamic signal: if there's real, well-evidenced history on this
+ // exact move identity and it's not essentially zero, don't
+ // gamble -- this is the ultra-safe tail only, not "probably fine."
+ uFHPctThousandths = GetMoveFailHighPctThousandths(mv, &uAttempts);
+ if ((uAttempts >= LMP_DYNAMIC_MIN_SAMPLES) &&
+ (uFHPctThousandths > LMP_DYNAMIC_MAX_PCT_THOUSANDTHS))
+ {
+ return FALSE;
+ }
+
+ // Static signal: must have real data AND be in the strictest
+ // (safest) tier -- no data (sentinel) is a reason to NOT gamble
+ // on overt pruning, unlike GetLMRReduction's more permissive
+ // treatment of that case for mere reduction.
+ iHangCp = EstimateHangingMaterialCp(ctx, mv, uMoveNum,
+ bbMinorAttacksSnapshot,
+ bbRookAttacksSnapshot);
+ uRawDepthB = DepthBucket(uRemainingDepth / ONE_PLY);
+ uRawDumbB = HangCpBucket(iHangCp);
+ uRawGapB = GapCpBucket((SCORE)(iAlpha - iEval));
+ uRawLateB = MoveNumBucket(uLegalMoves);
+ uPredictedFH = _JointTablePredictedFHThousandths(
+ uRawDepthB, uRawDumbB, uRawGapB, uRawLateB);
+ // 2026-10-03: publish the prediction so PRUNE_VERIFY can band-bucket
+ // this decision by the very number it was made on. Optional -- every
+ // non-instrumented caller passes NULL.
+ if (NULL != puPredictedFH)
+ {
+ *puPredictedFH = uPredictedFH;
+ }
+ if ((JOINT_TABLE_SENTINEL == uPredictedFH) ||
+ (uPredictedFH >= LMP_STATIC_MAX_PCT_THOUSANDTHS))
+ {
+ return FALSE;
+ }
+
+ return TRUE;
+}
+
SCORE
DecodeMoveValueEstimate(IN MOVE mv,
diff --git a/src/split.c b/src/split.c
index 183f4fa..22d8038 100644
--- a/src/split.c
+++ b/src/split.c
@@ -1185,6 +1185,24 @@ Return value:
(!IS_CAPTURE_OR_PROMOTION(mv)) &&
(!IS_CHECKING_MOVE(mv)))
{
+ // 2026-10-02: GetLMRReduction now also folds in the
+ // joint-table extra-reduction decision (searchsup.c),
+ // which needs EstimateHangingMaterialCp's attack-table
+ // snapshots -- never available here (this thread has
+ // no access to the originating node's Search()-local
+ // bbMinorAttacksSnapshot/bbRookAttacksSnapshot, same
+ // reason this call site never got the joint-table
+ // treatment at all before today). Pass all-zero
+ // arrays: EstimateHangingMaterialCp degrades safely on
+ // zeros (every "attacked by X" snapshot check becomes
+ // unconditionally false), the same already-accepted
+ // degradation search.c's own move loop uses when ITS
+ // snapshot is stale (see search.c's
+ // fParentAttackTablesAreFresh). puExtraTier is NULL --
+ // this call site has no joint-extra-tier re-search/
+ // calibration instrumentation to feed.
+ BITBOARD bbNoMinorAttacksSnapshot[2] = { 0, 0 };
+ BITBOARD bbNoRookAttacksSnapshot[2] = { 0, 0 };
INT iLMR = GetLMRReduction(iEval,
iAlpha,
iBeta,
@@ -1196,7 +1214,10 @@ Return value:
mv,
(g_SplitInfo[u].uAlreadyDone +
uMoveNum + 1),
- iExtend);
+ iExtend,
+ bbNoMinorAttacksSnapshot,
+ bbNoRookAttacksSnapshot,
+ NULL);
if (iLMR < 0)
{
ASSERT(iExtend == 0);
diff --git a/src/unix.c b/src/unix.c
index 2c90f7f..f400ebb 100644
--- a/src/unix.c
+++ b/src/unix.c
@@ -1003,6 +1003,79 @@ SystemObtainSemaphoreResource(ULONG u)
}
+//
+// Set the first time SIGINT asks a search to abort, and cleared once
+// we next observe the engine idle. Lets a second ^C -- sent before the
+// first one's TIMER_STOPPING has actually unwound the search -- force
+// an immediate exit instead of silently collapsing into a no-op repeat
+// of the first request.
+//
+static volatile sig_atomic_t g_fSigintAbortPending = 0;
+
+static void
+_AbortSearchOrTerminate(int iSig)
+/**
+
+Routine description:
+
+ Signal handler shared by SIGHUP and SIGINT. If a search is in
+ progress, abort just the current search the same way the engine
+ already unrolls on a timer/user-input stop (TIMER_STOPPING) --
+ control returns to whatever is driving the engine (interactively,
+ or the next line of a "script" test suite), rather than killing the
+ process. This is what a stuck ECM/EPD batch position needs: a way
+ to bail out of one pathological search without losing the rest of
+ the run.
+
+ If nothing is being searched right now there's nothing to abort.
+ For SIGINT specifically, fall back to the normal terminate-the-
+ process behavior in that case (restore the default disposition and
+ re-raise) so Ctrl-C still works as expected when the engine is
+ idle. SIGHUP while idle is just ignored.
+
+ A second ^C sent while the first one's abort is still pending (the
+ search hasn't unwound to idle yet -- it can take a moment, e.g. if
+ we're deep inside a long leaf-node burst between TIMER_STOPPING
+ checks) is treated as "I mean it" and kills the process immediately,
+ matching the usual double-^C idiom instead of silently eating the
+ second press.
+
+Parameters:
+
+ int iSig
+
+Return value:
+
+ void
+
+**/
+{
+ if (FALSE == g_Options.fThinking)
+ {
+ g_fSigintAbortPending = 0;
+ if (SIGINT == iSig)
+ {
+ signal(SIGINT, SIG_DFL);
+ raise(SIGINT);
+ }
+ return;
+ }
+
+ if (SIGINT == iSig)
+ {
+ if (0 != g_fSigintAbortPending)
+ {
+ signal(SIGINT, SIG_DFL);
+ raise(SIGINT);
+ return;
+ }
+ g_fSigintAbortPending = 1;
+ }
+ Trace("SIGINT/HUP -- STOP SEARCHING NOW");
+ g_MoveTimer.bvFlags |= TIMER_STOPPING;
+}
+
+
FLAG
SystemDependentInitialization(void)
/**
@@ -1043,5 +1116,23 @@ Return value:
}
InitializeListHead(&g_SystemThreadList);
pthread_mutex_init(&g_SystemLock, NULL);
+
+ //
+ // SIGHUP/SIGINT: abort just the in-progress search (see
+ // _AbortSearchOrTerminate's comment) instead of the default
+ // terminate-the-process disposition. SA_RESTART so a blocking
+ // syscall elsewhere in the process (e.g. the input thread's read())
+ // doesn't get kicked out with EINTR by a signal that has nothing to
+ // do with it.
+ //
+ {
+ struct sigaction sa;
+ memset(&sa, 0, sizeof(sa));
+ sa.sa_handler = _AbortSearchOrTerminate;
+ sa.sa_flags = SA_RESTART;
+ sigemptyset(&sa.sa_mask);
+ sigaction(SIGHUP, &sa, NULL);
+ sigaction(SIGINT, &sa, NULL);
+ }
return(TRUE);
-}
+}
diff --git a/tests/ECM.md b/tests/ECM.md
index 62c5bd4..d3077a3 100644
--- a/tests/ECM.md
+++ b/tests/ECM.md
@@ -88,6 +88,9 @@ session).
| ECM.717 | ringers | `r1qb1r1k/2p3pp/p1n1bp2/1p1Np2Q/P3P3/1BP3R1/1P3PPP/R1B3K1 w - - 0 0` | axb5 | Abandons a `++`-confirmed axb5 (depth 10, +3.16) for an unconfirmed Rxg7 (+3.17) at the very last iteration -- same shape as ECM.357. Lost by the f698c02 eval-margin-redesign build (2026-09-18) |
| ECM.601 | hard_quick | `8/p5k1/2p2p2/4p1p1/1p2P3/1P4PP/1PP5/5K2 b - - 0 0` | g4 | No iteration lines printed at all before the depth limit; final score a dead-equal +0.07 -- no confirmation of any kind. Lost by the f698c02 eval-margin-redesign build (2026-09-18) |
| ECM.193 | hard_quick | `r2r1bk1/3qp2p/3pp1p1/p2n2N1/2N3Q1/BP4P1/P4PP1/2R3K1 w - - 0 0` | Nb6 | Wavers between Qh3/Qf3 through most iterations, lands on Nb6 only at the sn5M node-budget cutoff, unconfirmed. Lost by the f698c02 eval-margin-redesign build (2026-09-18) at sn5M |
+| ECM.770 | hard_quick | `rnb2rk1/pp3pbp/2p3pB/2q5/4P3/1BN5/PPP3PP/R2Q1R1K w - -` | Rxf7 | HEAD locks onto (an unconfirmed but stable) `Rxf7` through depth 12, then abandons it for `Bf4` from depth 12 (second entry) through depth 16+ (+2.60 -> +2.98 -> +2.57). **Crafty independently confirms `Bf4`, not `Rxf7`**, stably from depth 22 through 28 (+1.78 -> +2.61, six-plus stable iterations) -- the labeled "solution" appears to be wrong, not a search miss. Same wobble reproduced identically under both `JOINT_EXTRA_TIER1_THOUSANDTHS=72` and `=150` (2026-09-30 threshold-sweep investigation), confirming it predates and is unrelated to that change |
+| ECM.826 | confident_quick | `1rbq2k1/b1p3pp/p1Pp4/3Ppr2/8/5N2/PPQ2PPP/R1B2RK1 b - -` | Rxf3 | Sits on `Bd4` (-0.73) through depth 10, only switches to (and then stably confirms) `Rxf3` at depth 11-14. Identical behavior, same scores, same switch point under both `JOINT_EXTRA_TIER1_THOUSANDTHS=72` and `=150` (2026-09-30) -- a depth-budget/horizon effect on both thresholds, not a regression |
+| ECM.491 | hard_quick | `1r2r1k1/p4ppb/q1pbnn1p/4p3/N1P1P3/pPB2NP1/2Q1RPBP/R5K1 b - -` | Nxe4 | Wobbles `c5` (depths 9-11) -> `Bxe4` (depth 11-13) -> `Nxe4` (matching "solution", depth 13-14), never settling before the sd10/sn5M test budget. Identical wobble, same depths, same scores under both `JOINT_EXTRA_TIER1_THOUSANDTHS=72` and `=150` (2026-09-30) -- predates the threshold change |
## Gains seen (candidate solves, HEAD doesn't) -- not deeply vetted