From 5933313ed3ddcafcc822429034d72682a4e49bfa Mon Sep 17 00:00:00 2001 From: Eugene Yakhnenko Date: Wed, 23 Jul 2025 21:48:34 -0700 Subject: [PATCH 1/3] feat: null move pruning --- pkg/board.go | 11 +++++++++++ pkg/search.go | 25 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/pkg/board.go b/pkg/board.go index 7decc7b..ae12a1c 100644 --- a/pkg/board.go +++ b/pkg/board.go @@ -371,6 +371,17 @@ func (board *Board) IsPieceAtSquareWhite(square byte) bool { return (board.WhitePawns|board.WhiteKnights|board.WhiteBishops|board.WhiteRooks|board.WhiteQueens|board.WhiteKing)&mask != 0 } +// IsInCheck returns true if the side to move is in check. +func (board *Board) IsInCheck(whiteToMove bool) bool { + var kingSq byte + if whiteToMove { + kingSq = byte(bits.TrailingZeros64(board.WhiteKing)) + } else { + kingSq = byte(bits.TrailingZeros64(board.BlackKing)) + } + return board.IsSquareAttacked(kingSq, !whiteToMove) +} + // SquareToRank returns the rank (0-7) of a square index (0 = rank 0, 7 = rank 7) func (board *Board) SquareToRank(square byte) byte { return square / 8 diff --git a/pkg/search.go b/pkg/search.go index c057003..a6c8936 100644 --- a/pkg/search.go +++ b/pkg/search.go @@ -10,6 +10,8 @@ const ( SearchMaxDepth = 16 // Maximum depth to search MaxEvaluationScore = 1_000_000 // Maximum score for wining MaxEvaluationTimeMs = 3_000 // Maximum time for a search at the root level + NullMoveReduction = 3 // Reduction for null move pruning (R) + NullMoveMinDepth = 3 // Minimum depth for null mov ) type SearchOptions struct { @@ -218,6 +220,29 @@ func (board *Board) AlphaBetaSearch(depth int, maximizing bool, alpha int, beta return board.MateOrStalemateScore(maximizing) } + // --- Null Move Pruning --- + if depth >= NullMoveMinDepth && ply > 0 && !board.IsInCheck(maximizing) && len(board.GenerateLegalMoves()) > 0 { + nullBoard := board.Clone() + nullBoard.WhiteToMove = !nullBoard.WhiteToMove // Switch side to move (null move) + nullEval := 0 + newDepth := depth - NullMoveReduction - 1 + if newDepth < 1 { + newDepth = 1 + } + if maximizing { + nullEval = nullBoard.AlphaBetaSearch(newDepth, false, alpha, beta, tt, stats, ctx, ply+1) + if nullEval >= beta { + return beta // Fail-hard beta cutoff + } + } else { + nullEval = nullBoard.AlphaBetaSearch(newDepth, true, alpha, beta, tt, stats, ctx, ply+1) + if nullEval <= alpha { + return alpha // Fail-hard alpha cutoff + } + } + } + + // --- Alpha-Beta Pruning --- var result int var bestMove Move if maximizing { From daa0d982f416cd6db578ac9f36a58f757f517e56 Mon Sep 17 00:00:00 2001 From: Eugene Yakhnenko Date: Thu, 24 Jul 2025 00:04:41 -0700 Subject: [PATCH 2/3] fix: null move pruning king capture bug --- pkg/board.go | 2 +- pkg/move.go | 5 +++++ pkg/search.go | 11 ++++++++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/pkg/board.go b/pkg/board.go index ae12a1c..511a951 100644 --- a/pkg/board.go +++ b/pkg/board.go @@ -379,7 +379,7 @@ func (board *Board) IsInCheck(whiteToMove bool) bool { } else { kingSq = byte(bits.TrailingZeros64(board.BlackKing)) } - return board.IsSquareAttacked(kingSq, !whiteToMove) + return board.IsSquareAttacked(kingSq, whiteToMove) } // SquareToRank returns the rank (0-7) of a square index (0 = rank 0, 7 = rank 7) diff --git a/pkg/move.go b/pkg/move.go index 497f9fc..59748ca 100644 --- a/pkg/move.go +++ b/pkg/move.go @@ -260,6 +260,11 @@ func (board *Board) Move(move Move) MoveState { board.Castling.BlackQueenSide = false } } + /** Defensive: king capture is illegal. + if move.Captured == WhiteKing || move.Captured == BlackKing { + panic(fmt.Sprintf("Illegal move: %s captures the king", move.ToMove())) + } + */ } board.WhiteToMove = !board.WhiteToMove diff --git a/pkg/search.go b/pkg/search.go index a6c8936..860f459 100644 --- a/pkg/search.go +++ b/pkg/search.go @@ -189,6 +189,15 @@ func (board *Board) ParallelRootSearch(depth int, tt *TranspositionTable, moves } func (board *Board) AlphaBetaSearch(depth int, maximizing bool, alpha int, beta int, tt *TranspositionTable, stats *SearchResult, ctx *SearchContext, ply int) int { + /* + defer func() { + if r := recover(); r != nil { + stack := debug.Stack() + fmt.Printf("PANIC in AlphaBetaSearch: %v\nStacktrace:\n%s\nContext: depth=%d maximizing=%v alpha=%d beta=%d ply=%d board=%v\n", r, stack, depth, maximizing, alpha, beta, ply, board) + panic(r) + } + }() + */ // Check for cancellation at every node select { case <-ctx.Done: @@ -221,7 +230,7 @@ func (board *Board) AlphaBetaSearch(depth int, maximizing bool, alpha int, beta } // --- Null Move Pruning --- - if depth >= NullMoveMinDepth && ply > 0 && !board.IsInCheck(maximizing) && len(board.GenerateLegalMoves()) > 0 { + if depth >= NullMoveMinDepth && ply > 0 && !board.IsInCheck(board.WhiteToMove) && len(board.GenerateLegalMoves()) > 0 { nullBoard := board.Clone() nullBoard.WhiteToMove = !nullBoard.WhiteToMove // Switch side to move (null move) nullEval := 0 From a6ec539889dc4b55d94b5bdbda66cac7e3d2ef73 Mon Sep 17 00:00:00 2001 From: Eugene Yakhnenko Date: Thu, 24 Jul 2025 00:06:32 -0700 Subject: [PATCH 3/3] fix: update reduction rate --- pkg/search.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/search.go b/pkg/search.go index 860f459..a84b80e 100644 --- a/pkg/search.go +++ b/pkg/search.go @@ -10,7 +10,7 @@ const ( SearchMaxDepth = 16 // Maximum depth to search MaxEvaluationScore = 1_000_000 // Maximum score for wining MaxEvaluationTimeMs = 3_000 // Maximum time for a search at the root level - NullMoveReduction = 3 // Reduction for null move pruning (R) + NullMoveReduction = 2 // Reduction for null move pruning (R) NullMoveMinDepth = 3 // Minimum depth for null mov )