| Age | Commit message (Collapse) | Author |
|
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%).
|
|
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
|
|
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]>
|
|
|
|
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]>
|
|
|