summaryrefslogtreecommitdiff
path: root/src/generate.c
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-08-29 15:03:54 -0700
committerScott Gasch <[email protected]>2026-08-29 15:03:54 -0700
commit0b12137376929d96da81cd088d3d288cb1eec32d (patch)
tree28cbd63a95629719bd6145c9b1c6024b34854f4a /src/generate.c
parenta56b15320444fcfe3aabdd3768c81c733f3d776b (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-xsrc/generate.c113
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;