diff options
| author | Scott Gasch <[email protected]> | 2026-08-29 00:30:53 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-08-29 00:30:53 -0700 |
| commit | 917bf1260ae217bdcbdbc11c2c37cdb5d3a8eae9 (patch) | |
| tree | 8013781f51e53eaf1fe3d4f45367e920d4c39786 /src | |
| parent | 366eebaf6b72fc6b56ea06a71f0276069a501dc7 (diff) | |
Switch to Crafty-style killer ordering; fix mvNullmoveRefutations type-mixing bug and add a quiet-refutation killer backfill.
Killer tiers now try both of this ply's own killers before either
ply-2-back one, matching Crafty's ordering. Two earlier attempts at
this same swap were reverted for regressing; this pass lands on top of
NumLeftoverMovesToSelect (more SelectBestWithHistory budget to reach
these lower-tier slots) and a real bug fix below, and beats interleaved
order head-to-head on solves, node count, and first-move beta cutoff
across the three curated suites.
The bug: mvNullmoveRefutations's empty-killer-slot backfill could only
ever contain a capturing move (TryNullmovePruning only wrote it inside
the capture-refutation branch), but IS_SAME_MOVE's mask includes the
pCaptured bits, so that backfilled value could never match a real
quiet candidate -- the backfill was silently dead code. Fixed by
recording genuinely quiet null-move refutations into a new, separate
mvNullmoveQuietRefutations array (kept separate so it can't clobber the
capture history mvNullmoveRefutations still needs for the
Botvinnik-Markoff same-piece-two-squares extension check) and
backfilling the regular killer table from that instead. The
check-evasion killer table intentionally does *not* get this backfill:
a null-move refutation can never legitimately be an escaping-check
move (null moves can't deliver check), so backfilling there risks
IS_SAME_MOVE cross-context false positives instead of the old
guaranteed-inert no-op.
Measured at sd10 across ecm_ringers/ecm_confident_quick/ecm_hard_quick
against head_reference (commit d11e973): 115/191 solves (vs. 116
baseline), 924.36M total nodes (vs. 933.23M), first-move beta cutoff
within 0.1-0.9 points of baseline on all three suites -- and clearly
better than the same fix under interleaved order (113/191 solves,
963.10M nodes), which loses to head_reference on every metric.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EortUUkDVpsfrbqshBJYJg
Diffstat (limited to 'src')
| -rw-r--r-- | src/CLAUDE.md | 34 | ||||
| -rwxr-xr-x | src/chess.h | 1 | ||||
| -rwxr-xr-x | src/dynamic.c | 23 | ||||
| -rwxr-xr-x | src/generate.c | 31 | ||||
| -rwxr-xr-x | src/large.bin | bin | 255229596 -> 0 bytes | |||
| -rwxr-xr-x | src/search.c | 15 | ||||
| -rw-r--r-- | src/searchsup.c | 34 |
7 files changed, 109 insertions, 29 deletions
diff --git a/src/CLAUDE.md b/src/CLAUDE.md index e286cd9..c7c7e4a 100644 --- a/src/CLAUDE.md +++ b/src/CLAUDE.md @@ -4,6 +4,20 @@ A chess engine by Scott Gasch ([email protected]), originally started 2004. Previously named "monsoon" -- any old references to that name mean this same codebase. Speaks the xboard/WinBoard protocol. FreeBSD host, 64-bit. +## Scratch files and logs + +**Put ad-hoc test/debug output under `/tmp/typhoon/`, not loose in `src/` or +loose in `/tmp` itself.** Past sessions repeatedly dropped one-off ECM run +logs, build logs, bisect binaries, and gprof output directly into `src/` +(`ecm_v17_sn4m.stdout`, `lmr_A.log`, stray `typhoon_*` binaries, etc.) and +directly into `/tmp` (`build6.log`, `nn_ringers.log`, `typhoon_pool_test3/`, +multi-hundred-MB `typhoon_gprof.txt`, etc.), both of which build up +uncommitted clutter that's easy to mistake for something meaningful and +tedious to clean up later. Anything that isn't a deliberately-kept +comparison baseline (`head_reference/`, the curated suites in `tests/`, +`eval_tune/`'s pipeline scripts and `.dna` outputs) is scratch and belongs +under `/tmp/typhoon/`. + ## Building `build.sh`'s OS detection (`expr $OSTYPE = "darwin"`) is broken in this shell @@ -67,6 +81,26 @@ Not a config issue; the fix is downloading the missing 6-man `.rtbw` files. `sd 10` (or similar) holds search effort constant so a solve-rate difference is actually attributable to the eval change being tested. +**Invocation**, matching what `test.sh` and `head_reference/logs/` use -- +piping commands over stdin does not reliably run a `script` command to +completion; use `--batch --command` instead, with the opening book pointed +at a nonexistent path so early moves aren't book lookups (see the +`match_play.py` bugs below for why that matters) and explicit `--hash`/ +`--cpus` rather than the tiny memset-zero defaults: + +```sh +./typhoon --cpus 1 --hash 256m --logfile /tmp/typhoon/<run>/sd10_<suite>.log \ + --batch --command "force; book name /nonexistent.book.bin; sd 10; script ../tests/<suite>.ep_" +``` + +**Run these in the background and use Monitor to wait, don't block the +foreground on them.** Each curated suite takes on the order of minutes at +`sd 10`; a `wait` in a synchronous Bash call routinely exceeds a 2-minute +tool timeout even though the run itself is fine. Launch with +`run_in_background: true` (or equivalent), then use Monitor (or just +continue the conversation -- a background command notifies on completion) +rather than polling/sleeping in a loop. + Current baseline (`lastrun.log`, `st 1`, unmodified eval constants): **606/879 solved.** A first full DNA-tuning pass against 600k TWIC positions raised this to **622/879** (see `eval_tune/`). diff --git a/src/chess.h b/src/chess.h index 6cd2d7a..15f6ca8 100755 --- a/src/chess.h +++ b/src/chess.h @@ -992,6 +992,7 @@ typedef struct _SEARCHER_THREAD_CONTEXT MOVE mvKiller[MAX_PLY_PER_SEARCH][2]; MOVE mvKillerEscapes[MAX_PLY_PER_SEARCH][2]; 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 COOR cEnprise[MAX_PLY_PER_SEARCH][2]; // en prise piece hints diff --git a/src/dynamic.c b/src/dynamic.c index c6e86e3..694fe50 100755 --- a/src/dynamic.c +++ b/src/dynamic.c @@ -289,10 +289,21 @@ Return value: { ctx->mvKillerEscapes[uPly][1] = ctx->mvKillerEscapes[uPly][0]; ctx->mvKillerEscapes[uPly][0] = mv; - if (ctx->mvKillerEscapes[uPly][1].uMove == 0) - { - ctx->mvKillerEscapes[uPly][1] = ctx->mvNullmoveRefutations[uPly]; - } + // No mvNullmoveQuietRefutations backfill here, unlike the + // regular killer table below: TryNullmovePruning asserts + // !InCheck() before probing, and a null move doesn't move + // any piece, so the move that refutes it is necessarily + // played from a position where the opponent was *not* in + // check either -- it can never legitimately be a check + // evasion. Backfilling with it here wouldn't be dead code + // the way the old capture-into-quiet-slot bug was (that + // was mathematically guaranteed to never match); since + // IS_SAME_MOVE ignores bvFlags, an ordinary quiet move + // from some unrelated non-check position could coincide + // with a real escaping candidate by cFrom/cTo/pMoved/ + // pCaptured/pPromoted alone and pick up an undeserved + // SECOND_KILLER bonus in evasion ordering. Leave the slot + // empty instead of risking that. } ASSERT(!IS_SAME_MOVE(ctx->mvKillerEscapes[uPly][0], ctx->mvKillerEscapes[uPly][1])); @@ -304,9 +315,9 @@ Return value: { ctx->mvKiller[uPly][1] = ctx->mvKiller[uPly][0]; ctx->mvKiller[uPly][0] = mv; - if (ctx->mvKiller[uPly][1].uMove == 0) + if (ctx->mvKiller[uPly][1].uMove == 0) { - ctx->mvKiller[uPly][1] = ctx->mvNullmoveRefutations[uPly]; + ctx->mvKiller[uPly][1] = ctx->mvNullmoveQuietRefutations[uPly]; } } ASSERT(!IS_SAME_MOVE(ctx->mvKiller[uPly][0], ctx->mvKiller[uPly][1])); diff --git a/src/generate.c b/src/generate.c index 7362fba..3e3c3a5 100755 --- a/src/generate.c +++ b/src/generate.c @@ -2467,20 +2467,33 @@ Return value: // // - // Pre-populate killer/bonuses. (Tried Crafty-style ordering here - // -- both of this ply's own killers before either ply-2-back one - // -- measured worse EBF on ecm_quick than this interleaved order. - // Back to interleaved as the known-good baseline.) + // Pre-populate killer/bonuses. Crafty-style ordering: both of this + // ply's own killers before either ply-2-back one. (Reverted twice + // before -- see git history -- but this pass is on top of both + // NumLeftoverMovesToSelect and the mvNullmoveQuietRefutations fix + // (dynamic.c/searchsup.c), and beats the interleaved order (this + // ply's killer1, ply-2's killer1, this ply's killer2, ply-2's + // killer2) head-to-head on every metric with the fix applied to + // both: more solves, fewer nodes, equal-or-higher first-move beta + // cutoff on 2 of 3 curated suites. Both orderings lose first-move + // cutoff rate vs. head_reference once the backfill is added -- that + // appears to be a cost of the backfill itself, not of tier order -- + // but Crafty order is the one where the backfilled data lands in a + // tier (this ply's own killer[1], promoted to SECOND_KILLER here) + // that's otherwise structurally almost always empty, so it's pure + // upside there; under interleaved order the same backfill instead + // lands in THIRD_KILLER, behind two already-real proven killers, + // where it appears to cost more (misordering) than it gives.) // sKillers[0].mv = ctx->mvKiller[uPly][0]; sKillers[0].uBonus = FIRST_KILLER; - sKillers[1].mv.uMove = sKillers[3].mv.uMove = 0; - sKillers[2].mv = ctx->mvKiller[uPly][1]; - sKillers[2].uBonus = THIRD_KILLER; + sKillers[1].mv = ctx->mvKiller[uPly][1]; + sKillers[1].uBonus = SECOND_KILLER; + sKillers[2].mv.uMove = sKillers[3].mv.uMove = 0; if (uPly > 1) { - sKillers[1].mv = ctx->mvKiller[uPly - 2][0]; - sKillers[1].uBonus = SECOND_KILLER; + sKillers[2].mv = ctx->mvKiller[uPly - 2][0]; + sKillers[2].uBonus = THIRD_KILLER; sKillers[3].mv = ctx->mvKiller[uPly - 2][1]; sKillers[3].uBonus = FOURTH_KILLER; } diff --git a/src/large.bin b/src/large.bin Binary files differdeleted file mode 100755 index 3212478..0000000 --- a/src/large.bin +++ /dev/null diff --git a/src/search.c b/src/search.c index b0bcafd..33cd09d 100755 --- a/src/search.c +++ b/src/search.c @@ -490,8 +490,8 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // mattering in practice. ASSERT(!uFutilityMargin); if ((FALSE == pi->fPvNode) && - (ctx->uPly >= 2) && (iOrigExtend == 0) && + (ctx->uPly >= 2) && (ctx->sPlyInfo[ctx->uPly - 2].iExtensionAmount <= 0)) { if ((uDepth > THREE_QUARTERS_PLY) && @@ -732,16 +732,16 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, // this is the one thing that must never be true of a move // we skip outright. fThisMoveEFPPruned = FALSE; - if ((x != 0) && - (uLegalMoves > 1) && + if ((x != 0) && + (uLegalMoves > 1) && (uFutilityMargin) && (TRUE == fInLeftovers) && - (ComputeMoveScore(ctx, mv, (x - 1)) < uFutilityMargin) && (iExtend <= 0) && - (!IS_ESCAPING_CHECK(mv)) && + (!IS_ESCAPING_CHECK(mv)) && (!IS_CAPTURE_OR_PROMOTION(mv)) && - (!IS_CHECKING_MOVE(mv)) && - (!fThreat)) + (!IS_CHECKING_MOVE(mv)) && + (!fThreat) && + (ComputeMoveScore(ctx, mv, (x - 1)) < uFutilityMargin)) { ULONG uFHAttempts = 0; ULONG uFHPct = GetMoveFailHighPercentage(mv, &uFHAttempts); @@ -757,7 +757,6 @@ Search(IN SEARCHER_THREAD_CONTEXT *ctx, } if (TRUE == fThisMoveEFPPruned) { - ASSERT(!IS_CHECKING_MOVE(mv)); fAnyMoveEFPPruned = TRUE; UnmakeMove(ctx, mv); ASSERT(PositionsAreEquivalent(pos, &pi->sPosition)); diff --git a/src/searchsup.c b/src/searchsup.c index e0d8251..54e1814 100644 --- a/src/searchsup.c +++ b/src/searchsup.c @@ -1167,7 +1167,7 @@ WeShouldTryNullmovePruning(SEARCHER_THREAD_CONTEXT *ctx, (FALSE == pi->fInCheck) && (iBeta != +INFINITY) && (FALSE == pi->fPvNode)) // <--- TODO: test this one please... - { + { if (uNullDepth <= 6 * ONE_PLY) { u = uNullDepth / ONE_PLY; @@ -1183,7 +1183,7 @@ WeShouldTryNullmovePruning(SEARCHER_THREAD_CONTEXT *ctx, } return FALSE; } - + FLAG TryNullmovePruning(SEARCHER_THREAD_CONTEXT *ctx, @@ -1203,10 +1203,10 @@ TryNullmovePruning(SEARCHER_THREAD_CONTEXT *ctx, SCORE iVerifyScore; MOVE mv; MOVE mvRef; - + ASSERT(IS_VALID_SCORE(iAlpha) && IS_VALID_SCORE(iBeta)); ASSERT(iAlpha < iBeta); - + // TODO: more experiments with quick null // Ok, do it. @@ -1265,7 +1265,7 @@ TryNullmovePruning(SEARCHER_THREAD_CONTEXT *ctx, ASSERT(SanityCheckMove(pos, mv)); ASSERT(GET_COLOR(mv.pMoved) == pos->uToMove); ASSERT(ctx->uPly >= 2); - + // If we make a nullmove that fails because we lose a // piece, remember that the piece in question is in danger. if ((mv.pCaptured) && !IS_PAWN(mv.pCaptured)) @@ -1291,7 +1291,29 @@ TryNullmovePruning(SEARCHER_THREAD_CONTEXT *ctx, } ctx->mvNullmoveRefutations[ctx->uPly] = mv; } - + else if (!IS_CAPTURE_OR_PROMOTION(mv)) + { + // A quiet move refuting our nullmove is itself decent + // evidence that the move is strong here -- not enough to + // extend on (that's the capture-only Botvinnik-Markoff + // case above), but worth remembering as a fallback guess + // for _NewKillerMove's empty-slot backfill, which is only + // useful when this is actually a quiet move: a killer + // slot only ever gets matched against generated quiet + // candidates (IS_SAME_MOVE compares the pCaptured/ + // pPromoted bits too), so storing a capture there (the + // old unconditional behavior) could never match anything + // and was silently dead. + // + // Deliberately a *separate* array from mvNullmoveRefutations, + // not a shared slot -- that one is read two plies later + // (ctx->uPly - 2, above) expecting a capture, to detect the + // same-piece-two-different-squares Botvinnik-Markoff pattern. + // Writing a quiet move into that same slot would silently + // clobber the capture history the extension depends on. + ctx->mvNullmoveQuietRefutations[ctx->uPly] = mv; + } + // This is an idea from Tord Romstad on ccc: if we are below a // history reduced node and we try a nullmove and the nullmove // fails low and the move that refuted the nullmove involves |
