summaryrefslogtreecommitdiff
path: root/src/chess.h
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 /src/chess.h
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]>
Diffstat (limited to 'src/chess.h')
-rwxr-xr-xsrc/chess.h347
1 files changed, 342 insertions, 5 deletions
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,