Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions include/ironphoenix/position.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion include/ironphoenix/search.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
39 changes: 39 additions & 0 deletions src/position.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::uint8_t>(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<Color>(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) {
Expand Down
67 changes: 65 additions & 2 deletions src/search.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<Color>(static_cast<unsigned>(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;
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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{};
Expand Down
16 changes: 16 additions & 0 deletions tests/property.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading