diff --git a/include/ironphoenix/position.hpp b/include/ironphoenix/position.hpp index 4226525..9b730dd 100644 --- a/include/ironphoenix/position.hpp +++ b/include/ironphoenix/position.hpp @@ -90,6 +90,12 @@ class Position { void makeMove(Move m, StateInfo& st); void undoMove(Move m, const StateInfo& st); + // Search-only null move. No piece moves; the current player's en-passant + // marker expires and the turn advances exactly as it would after a move. + // StateInfo makes the operation fully reversible for null-move pruning. + void makeNullMove(StateInfo& st); + void undoNullMove(const StateInfo& st); + [[nodiscard]] Key recomputeKey() const noexcept; [[nodiscard]] bool verify() const noexcept; diff --git a/include/ironphoenix/search.hpp b/include/ironphoenix/search.hpp index 0604b00..6f697be 100644 --- a/include/ironphoenix/search.hpp +++ b/include/ironphoenix/search.hpp @@ -85,7 +85,7 @@ namespace ironphoenix { void run(Position position, std::ostream& out); void runMultiPV(Position position, int multiPV, std::ostream& out); - int negamax(Position& pos, int depth, int alpha, int beta, int ply, bool pvNode); + int negamax(Position& pos, int depth, int alpha, int beta, int ply, bool pvNode, bool allowNull = true); int qsearch(Position& pos, int alpha, int beta, int ply, bool pvNode); int evaluate(const Position& pos) const noexcept; int moveScore(const Position& pos, Move move, Move ttMove, int ply) const noexcept; diff --git a/src/position.cpp b/src/position.cpp index 3f66557..64130ad 100644 --- a/src/position.cpp +++ b/src/position.cpp @@ -425,6 +425,45 @@ namespace ironphoenix { assert(verify()); } + void Position::makeNullMove(StateInfo& st) { + const Color us = sideToMove_; + + st.key = key_; + st.checkers = checkers_; + st.enPassant = enPassant_; + st.kingSq = kingSq_; + st.castlingRights = castlingRights_; + st.aliveMask = aliveMask_; + st.side = static_cast(us); + st.moved = NO_PIECE; + st.captured = NO_PIECE; + st.capturedSq = SQ_NONE; + + // In 4PC an en-passant marker belongs to the player who created it and + // normally expires when that player gets their next turn. A null move + // consumes that turn, so expire only the current player's marker. + setEnPassant(us, SQ_NONE); + + const Color them = nextColor(us); + key_ ^= Zobrist::Side[us] ^ Zobrist::Side[them]; + sideToMove_ = them; + refreshCheckers(); + + assert(verify()); + } + + void Position::undoNullMove(const StateInfo& st) { + enPassant_ = st.enPassant; + kingSq_ = st.kingSq; + castlingRights_ = st.castlingRights; + aliveMask_ = st.aliveMask; + sideToMove_ = static_cast(st.side); + checkers_ = st.checkers; + key_ = st.key; + + assert(verify()); + } + Key Position::recomputeKey() const noexcept { Key k = Zobrist::Side[sideToMove_] ^ Zobrist::Ruleset[rulesetId_]; for (unsigned sq = 0; sq < SQUARE_NB; ++sq) { diff --git a/src/search.cpp b/src/search.cpp index 393c9b7..cc63724 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -22,11 +22,28 @@ namespace ironphoenix { constexpr int SEE_PRUNE_MARGIN_PER_DEPTH = 80; constexpr int QSEARCH_SEE_THRESHOLD = -50; + // 4PC NMP is deliberately more conservative than a normal 2-player + // baseline. A shallow false fail-high is expensive because one search + // ply is only one seat's turn, not a complete four-seat rotation. + constexpr int NULL_MOVE_MIN_DEPTH = 5; + constexpr int NULL_MOVE_EVAL_MARGIN = 100; + constexpr int NULL_MOVE_BASE_REDUCTION = 2; + constexpr int NULL_MOVE_DEPTH_DIVISOR = 6; + constexpr int NULL_MOVE_MIN_NON_PAWN_PIECES = 2; + IRONPHOENIX_FORCE_INLINE Bitboard teamOccupancy(const Position& pos, Color c) noexcept { const Color partner = static_cast(static_cast(c) ^ 2u); return pos.occupancy(c) | pos.occupancy(partner); } + IRONPHOENIX_FORCE_INLINE bool hasEnoughNonPawnMaterial(const Position& pos, Color c) noexcept { + const Bitboard pieces = pos.pieces(c, KNIGHT) + | pos.pieces(c, BISHOP) + | pos.pieces(c, ROOK) + | pos.pieces(c, QUEEN); + return pieces.popcount() >= NULL_MOVE_MIN_NON_PAWN_PIECES; + } + } SearchEngine::SearchEngine() = default; @@ -385,7 +402,7 @@ namespace ironphoenix { return bestScore; } - int SearchEngine::negamax(Position& pos, int depth, int alpha, int beta, int ply, bool pvNode) { + int SearchEngine::negamax(Position& pos, int depth, int alpha, int beta, int ply, bool pvNode, bool allowNull) { if (ply >= MAX_PLY - 1) return evaluate(pos); if (shouldStop()) @@ -413,12 +430,58 @@ namespace ironphoenix { } const bool inCheckNode = pos.inCheck(); + const Color us = pos.sideToMove(); + const bool mateWindow = !(alpha > -MATE_THRESHOLD && beta < MATE_THRESHOLD); + + // Null Move Pruning (NMP): only prune when the static evaluation is + // already comfortably above beta. Requiring two non-pawn pieces and a + // slower depth-scaled reduction makes the heuristic less eager in 4PC + // endgames and at shallow depths, where a false null cutoff is costly. + if (allowNull + && !pvNode + && ply > 0 + && depth >= NULL_MOVE_MIN_DEPTH + && !inCheckNode + && !mateWindow + && hasEnoughNonPawnMaterial(pos, us)) { + + const int staticEval = evaluate(pos); + if (staticEval >= beta + NULL_MOVE_EVAL_MARGIN) { + StateInfo nullState; + pos.makeNullMove(nullState); + searchStack_[ply + 1] = HistoryContext{}; + + const int reduction = NULL_MOVE_BASE_REDUCTION + + depth / NULL_MOVE_DEPTH_DIVISOR; + const int nullDepth = std::max(0, depth - 1 - reduction); + + const int nullScore = -negamax( + pos, + nullDepth, + -beta, + -beta + 1, + ply + 1, + false, + false + ); + + pos.undoNullMove(nullState); + + if (shouldStop()) + return 0; + + // The null search proves only a fail-high bound. Return beta + // instead of the synthetic null score so an optimistic pass + // cannot inflate aspiration windows or parent-node scores. + if (nullScore >= beta && nullScore < MATE_THRESHOLD) + return beta; + } + } MoveList moves; generatePseudoLegalMoves(pos, moves); orderMoves(pos, moves.begin(), moves.end(), ttMove, ply); - const Color us = pos.sideToMove(); int legalMoves = 0; int bestScore = -INF; Move bestMove{}; diff --git a/tests/property.cpp b/tests/property.cpp index 8443c0a..4f7723c 100644 --- a/tests/property.cpp +++ b/tests/property.cpp @@ -73,6 +73,22 @@ int main() { p.finalizeSetup(); assert(p.key() == p.recomputeKey()); + // Null move must advance exactly one 4PC turn and then restore the + // position byte-for-byte from the searcher's point of view. + const Key beforeNull = p.key(); + const Color beforeNullSide = p.sideToMove(); + const Bitboard beforeNullCheckers = p.checkers(); + StateInfo nullState; + p.makeNullMove(nullState); + assert(p.sideToMove() == nextColor(beforeNullSide)); + assert(p.key() == p.recomputeKey()); + p.undoNullMove(nullState); + assert(p.sideToMove() == beforeNullSide); + assert(p.checkers() == beforeNullCheckers); + assert(p.key() == beforeNull); + assert(p.key() == p.recomputeKey()); + assert(p.verify()); + Bitboard movers = p.occupancy(stm); if (!movers) continue;