Search Algorithm

Overview

Kirin uses a negamax search with alpha-beta pruning, iterative deepening, and aspiration windows.

Iterative Deepening

The engine uses iterative deepening with aspiration windows:

void searchPosition(int depth) {
  int score = 0;
  int alpha = -infinity;
  int beta = infinity;
  
  for(int currentDepth = 1; currentDepth <= depth; currentDepth++) {
    score = negamax(alpha, beta, currentDepth);
    
    // Aspiration window for next iteration
    alpha = score - 50;
    beta = score + 50;
    
    // Print UCI info
    printf("info score cp %d depth %d nodes %ld time %d pv ...", 
           score, currentDepth, nodes, getTimeMS() - startTime);
  }
  printf("bestmove ...");
}

Search Enhancements

Transposition Table

Zobrist hashing is used for position identification:

U64 pieceKeys[12][64];    // Random keys for each piece on each square
U64 enpassantKeys[64];    // Random keys for en passant squares
U64 castlingKeys[16];     // Random keys for castling states
U64 sideKey;              // Random key for side to move

U64 generateHashKey() { 
  U64 finalKey = 0ULL; 
  for(int piece = P; piece <= k; piece++) { 
    U64 bitboard = bitboards[piece];
    while(bitboard) { 
      int square = getLSBindex(bitboard);
      finalKey ^= pieceKeys[piece][square];
      popBit(bitboard, square);
    }
  }
  if(enpassant != no_sq) finalKey ^= enpassantKeys[enpassant];
  finalKey ^= castlingKeys[castle];
  if(side == black) finalKey ^= sideKey;
  return finalKey;
}

Move Ordering

Moves are ordered for optimal alpha-beta pruning:

  1. Hash move (from transposition table)

  2. Winning captures (MVV-LVA ordering)

  3. Killer moves (quiet moves that caused beta cutoffs)

  4. History heuristic (successful quiet moves)

  5. Remaining quiet moves

MVV-LVA Table

Most Valuable Victim - Least Valuable Attacker scoring:

// MVV LVA [attacker][victim]
static int mvvLVA[12][12] = {
  105, 205, 305, 405, 505, 605, ...  // Pawn captures
  104, 204, 304, 404, 504, 604, ...  // Knight captures
  103, 203, 303, 403, 503, 603, ...  // Bishop captures
  102, 202, 302, 402, 502, 602, ...  // Rook captures
  101, 201, 301, 401, 501, 601, ...  // Queen captures
  100, 200, 300, 400, 500, 600, ...  // King captures
};

Repetition Detection

The engine maintains a repetition table to detect threefold repetition:

U64 repetitionTable[1000];
int repetitionIndex;

static inline int isRepetition() {
  for(int i = 0; i < repetitionIndex; i++) {
    if(repetitionTable[i] == hashKey) return 1;
  }
  return 0;
}

The pruning and reduction surface

Everything above describes the search’s skeleton. What actually determines its strength is the set of techniques layered on top that let it avoid searching most of the tree. Every one of them is individually switchable at runtime through a UCI option, which is not a convenience feature: it is the mechanism that makes the A/B testing in Ch. Measuring Strength possible at all. A technique that cannot be turned off cannot be measured.

Technique

Default

What it does

Principal variation search

on

assume the first move is best; search the rest with a null window

Null-move pruning

on

give the opponent a free move; if the position still fails high, prune

Late move reductions

on

search moves ordered late at reduced depth

Aspiration windows

on

re-search around the previous iteration’s score

History ordering

on

order quiets by how often they have caused cutoffs

SEE ordering

on

bucket captures as winning/losing before searching them

SEE pruning

on

skip losing captures in quiescence

Rich evaluation

on

the full positional term set (§Evaluation)

Futility pruning

off

skip quiet moves that cannot raise alpha

Singular extensions

off

extend a move that is much better than every alternative

ProbCut

off

shallow, wide-margin search to prove a beta cutoff early

Internal iterative deepening

off

shallow search to find a hash move when none exists

Continuation history

off

order by the follow-up to the previous move

NNUE evaluation

off

neural evaluation (Ch. NNUE Evaluation)

Note

The defaults above are the shipped defaults, read from kirin/src/engine/uci.cpp. Several powerful techniques are off. That is not an oversight — it reflects what has and has not been through an SPRT run. A feature stays off until it has demonstrated a gain; see Ch. Measuring Strength and decision D-13.

Late move reductions

The two governing constants live in search.cpp: the first fullDepthMoves = 4 moves at a node are searched to full depth, and reductions only apply at depth >= reductionLimit = 3. Beyond that, the reduction amount comes from a precomputed lmrReductionTable[depth][moveNumber], with one adjustment worth knowing about:

int reduction = lmrReductionTable[d][m];
if (pvNode && reduction > 0) reduction--;  // reduce principal-variation nodes less
return std::max(1, reduction);

Principal-variation nodes are reduced one ply less than the rest. The PV is the line the engine actually intends to play, so an error there costs far more than an error in a line it is merely refuting.

Null-move pruning

The reduction scales with remaining depth rather than being fixed:

int reduction = 2 + depth / 6;
score = -negamax(-beta, -beta + 1, depth - 1 - reduction);

Null-move pruning is unsound in zugzwang, where having to move is itself the disadvantage, so the guard conditions matter as much as the reduction — search.cpp gates it on side-to-move material and on not already being in check.

Static exchange evaluation

SEE answers “if I initiate this capture sequence on this square, do I come out ahead?” by playing out the exchange with the least valuable attacker each time. It is exposed as two independent options because the two uses carry very different risk:

  • SEE ordering re-buckets winning versus losing captures during move ordering. Low risk — it changes the order moves are examined, never which ones are examined.

  • SEE pruning skips captures with $\text{SEE} < 0$ in quiescence outright. More aggressive, and the one that can actually lose information: a losing capture is occasionally the point of a combination.

The Use SEE option sets both together; the individual options exist so a gauntlet can isolate which half of the technique is responsible for a result. That distinction is a comment in the source itself (search.cpp:71--72) and is exactly the kind of separation that makes an A/B result interpretable rather than merely positive.

History and continuation history

Plain history scores a quiet move by historyMoves[piece][target] — how often moving this piece to this square has caused a cutoff anywhere in the search. Continuation history extends the idea by conditioning on what was played immediately before, indexed [offset][prevPiece][prevTarget][piece][target], which captures follow-up patterns that plain history flattens away. It is off by default and is a natural SPRT candidate.