diff options
| author | Scott Gasch <[email protected]> | 2026-10-04 08:17:52 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-10-04 08:17:52 -0700 |
| commit | bbdf1bda8f410df6aa9be0a38e71b7db04982198 (patch) | |
| tree | e8315247f700ee94875cd6d2eb5dd81aef71395d /src/chess.h | |
| parent | 9b28080f8b3dca4588cb2aaaf748451f45e3c626 (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-x | src/chess.h | 347 |
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, |
