diff options
| author | Scott Gasch <[email protected]> | 2026-08-29 15:03:54 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-08-29 15:03:54 -0700 |
| commit | 0b12137376929d96da81cd088d3d288cb1eec32d (patch) | |
| tree | 28cbd63a95629719bd6145c9b1c6024b34854f4a /src/generate.c | |
| parent | a56b15320444fcfe3aabdd3768c81c733f3d776b (diff) | |
Dynamic move ordering overhaul: continuation-history, evidence-gated
countermove promotion, retired hung-piece-escape and NumLeftoverMovesToSelect.
Full session was built on a "measure the pick, not the game" methodology:
aggregate solve counts on curated suites are too noisy to tune move-ordering
knobs against, so most decisions here came from per-move fail-high/alpha-raise
rates at much larger sample sizes (leftover FH% instrumentation, a zero-
selection-budget diagnostic that isolates a single best-of-remaining pick,
and evidence-bucket calibration), not solve-count deltas alone. See
CLAUDE.md's "Dynamic move ordering experiments" section for the reusable
methodology and generate.c's _ScoreAllMoves comment for the resulting
ordering hierarchy.
Changes:
- Added g_ContinuationHistory: same growth/decay math as the existing
g_HistoryCounters butterfly table, additionally keyed by the previous
move, so its magnitude is self-calibrated rather than a hand-picked
constant. Flat, sufficient response across a 256x scale sweep.
- Countermove-table matches now get a real GOOD_MOVE-tier promotion
(previously the table was write-only, tracked for stats but never read
for ordering), but only when the match's own accumulated
history+continuation evidence clears COUNTERMOVE_EVIDENCE_THRESHOLD
(10,000) -- a raw match with no track record was shown to perform
identically to an ordinary leftover (~0.6-0.85% FH), so promoting on
match alone would have repeated hung-piece-escape's mistake below.
- Retired hung-piece-escape's unconditional GOOD_MOVE-tier promotion.
Evidence-calibration showed the overwhelming majority of triggers (a
zero-evidence population 250-1000x larger than countermove's) performed
at the plain-leftover baseline -- the promotion was mostly free tier-
escape treatment for moves that hadn't earned it. Replaced with
FLEE_BONUS, a flat same-tier nudge inside SelectBestWithHistory (never
escapes GOOD_MOVE/leftover classification, unlike a generation-time
promotion) at the magnitude found to plateau a same-tier-nudge sweep.
- Retired NumLeftoverMovesToSelect (the depth-indexed budget on how many
leftover moves got a full selection scan before falling back to
unsorted order). search.c's main move loop now always fully selects --
the leftover pool was shown to contain real, findable signal a bailout
budget was discarding for a node-count savings that didn't hold up net-
net once measured by solve counts and fail-high rates rather than raw
node counts (noisy on small suites independent of this change).
- Collapsed leftover-move instrumentation from sorted/raw pairs down to a
single set now that "raw" (unsorted fallback) is structurally
impossible; kept the countermove evidence-bucket calibration counters
(ongoing check that COUNTERMOVE_EVIDENCE_THRESHOLD stays well-
calibrated); removed the contested-node A/B harness and hung-piece
evidence calibration now that the decisions they were built to inform
are made.
Net effect on the three curated suites (sd 10): solve counts wash (tied,
+1, -1 across ringers/confident/hard), leftover fail-high rate improved
consistently on all three (the intended, directly-measured target of this
work). Not yet validated beyond sd 10 -- an sn-based run or
eval_tune/match_play.py head-to-head gate is the natural next check before
leaning on this as a proven strength gain rather than a directionally-
sound, sd-10-clean change.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014XePz6Sk4qQsTaP2jVJWJu
Diffstat (limited to 'src/generate.c')
| -rwxr-xr-x | src/generate.c | 113 |
1 files changed, 110 insertions, 3 deletions
diff --git a/src/generate.c b/src/generate.c index 3e3c3a5..765c082 100755 --- a/src/generate.c +++ b/src/generate.c @@ -2433,6 +2433,56 @@ Routine description: We have just generated all the moves, now score them. See comments inline below about order. + Full move-ordering hierarchy for a normal (not-in-check) node, from + highest-scored to lowest -- "vNext", the dynamic-move-ordering + overhaul from the 2026-08-29 session (see CLAUDE.md's "Dynamic move + ordering experiments" section for the methodology behind it): + + 1. Hash move -- already handled by Search(), never (re)generated + here; excluded from this list entirely. + 2. Killer that also threatens/delivers mate -- FIRST_KILLER (or + whichever killer slot matched) OR'd with SORT_THESE_FIRST. + 3. Winning/even captures & promotions -- SORT_THESE_FIRST, MVV/LVA + tiebreak; strictly-winning-by-raw-material trades skip SEE, an + even or ambiguous trade (or any promotion) gets the full + exchange walk. + 4. Killers, this ply's slot 0/1, then ply-2's slot 0/1 (Crafty-style + ordering, this ply's own pair before either ply-2-back one) -- + FIRST_KILLER/SECOND_KILLER/THIRD_KILLER/FOURTH_KILLER. + 5. A countermove-table match (exact move, keyed by whatever the + opponent just played) *whose own accumulated history+ + continuation-history evidence clears COUNTERMOVE_EVIDENCE_ + THRESHOLD* -- COUNTERMOVE_TIER_BONUS, placed below both ply-2 + killer slots (conservative default; never validated as + deserving to outrank them). An *unevidenced* match gets no + special treatment at all and falls through to tier 6 -- see + chess.h's COUNTERMOVE_TIER_BONUS/COUNTERMOVE_EVIDENCE_THRESHOLD + comments for why the promotion is evidence-gated rather than + automatic on a bare match. + 6. GOOD_MOVE boundary. Below this, "leftover" (see search.c's + fIsLeftoverMove) -- eligible for EFP pruning, but every leftover + is still fully ranked via SelectBestWithHistory (movesup.c); the + old NumLeftoverMovesToSelect depth-indexed selection budget was + retired once evidence showed the leftover pool has real, + findable signal a bailout was discarding. An ordinary leftover + quiet move's score is: + PSQT (g_iPSQT[piece][to], 0..1000) + + g_HistoryCounters[piece][to] (classic butterfly history, + (depth+1)^2 growth on fail-high, decays on a miss or global + aging pass) + + CONTINUATION_SCALE * g_ContinuationHistory[(prev move, this + move)] (same growth/decay math as history, additionally + keyed by the previous move -- self-calibrating, no magic + constant needed; CONTINUATION_SCALE=1 found flat/sufficient + across a 256x sweep) + + FLEE_BONUS if this move's origin square is currently en + prise (flat same-tier nudge; the old unconditional GOOD_ + MOVE-tier promotion for these moves was retired after + evidence-calibration showed most such moves, unbacked by + real track record, perform identically to an ordinary + leftover -- see the RETIRED comment below). + 7. Losing captures -- negative, SEE-verified. + Parameters: IN MOVE_STACK *pStack, @@ -2454,7 +2504,9 @@ Return value: ULONG uHashMoveLoc = (ULONG)-1; ULONG uColor = pos->uToMove; PRECOMP_KILLERS sKillers[4]; - COOR cEnprise = FindEnprisePiece(ctx, uColor); + ULONG uCounterMoveIdx = 0; + ULONG uCounterMoveContKey = 0; + FLAG fHaveCounterMove = FALSE; // // We have generated all moves here. We also know that we are not @@ -2506,6 +2558,18 @@ Return value: sKillers[3].uBonus |= (SORT_THESE_FIRST * (IS_KILLERMATE_MOVE(sKillers[3].mv) != 0)); + // EXPERIMENT: countermove-match tier. Precompute the previous + // move's countermove-table index once per node (mirrors killer + // precompute above) so the per-move loop below is just an + // IS_SAME_MOVE check, not a recompute. + fHaveCounterMove = FALSE; + if ((uPly > 0) && (0 != (ctx->sPlyInfo[uPly - 1]).mv.uMove)) + { + uCounterMoveIdx = MOVE_TO_INDEX((ctx->sPlyInfo[uPly - 1]).mv); + uCounterMoveContKey = MOVE_TO_CONT_KEY((ctx->sPlyInfo[uPly - 1]).mv); + fHaveCounterMove = TRUE; + } + // // Score moves // @@ -2587,8 +2651,51 @@ Return value: s |= (IS_SAME_MOVE(sKillers[1].mv, mv) * sKillers[1].uBonus); s |= (IS_SAME_MOVE(sKillers[2].mv, mv) * sKillers[2].uBonus); s |= (IS_SAME_MOVE(sKillers[3].mv, mv) * sKillers[3].uBonus); - s |= ((GOOD_MOVE + PIECE_VALUE(mv.pMoved) / 2) * - (mv.cFrom == cEnprise)); + + // RETIRED: hung-piece-escape used to get an unconditional + // (GOOD_MOVE + PIECE_VALUE/2) promotion here for any move + // whose origin square was flagged en-prise, regardless of + // any track record. Evidence-calibration data showed the + // overwhelming majority of triggers (the "zero accumulated + // history/continuation evidence" bucket -- 250-1000x more + // populous than the equivalent countermove-match bucket) + // had a fail-high rate statistically identical to an + // ordinary, unprivileged leftover move (~0.22-0.25% vs. + // ~0.19-0.21% baseline). The escape motif alone, with no + // verification the destination is actually safe or the + // threat was real, isn't a trustworthy enough signal to + // justify an unconditional tier promotion -- unlike + // killers (self-evidencing by construction) or a + // sufficiently-evidenced countermove match. Retired + // rather than evidence-gated: a well-evidenced escape + // still gets ranked via the existing, already-validated + // history/continuation scoring below, same as any other + // leftover -- no separate mechanism needed for that case. + + // Countermove-match tier, evidence-gated -- see chess.h's + // COUNTERMOVE_TIER_BONUS/COUNTERMOVE_EVIDENCE_THRESHOLD + // comment. Calibration data showed a raw match, with no + // track record, performs identically to an ordinary + // leftover (~0.6-0.85% FH) -- promoting on match alone + // repeats hung-piece-escape's mistake. Only promote once + // the same history+continuation evidence already used + // to score ordinary leftovers clears a threshold where + // it demonstrably beats killer-ply2's own average. + // Written into iValue itself (not a selection-time-only + // nudge) so a qualifying match actually escapes + // GOOD_MOVE/leftover classification. + if ((TRUE == fHaveCounterMove) && + (IS_SAME_MOVE(mv, ctx->mvCounter[uCounterMoveIdx][0]) || + IS_SAME_MOVE(mv, ctx->mvCounter[uCounterMoveIdx][1]))) + { + ULONG uCMEvidence = g_HistoryCounters[mv.pMoved][mv.cTo] + + g_ContinuationHistory[(uCounterMoveContKey * CONT_KEY_RANGE) + + MOVE_TO_CONT_KEY(mv)]; + if (uCMEvidence >= COUNTERMOVE_EVIDENCE_THRESHOLD) + { + s |= COUNTERMOVE_TIER_BONUS; + } + } ASSERT(s >= 0); } pStack->mvf[u].iValue = s; |
