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..3cabbbe 100644 --- a/ChessKit/Tests/ChessProtocolTests/EngineTests.swift +++ b/ChessKit/Tests/ChessProtocolTests/EngineTests.swift @@ -25,6 +25,19 @@ final class EngineTests: XCTestCase { XCTAssertGreaterThan(result.scoreCentipawns, 0) } + func testRecognizesPerpetualCheckAsDraw() { + // 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)) + XCTAssertGreaterThan(result.scoreCentipawns, -50, "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.