| Age | Commit message (Collapse) | Author |
|
Comment-only; split.c never called the macro itself (it always fully
sorts its remaining moves regardless, unaffected by the rename), just
referenced the name in a doc comment explaining why it opts out of the
leftover-selection budget entirely.
|
|
NumLeftoverMovesToSelect, indexed by remaining depth; make EFP's
leftover-only scope explicit.
SEARCH_SORT_LIMIT[ply] was a poor proxy for what actually matters here
-- how large the remaining subtree below this node is. Distance from
root only correlates with that when total search depth is roughly
fixed; it says nothing once extensions/reductions/iterative-deepening
are in play. NumLeftoverMovesToSelect(ctx, uDepth) uses remaining depth
instead, only ever consulted once every high-performer move (winning/
even capture, killer, killer-mate -- anything >= GOOD_MOVE) has already
been exhausted; this never limits how many of *those* get selected,
only how much further care to spend on the ordinary/leftover tail.
Table values carried over verbatim from the old one as an untuned
starting point, just reindexed.
Also adds an explicit (TRUE == fInLeftovers) gate to EFP's per-move
checklist (landed last commit) -- every high-performer move was already
excluded as a side effect of the capture/check/killer exemptions, but
this makes "EFP only ever touches leftovers" a real, direct condition
rather than an emergent property of unrelated checks.
Verified against HEAD (commit bb07fbd) at sd10:
ecm_ringers: 9/11 -> 10/11 (+1 solve), ~flat nodes (-0.04%)
ecm_confident_quick: 88/90 -> 88/90 (even), +0.43% nodes
ecm_hard_quick: 15/90 -> 18/90 (+3 solves), +4.4% nodes
Net +4 solves across 269 positions for a negligible node-count cost.
|
|
Heinz's actual two-tier schedule, a hardened per-move checklist, and a
TT-soundness fix. Proto-LMR (live, unrelated) untouched.
The old condition used a single flat VALUE_ROOK margin across
uDepth <= TWO_PLY -- that's neither of the two numbers Heinz's book
actually specifies for that range. Split into his real two tiers,
each gated on the same common conditions (PV-node guard added; HEAD's
version had none, unlike GetLMRReduction which has always required
non-PV) but different depth bands and margins:
- frontier ("selective futility"): VALUE_KNIGHT, one ply above the
QSearch jump (bands are relative to THREE_QUARTERS_PLY, the actual
cutoff, not ONE_PLY, since the check-extension rework lowered it).
- pre-frontier ("extended futility pruning" proper): VALUE_ROOK, the
next ply out.
Heinz's third tier ("limited razoring", pre-pre-frontier, VALUE_QUEEN)
is a per-node depth reduction, not a per-move prune -- a different
technique, deliberately not implemented here (spiritual predecessor to
LMR, revisit then). Also drops the old ValueOfMaterialInTroubleDespite-
Move requirement (an en-prise/trapped-piece safety net) -- intent is
to fire on ordinary quiet positions too, not just ones with an already-
flagged piece in danger.
Per-move checklist, replacing an ASSERT that captures/checks couldn't
reach here (untrue in a non-DEBUG build, so no actual protection) with
real exemptions: explicit !IS_CAPTURE_OR_PROMOTION / !IS_CHECKING_MOVE,
a killer-adjacency exemption (ply-1/ply-3, borrowed from
GetLMRReduction), a well-evidenced fail-high-history exemption
(GetMoveFailHighPercentage, >=5 samples before trusting it), an
en-prise-escape exemption, and suppression at any node where this
node's own null-move probe raised fThreat.
TT-soundness fix (Heinz's own book, quoted directly): a node whose
result depends on alpha/beta via forward pruning can't be stored as an
exact score or a sound upper bound -- a skipped move might have been
the best one, so the true value could be higher than computed in
either case. Tracks fAnyMoveEFPPruned; downgrades an alpha-raise to
StoreLowerBound instead of StoreExactScore when set, and skips hash
storage entirely on a fail-low with pruning (no sound bound available
in either direction).
Verified against head_reference (HEAD, commit 7e762b2) at sd10:
ecm_ringers: 10/11 -> 9/11 (-1 solve), -8.96% nodes
ecm_confident_quick: 87/90 -> 88/90 (+1 solve), -16.49% nodes
ecm_hard_quick: 17/90 -> 15/90 (-2 solve), -15.94% nodes
Net -2 solves across 269 positions for 9-16% fewer nodes per suite --
similar shape to Heinz's own reported trade-off in the book (8 lost
solutions for -16.70% fewer nodes). Old flat-margin candidates built
earlier today (kept in git stash, not this commit) only achieved
0.3-1% node reduction vs. a no-EFP baseline; dropping the stale
material-in-trouble gate is what actually recovered real pruning
power, not the checklist alone.
|
|
of (cFrom, cTo, color), and expose sample size.
MOVE_TO_INDEX (used elsewhere for the counter-move table) folds any
two moves with the same from/to/color into one fail-high bucket --
e.g. a king shuffle and a queen sac to the same square/color shared
a slot. New MOVE_TO_FH_INDEX uses the low 20 bits of mv.uMove
(cFrom+cTo+pMoved), which already encodes color in pMoved's low bit,
so this is strictly more granular for free. Table grown 0x20000 ->
0x100000 entries to match. Also adds an optional ULONG *puAttempts
out-param so future callers can weight by sample confidence instead
of trusting a percentage computed from as few as one observation.
GetLMRReduction (proto-LMR, live at HEAD) is the only real consumer
right now; verified against head_reference at sd10 across
ecm_ringers/ecm_confident_quick/ecm_hard_quick: net +2 solves (88 vs
87 on confident_quick, 18 vs 17 on hard_quick), node counts flat
within noise (-1.5%/+1.4%/+0.5%).
|
|
32-bit clang isn't available in this environment, so every build this
session has needed the flag passed explicitly anyway; make it the
default via ?= (still overridable) rather than requiring it on every
invocation. Build-only change, verified byte-identical search behavior
(ringers/sd10: 10/11 solved, 46,341,121 nodes, matching head_reference
exactly).
|
|
one: always fully select "high performer" moves regardless of count,
only apply the per-ply budget to leftover ordinary moves.
The old gate (uLegalMoves < SEARCH_SORT_LIMIT(ply)) stopped selecting
carefully after a fixed count, counting the hash move too -- so at
ply 6+ (limit 5), a position with a hash move already used one of only
5 total slots before the cutoff hit. It had no way to tell "a handful
of mediocre quiet moves" from "a hash move plus three winning captures
and two killers" -- in the latter case, a real high-performer beyond
the 4th/5th slot would get treated identically to a random leftover
quiet move, even though generate.c had already tagged it as excellent.
Checked what three real engines do here: Crafty always fully sorts the
hash move, then MVV/LVA-ordered captures, then up to 4 killers -- its
own cheap fallback (a move-count cutoff, gated by remaining depth) only
ever applies to what's left after all of that, i.e. plain untested
quiet moves. Stockfish uses a value threshold, not a position/count
threshold, so a good move is never orphaned by where it happens to sit
in the list, only by its own assessed quality. Berserk never gates at
all -- full selection sort unconditionally, every node.
New design: keep fully selecting for as long as every move found so
far is >= GOOD_MOVE (a generate.c ordering-encoding constant that
already sits, by construction, below every killer tier and
SORT_THESE_FIRST's winning/even-capture range, and above ordinary
quiet moves and losing captures -- a real quality floor already baked
into the existing encoding, not a new one). The first selection that
reveals a move below that floor marks the transition to "the rest of
the team"; from there, SEARCH_SORT_LIMIT's existing table is reused
(as an explicitly untuned starting point -- its old numbers were
calibrated, if at all, against a different question: total selection
budget from move 1, not a leftover-only budget) to decide how many
more full selections are worth the cost before taking the remainder in
place. On an IID-rescored ply, GOOD_MOVE is meaningless (iValue is a
real eval-axis score there, not generate.c's encoding), so that ply
type keeps its existing unconditional full-select behavior unchanged.
Measured (ecm_ringers.ep_/ecm_confident_quick.ep_/ecm_hard_quick.ep_,
sn=5M) against the prior baseline (10/88/9): 11/87/12, net +3 solves.
EBF: unchanged on ringers, worse on confident_quick (the one suite that
also lost a solve -- consistent single-suite regression, not a
systemic pattern), better on hard_quick (paired with its solve gain).
Not yet a fully validated result -- SEARCH_SORT_LIMIT's numbers
(17/12/9/7/6/5) now need their own recalibration pass under this new
"leftover budget" meaning, since whatever they were tuned against
before doesn't apply to this role.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YGSMkwjqiCk4XhbfN7ugD2
|
|
winning move's own score, and stop discarding generate.c's ordering
information for moves it never got to search.
Two bugs, found while reading this function to understand it:
1. On fail-high, `goto end` jumped past the for loop's own x++, so x at
the `end:` label still pointed at the move that just failed high --
whose iValue had just been correctly set to its real score two lines
earlier. The clearing loop then started at that same x, immediately
overwriting the winning move's own just-computed score with
-INFINITY: exactly backwards, marking the one move IID found good
enough to fail high on as worst-possible, while the inferior moves
it beat kept their real scores and would be preferred instead.
2. Even with that fixed, every move after the winner was still set to
-INFINITY -- total, deliberate amnesia about generate.c's original
ordering estimate for moves we simply didn't get to (a fail-high
means we stop early on purpose, to avoid burning nodes confirming
what we've already decided to play). If the winner's fail-high
doesn't hold up at full depth, the caller falls back to a list where
every remaining move is a tied -INFINITY -- worse than never having
run IID at all for that tail, and inconsistent with -INFINITY's use
elsewhere in this function for genuinely-known-illegal moves.
Restructured to defer committing to mvf[].iValue until it's known
whether every move got an honest, fully-searched score (scores go into
a local scratch array during the loop instead of directly into the
move stack). On full completion, commit all of them and set
fMovesRescoredByIID as before. On fail-high, commit nothing -- leave
every move's original generate.c ordering value untouched, and bump
just the winning move into killer-tier territory (same trick
generate.c uses for a real killer move) so the normal, non-rescored
selection path still tries it first. fMovesRescoredByIID stays FALSE
in this case, since the ply's iValue is back to being generate.c's
ordering encoding, not real scores.
Measured (ecm_ringers.ep_/ecm_confident_quick.ep_/ecm_hard_quick.ep_,
sn=5M): 10/88/9, recovering the confident_quick point lost by the
previous IID-trust commit (was 10/87/9) with no cost elsewhere.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YGSMkwjqiCk4XhbfN7ugD2
|
|
Before searching its own move list, RescoreMovesViaSearch called itself
recursively at an even shallower depth, at the *same* ctx->uPly, on the
theory that the extra rescore's side effects (hash/killer/history table
population) would help the real loop's own -Search() calls find cutoffs
faster. But the recursive call's own iValue writes were always fully
overwritten by this same call's loop immediately after it (same ply,
same move-stack range), so the only way it could possibly help was via
those side effects.
Measured directly: disabling it produced a bit-identical result across
all three test suites (ecm_ringers.ep_, ecm_confident_quick.ep_,
ecm_hard_quick.ep_ at sn=5M) -- no change whatsoever, not even a single
position. It was pure wasted search effort. Removed.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YGSMkwjqiCk4XhbfN7ugD2
|
|
of iValue; harden against a latent ComputeMoveExtension bug.
DO_IID's "is the top move crappy" gate compared raw iValue against
SORT_THESE_FIRST only, missing that ordinary killer moves (FIRST_KILLER
through FOURTH_KILLER) sit below that threshold too -- a killer that
already proved itself elsewhere in the tree was being treated as
"crappy" and triggering an unnecessary shallow rescore. Fixed by also
excluding killer-flagged moves from the gate.
RescoreMovesViaSearch corrupted the winning move's real search score by
OR-ing in SORT_THESE_FIRST to force it to sort first (`mvf[uBest].iValue
|= SORT_THESE_FIRST`) -- unnecessary (SelectBest{With,No}History already
find the true max by plain magnitude comparison, no flag needed) and
actively dangerous: a later ComputeMoveScore() call on that same move,
if it's a capture, would see the corrupted value, mistake it for
generate.c's biased-capture-ordering format, and subtract the wrong
bias entirely. Removed the OR; added an explicit
PLY_INFO.fMovesRescoredByIID flag so ComputeMoveScore and the main
search-loop's move-selection call can both recognize "this ply's
iValue holds a real eval-axis score" without relying on bit-pattern
inference.
Consequently, ComputeMoveScore now trusts an IID-rescored move's score
outright instead of running it through the capture-bias-subtraction or
quiet-move-collapse-to-0 logic (both of which assume generate.c's
ordering encoding, which a rescored ply no longer holds). Separately
hardened it against quiet killer-mate moves, which can reach
SORT_THESE_FIRST via a different, capture-unrelated path and were
incorrectly getting the capture bias subtracted from them; they now
correctly collapse to 0 like other quiet moves.
Two follow-on ideas -- blending history into the real IID score (scaled
or capped) and a exact-tie-only history tiebreak -- were implemented,
measured, and rejected: blending invents a new, leak-prone move-scoring
axis for no measured benefit, and the tiebreak-only compromise still
cost solves relative to just trusting the real score outright. Main
search's move-selection call now branches once per selection (not once
per candidate move) between SelectBestNoHistory (IID-rescored plies)
and SelectBestWithHistory (everyone else), keeping the overwhelmingly
common non-rescored path at zero added cost.
Net measured effect (ecm_ringers.ep_/ecm_confident_quick.ep_/
ecm_hard_quick.ep_, sn=5M): 10/90/9, down from a pre-existing 11/88/10
on ringers and hard specifically -- see lmr_testing/RESULTS.md for the
full sweep of rejected alternatives and why the regression was accepted
as the cost of removing a latent, leak-prone bug class rather than
chasing the exact prior numbers.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YGSMkwjqiCk4XhbfN7ugD2
|
|
killer-mate edge case; fix PV-display cycle hang.
_ShouldWeConsiderThisMove (QSearch's move-consider gate) read the raw,
move-ordering-biased mvf[].iValue directly instead of going through
ComputeMoveScore, so it inherited the same +120-ish flat bias (plus
small MVV-LVA nudges) on winning/even captures that ComputeMoveScore
was already fixed to strip out. Fixed via the same MOVE_SCORE_ORDERING_BIAS
subtraction, now factored into a shared chess.h macro. Restoring the old
effective leniency required an explicit QSEARCH_CONSIDER_MARGIN (120,
A/B'd against 0/60/120 on ecm_ringers/confident_quick/hard_quick) rather
than assuming the bug's magnitude was itself a meaningful margin -- net
effect vs the pre-fix baseline is -2 solves on hard_quick, accepted as
the cost of correctness (see lmr_testing/RESULTS.md for the full sweep).
ComputeMoveScore separately mishandled quiet killer-mate moves: they can
reach SORT_THESE_FIRST via generate.c's killer-mate bonus (unrelated to
the capture-bias path), so the bias-subtraction was wrongly applied to a
move that never had that bias. Gated the subtraction on
IS_CAPTURE_OR_PROMOTION(mv); quiet moves (including killer-mate ones) now
correctly collapse to 0, per the function's contract of estimating a
move's value on the 100=1-pawn axis. Measured as a no-op on all three
suites -- rare in practice, but a real correctness fix. Left a comment
documenting two candidate refinements for scoring quiet moves as
non-uniform future work, deliberately not implemented (each needs its
own isolated test).
FinishPVTailFromHash (cosmetic PV-display hash-walk, used only for
printing) had no cycle detection, so a drawish/repeating position could
spin until the output buffer filled instead of terminating naturally.
Added visited-position-signature tracking and a <REP> marker.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YGSMkwjqiCk4XhbfN7ugD2
|
|
tune singular-reply-to-check margin.
QSearch stand-pat: the "deny stand pat when material is genuinely in
trouble" check was gated on fCouldStandPat (has this side had a chance
to stand pat earlier in this qsearch line). That's wrong -- whether an
ancestor node had a moment of safety says nothing about whether *this*
node's material danger is real; a hanging piece doesn't stop hanging
because the position was quiet three plies ago. fCouldStandPat's
legitimate uses (search.c:950, 1190/1193) are about deciding whether a
*whole line* looks forcing enough to justify extra qsearch depth/
breadth, a different question from per-node stand-pat correctness.
Removed the gate; the material-in-trouble check now always denies
stand-pat, regardless of history.
ComputeMoveScore: for winning/even captures and promotions, the value
extracted from the move-ordering sort key (generate.c's _ScoreAllMoves)
included a flat +120 ordering bias plus small MVV-LVA tie-break nudges
(PIECE_VALUE_OVER_100 terms) baked in on top of the real SEE/
material-diff value. Harmless for its original sorting purpose (every
capture gets the same treatment), but this function's callers
(futility pruning, the singular-reply-to-check extension) use the
result as an eval-axis quantity compared against material-scale
margins -- the contamination doesn't belong there. Subtracted the
ordering-only bias back out to recover pure SEE/material-diff, same
axis as the raw PIECE_VALUE() fallback used when no move-stack index is
available. Quiet-move and losing-capture handling were already correct
(both collapse to a clean, uncontaminated value).
Also bumped the singular-reply-to-check margin (225 -> 400): confirmed
via direct A/B on the quick suites that this is a real, independent
improvement on top of the SEE fix, not just compensating for it --
reverting to 225 measurably regressed both ecm_confident_quick.ep_
(89->88/90) and ecm_hard_quick.ep_ (10->6/90) versus keeping 400.
Verified against pristine baseline (no LMR in this binary) on the
three-suite protocol (ecm_ringers.ep_, ecm_confident_quick.ep_,
ecm_hard_quick.ep_, sn=5M, book disabled): 11/11 ringers (matches
baseline exactly), 89/90 confident (vs baseline's 90/90), 10/90 hard
(vs baseline's 4/90) -- a real net improvement over baseline with zero
LMR involved, considerably stronger than any state reached earlier in
this session's LMR-only experimentation.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YGSMkwjqiCk4XhbfN7ugD2
|
|
Finishes work left half-done in 7857096 ("Replace ctx->uPositional with
a data-calibrated Eval() return value"): that commit added Eval()'s new
piPositional out-param but never migrated GetRoughEvalScore onto it, so
GetRoughEvalScore's mid/deep-tree fallback kept reading the old
ctx->uPositional field -- a per-thread EWMA written only on full-eval
calls and never touched by the (far more common) lazy-eval path, so it
carried a stale value from whatever unrelated position last triggered a
full eval, potentially many nodes/plies away. Combined with EVAL_HASH
being long since disabled (its probe branch already dead), every
GetRoughEvalScore call past ply 4 was effectively "material + garbage."
Fixed by having GetRoughEvalScore just call Eval() directly -- its own
lazy-exit machinery already is the cheap, calibrated estimate this
function exists to provide, so there's no separate estimator to
maintain. Removed ctx->uPositional entirely (struct field, its EWMA
update in eval.c, both root.c init sites, split.c's cross-split
propagation, testeval.c's reset) along with the entire EVAL_HASH
subsystem (struct, table, Probe/StoreEvalHash, main.c's now-dead
reporting branch, the GNUmakefile flag) -- confirmed unused elsewhere
and explicitly being cut for good, not coming back in this form.
Also fixed GetRoughEvalScore's prototype being wrongly declared inside
#ifdef EVAL_HASH in chess.h even though the function itself is defined
and called unconditionally -- this was the source of the recurring
"call to undeclared function 'GetRoughEvalScore'" implicit-declaration
warning seen throughout this session's builds.
Separately, fixed QSearch to match its own documented intent: the
en-prise/trapped-piece "don't let this side stand pat" check now only
fires if the side hasn't already been allowed to stand pat earlier in
this qsearch line (matching the comment above it, which already said
this but the code never implemented it).
Verified against baseline/typhoon_baseline (pristine, pre-session) on
ecm_ringers.ep_ (4), ecm_hard_quick.ep_ (50-sample), and
ecm_confident_quick.ep_ (40) at sn=5M, --cpus 1, book disabled:
pristine baseline solves 3/50 on the hard sample; this commit solves
6/50, with the stand-pat fix and GetRoughEvalScore fix each
contributing +1 independently confirmed. No regressions on the other
two suites (4/4 and 40/40 unchanged throughout).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YGSMkwjqiCk4XhbfN7ugD2
|
|
uMax (the largest histogram bucket count) can be 0 when a script run's
positions are all unsolved -- ASSERT(uMax > 0) doesn't stop this in a
non-DEBUG build, and the following loop unconditionally divides by
uMax, crashing with SIGFPE. Hit repeatedly today running single-position
diagnostic scripts (an isolated unsolved position naturally has an
all-zero histogram) while investigating an LMR regression on ECM.213.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YGSMkwjqiCk4XhbfN7ugD2
|
|
EBF/beta-cutoff/counter-move stats, script.c FPE fix.
No LMR, no counter-move-driven move ordering (both explored separately,
kept out for now -- counter-move measured worse, ~655->647 solved on
ecm879 @ sn=4M with a leaner tree beforehand). Futility pruning restored.
Verified: 647/879 solved, EBF 4.609 @ sn=4M; 684/879 solved, EBF 3.995
@ 20s/move, 1cpu, 256m hash (typhoon_baseline.log).
The counter-move table is still written and its stats still tracked
(dynamic.c) for diagnostic purposes, but generate.c no longer reads it
for move ordering, so it has no effect on search behavior in this
commit.
lmr_testing/ holds the in-flight graded-LMR + counter-move code (not
applied here) with notes on what was already tried and measured, so a
future session can resume without re-deriving it.
|
|
uPositional was a per-thread EWMA of abs(material - true score) used to
size lazy-eval and futility margins. It was history-derived (reflecting
whatever recent, unrelated positions looked like) rather than derived
from the position actually being margined, and its update/consumption
was tangled with EVAL_HASH (now disabled).
Eval() now takes an optional SCORE *piPositional out-param and fills it
in on every return path: exact (abs(material-delta)) on a full eval,
or an estimate from a new EstimatePositionalScore() on a lazy exit.
EstimatePositionalScore()'s two terms (king-safety-defect-bucketed, and
a flat residual for mobility/passers/everything else) are calibrated
from ~1.6M measured full-eval samples (p90 of the actual swing), not
guessed -- an initial guessed version measurably regressed ECM solve
rate (630 vs a 650 baseline at sn=4M); the recalibrated version is back
at parity (649/879).
search.c's qsearch futility now reads the value Eval() just computed
instead of the stale/shared ctx field. Also removes QSearchInDangerNoStandPat
and SideCanStandPat, dead since the danger-hash check that fed them was
already commented out (e08387a) -- they depended on the same enprise/
trapped-piece data this conversation is about to move off of
g_PositionHash entirely.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
|
|
|
|
|
|
|
|
Profiling (pmcstat, sampled) showed SideCanStandPat as the single
hottest leaf function in the engine, ahead of Eval itself -- not from
expensive logic (it's a 3-line signature/lock/lookup), but from sheer
call volume (~95% of the tree is qsearch) combined with a likely-cold
16MB global hash table probe and an uncontended-but-nonzero lock/unlock
pair paid on every call even single-threaded.
Disabling it entirely: real bench nps was flat (~1.53M vs ~1.57M,
within noise -- the earlier "1.2M->1.75M" bench reading was itself an
uncontrolled, noisy single comparison on this shared box, not a real
effect). But ECM (sn 4M) came back at 655/879, the best result of the
whole session (vs 650 baseline) -- suggests the danger-detection
heuristic may have been net-negative for search quality on balance,
forcing exhaustive no-stand-pat search in some positions where
standing pat was actually fine. Speed claim didn't hold up; the
tactical-quality result is a genuine, unexpected positive worth
investigating further.
Not yet validated in self-play -- pending a clean match run.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
|
|
Fixes a real pathology: checks along a long unbroken forcing line could
extend for free (net zero cost against the qsearch boundary), letting
tree size blow up multiple orders of magnitude on positions like a
near-all-check forced mate (ECM.089: 4.5B nodes / 31min at depth 12
before this change).
- Main-search check extension: gate on SEE soundness (a losing
sacrifice check gets a small consolation QUARTER_PLY instead of the
full bonus a sound check gets), and flatten the sound-check bonus to
a flat THREE_QUARTERS_PLY instead of a near-free ONE_PLY.
- Lower the qsearch entry threshold to match (THREE_QUARTERS_PLY
instead of ONE_PLY) so a lone check still buys one extra full-width
ply as before; root.c trims QUARTER_PLY off the per-iteration depth
budget so this doesn't add a blanket 1/4 ply to every search.
- Qsearch's own check-widening (QSearchFromCheckNoStandPat) now relies
on fCouldStandPat history plus a g_uIterateDepth/4 ceiling instead of
an unconditional per-check grant, and QPLIES_OF_NON_CAPTURE_CHECKS
moved from 1 to 2 to cover both "enter qsearch already in check" and
"opponent's reply is the first real check" cases with one baseline
window instead of ad hoc attacker-color tracking.
Net effect on ECM.089 (sn 4M canary): ~7.5x fewer nodes and ~4x less
time at depth 12 versus the original, unbounded behavior. Costs solve
count on the full ECM suite (879 pos, sn 4M): 650 baseline -> 636 here
-- expected and accepted, since ECM is unusually check-extension-heavy
tactics and not representative of real games. Self-play vs baseline
(1000 games, st 1) came back at B_SCORE=0.4955, ELO=-3.1+/-21.5 --
statistically neutral, confirming the fix costs nothing in real play.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
|
|
Late Move Reductions using a depth x movecount table (Ethereal-style
formula), gated off PV nodes and the ply directly below a PV node
(PLY_INFO.fIsPVNode), with magnitude-aware re-search on fail-high.
Verified node-for-node identical to the previously tested-good v7
binary on a canary position (sd 10) after reconstructing from a ZFS
snapshot of search.c/root.c/split.c taken just before that binary was
built.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
|
|
|
|
|
|
|
|
|
|
|
|
it found)
|
|
|