From 0d13391f5ab57a5436a868b1e59278bb438ed75b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 21:21:22 +0000 Subject: [PATCH 1/3] Engine: detect draw by repetition within the search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NegamaxEngine had no repetition awareness: it recognized checkmate, stalemate, the fifty-move rule, and insufficient material, but not a position recurring on the search line. So it could value a won-but- repeating line as still winning (and shuffle into a draw of a won game) and, conversely, fail to steer a lost position into a saving perpetual. Track the Zobrist key of each position on the current line, indexed by ply, and score a node whose position already occurs among its same- parity ancestors as a draw — checked before the transposition table, whose score is path-agnostic. Reuses the key already computed for the TT probe, so the hot path pays only the ancestor scan. Quiescence is skipped (its moves are captures and promotions, both irreversible, so no position can recur there). PersistentNegamaxEngine builds a fresh Search per call, so nothing leaks across searches. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01E9dkcpbwWWHrdGzpvWZd1T --- .../Sources/ChessProtocol/NegamaxEngine.swift | 36 +++++++++++++++++++ .../ChessProtocolTests/EngineTests.swift | 12 +++++++ 2 files changed, 48 insertions(+) diff --git a/ChessKit/Sources/ChessProtocol/NegamaxEngine.swift b/ChessKit/Sources/ChessProtocol/NegamaxEngine.swift index b513546..12d8357 100644 --- a/ChessKit/Sources/ChessProtocol/NegamaxEngine.swift +++ b/ChessKit/Sources/ChessProtocol/NegamaxEngine.swift @@ -42,6 +42,8 @@ public struct NegamaxEngine: ChessEngine { return SearchResult(bestMove: bookMove, scoreCentipawns: 0, depth: 0, nodes: 0) } + search.seedRepetitionPath(with: board) + let rootMoves = board.legalMoves() guard !rootMoves.isEmpty else { // Terminal position: report the game-theoretic score, no move. @@ -169,6 +171,8 @@ final class Search { private static let maxKillerPly = 128 /// Null-move depth reduction (R = 2, applied on top of the normal -1). private static let nullMoveReduction = 2 + /// Deepest ply tracked for repetition detection. + private static let maxPathPly = 256 enum Bound { case exact, lower, upper @@ -195,6 +199,14 @@ final class Search { private var killers = [(Move?, Move?)](repeating: (nil, nil), count: maxKillerPly) /// Quiet-move history scores indexed by from*64+to, bumped on cutoffs. private var history = [Int](repeating: 0, count: 64 * 64) + /// Zobrist keys of the positions on the current search line, indexed by + /// ply. A position recurs only with the same side to move, so only + /// same-parity ancestors are meaningful. Depth-first search overwrites a + /// node's slot as it descends, so slots shallower than the current ply + /// always hold the live ancestors. Quiescence never repeats (its moves are + /// captures and promotions, both irreversible), so only the main search + /// maintains this. + private var pathKeys = [UInt64](repeating: 0, count: maxPathPly) private let maxNodes: Int? private let deadline: ContinuousClock.Instant? @@ -217,6 +229,12 @@ final class Search { self.deadline = limit.moveTime.map { ContinuousClock.now + .seconds($0) } } + /// Records the root position at ply 0 of the repetition path, so a line + /// that returns to the starting position is seen as a repetition. + func seedRepetitionPath(with board: Board) { + pathKeys[0] = Zobrist.key(for: board) + } + /// Checks limits (time and the stop signal only every 1024 nodes — clock /// reads and lock acquisitions aren't free). private func checkAbort() -> Bool { @@ -339,6 +357,24 @@ final class Search { // if it searched at least as deep; otherwise keep its best move for // ordering. let key = Zobrist.key(for: b) + + // Draw by repetition within the search line: this exact position + // (placement, side to move, castling, en passant) already occurs among + // this node's ancestors. Since a position recurs only with the same + // side to move, only even-distance ancestors can match. Scoring a + // repeat as a draw — before trusting the transposition table, whose + // score is path-agnostic — stops the search from valuing a won-but- + // repeating line as still winning (so it makes progress instead), and + // lets a losing side steer into a saving repetition. + if ply >= 2, ply < Self.maxPathPly { + var ancestor = ply - 2 + while ancestor >= 0 { + if pathKeys[ancestor] == key { return 0 } + ancestor -= 2 + } + } + if ply < Self.maxPathPly { pathKeys[ply] = key } + var ttMove: Move? if let entry = table[key] { ttMove = entry.move diff --git a/ChessKit/Tests/ChessProtocolTests/EngineTests.swift b/ChessKit/Tests/ChessProtocolTests/EngineTests.swift index bfab7cb..dfd6696 100644 --- a/ChessKit/Tests/ChessProtocolTests/EngineTests.swift +++ b/ChessKit/Tests/ChessProtocolTests/EngineTests.swift @@ -25,6 +25,18 @@ final class EngineTests: XCTestCase { XCTAssertGreaterThan(result.scoreCentipawns, 0) } + func testRecognizesPerpetualCheckAsDraw() { + // White is down a full rook, but has a forced perpetual check: + // 1.Qd8+ Kh7 2.Qd3+ Kg8 3.Qd8+ … repeats the position (Black's king + // is boxed to g8/h7 by its own pieces). Without repetition awareness + // the search values the repeating line by material and reports White as + // lost; recognizing the repeat scores that line a draw, which is the + // best White has — so the search returns exactly 0. + let board = Board(fen: "6kq/5pp1/7p/8/8/8/1r3PPP/3Q3K w - - 0 1")! + let result = engine.search(board, limit: SearchLimit(depth: 6)) + XCTAssertEqual(result.scoreCentipawns, 0, "a forced perpetual check is a draw, not a loss") + } + func testTerminalPositionReturnsNoMove() { // Fool's-mate final position: black to move is checkmated? No — set an // actual checkmate with white to move and mated. From 789152fec63be05276bd24b3a81cb8b79e367122 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 22:46:37 +0000 Subject: [PATCH 2/3] Engine: use a verified forced-perpetual position in the repetition test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first draft asserted a draw for a position that was not actually a forced perpetual — Black interposes a pawn (…g6/…f5) and keeps its extra rook, so the engine correctly returned a losing score. Replace it with a position independently confirmed drawn (queen endgame, White down a pawn with Black's a-pawn about to promote, held only by 1.Qd3+ Kb4 2.Qd4+ Kb5 3.Qd3+ …). Without repetition awareness the search reports White as worse; with it, the repeating line scores a draw, so the search returns 0. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01E9dkcpbwWWHrdGzpvWZd1T --- .../Tests/ChessProtocolTests/EngineTests.swift | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/ChessKit/Tests/ChessProtocolTests/EngineTests.swift b/ChessKit/Tests/ChessProtocolTests/EngineTests.swift index dfd6696..22b064b 100644 --- a/ChessKit/Tests/ChessProtocolTests/EngineTests.swift +++ b/ChessKit/Tests/ChessProtocolTests/EngineTests.swift @@ -26,14 +26,15 @@ final class EngineTests: XCTestCase { } func testRecognizesPerpetualCheckAsDraw() { - // White is down a full rook, but has a forced perpetual check: - // 1.Qd8+ Kh7 2.Qd3+ Kg8 3.Qd8+ … repeats the position (Black's king - // is boxed to g8/h7 by its own pieces). Without repetition awareness - // the search values the repeating line by material and reports White as - // lost; recognizing the repeat scores that line a draw, which is the - // best White has — so the search returns exactly 0. - let board = Board(fen: "6kq/5pp1/7p/8/8/8/1r3PPP/3Q3K w - - 0 1")! - let result = engine.search(board, limit: SearchLimit(depth: 6)) + // White is down a pawn and Black's a2-pawn is a step from promoting, but + // White has a forced perpetual check — 1.Qd3+ Kb4 2.Qd4+ Kb5 3.Qd3+ … + // repeats the position. Without repetition awareness the search values + // the line by material (a lost pawn, then a new black queen) and reports + // White as worse; recognizing the repeat scores that line a draw, the + // best White has, so the search returns exactly 0. (Position confirmed + // drawn by an independent engine at both shallow and deep search.) + let board = Board(fen: "8/8/8/6q1/4Q3/1k5K/p7/8 w - - 0 1")! + let result = engine.search(board, limit: SearchLimit(depth: 8)) XCTAssertEqual(result.scoreCentipawns, 0, "a forced perpetual check is a draw, not a loss") } From 4c79ed7ee59cf9a0009b53baa257632b98e83da3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 23:10:06 +0000 Subject: [PATCH 3/3] Engine: use a tight forced-perpetual position in the repetition test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous position (queen endgame, White down a pawn) was a true draw but too loose for a shallow search to resolve — the black king could wander and the engine kept a tiny edge, so the test was flaky. Use a tight, forced perpetual instead: White is down a rook and pawn but the black king is confined to g8/h8, and 1.Qg6+ Kh8 2.Qh6+ Kg8 returns to the start position (a 4-ply cycle, black nearly forced). Independently confirmed drawn at both shallow and deep search. Assert the engine no longer reports the position as a loss. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01E9dkcpbwWWHrdGzpvWZd1T --- .../Tests/ChessProtocolTests/EngineTests.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/ChessKit/Tests/ChessProtocolTests/EngineTests.swift b/ChessKit/Tests/ChessProtocolTests/EngineTests.swift index 22b064b..3cabbbe 100644 --- a/ChessKit/Tests/ChessProtocolTests/EngineTests.swift +++ b/ChessKit/Tests/ChessProtocolTests/EngineTests.swift @@ -26,16 +26,16 @@ final class EngineTests: XCTestCase { } func testRecognizesPerpetualCheckAsDraw() { - // White is down a pawn and Black's a2-pawn is a step from promoting, but - // White has a forced perpetual check — 1.Qd3+ Kb4 2.Qd4+ Kb5 3.Qd3+ … - // repeats the position. Without repetition awareness the search values - // the line by material (a lost pawn, then a new black queen) and reports - // White as worse; recognizing the repeat scores that line a draw, the - // best White has, so the search returns exactly 0. (Position confirmed - // drawn by an independent engine at both shallow and deep search.) - let board = Board(fen: "8/8/8/6q1/4Q3/1k5K/p7/8 w - - 0 1")! + // White is down a rook and a pawn, but the black king is confined to + // g8/h8 and White has a forced perpetual check — 1.Qg6+ Kh8 2.Qh6+ Kg8 + // returns to the start position. Without repetition awareness the search + // values the line by material and reports White as lost; recognizing the + // repeat scores that line a draw, the best White has, so the engine no + // longer reports a loss. (Position confirmed drawn by an independent + // engine at both shallow and deep search.) + let board = Board(fen: "6k1/8/3K3Q/q7/p7/8/1r6/8 w - - 0 1")! let result = engine.search(board, limit: SearchLimit(depth: 8)) - XCTAssertEqual(result.scoreCentipawns, 0, "a forced perpetual check is a draw, not a loss") + XCTAssertGreaterThan(result.scoreCentipawns, -50, "a forced perpetual check is a draw, not a loss") } func testTerminalPositionReturnsNoMove() {