Quiescent Search
The Horizon Effect
The horizon effect is when a chess engine makes a terrible move because it can’t see past its search depth limit.
It’s like looking at a situation and thinking “This is fine!” when disaster is just one move beyond what you can see.
Why Fixed-Depth Search Fails
Fixed-depth search stops at a specific depth, regardless of what’s happening in the position.
Depth 10 search:
├─ Move 1, 2, 3, ... 10 ✓ Search these
└─ Move 11 ✗ STOP (even if critical!)
Example 1: The Hanging Queen
Position: White to move
♜ ♞ ♝ ♛ ♚ ♝ ♞ ♜
♟ ♟ ♟ ♟ ♟ ♟ ♟
♟
♟
♙
♙
♙ ♙ ♙ ♙ ♙ ♙
♖ ♘ ♗ ♕ ♔ ♗ ♘ ♖
Search to depth 8:
Depth 7: White plays Bxe5 (bishop takes pawn)
"I won a pawn! +1.0"
Depth 8: Black's turn, but search STOPS
Return evaluation: +1.0
Engine thinks: "Great move! I'm up a pawn."
Reality (depth 9):
Black plays Qxe5 (queen takes bishop)
Actual eval: -2.0 (lost a bishop for a pawn!)
The engine was “blind” to the recapture because it hit the horizon.
Example 2: Delaying the Inevitable
This is the classic horizon effect - pushing losses beyond the search depth.
Position: Black is getting mated in 5 moves
Instead of accepting mate, Black desperately gives checks:
├─ Qh4+ (check, forces King to move)
├─ Qh5+ (another check)
├─ Qh6+ (another check)
└─ Gives up queen for rook
→ Now mated in 8 moves instead of 5
From depth 10 search perspective:
"If I don't give checks: mate in 5" (sees this)
"If I give checks: not mated yet at depth 10!" (doesn't see mate in 8)
Engine chooses to give useless checks!
The engine “pushes” the mate beyond the horizon, thinking it avoided it.
Example 3: The Sacrificial Trap
Engines might avoid sacrifices thinking they’re losing material when necessary results (either capture or winning back more material) is not achieved in fixed depth.
Key insight: Some positions are quiet (safe to evaluate), others are not quiet (must keep searching).
What Makes a Position “Quiet”?
Quiet Positions (Safe to Stop)
✓ No pieces hanging ✓ No checks ✓ No captures available ✓ No immediate threats ✓ Pieces safely defended
Unstable Positions (DON’T Stop!)
✗ Pieces hanging (undefended) ✗ In check ✗ Captures available ✗ Pieces attacked ✗ Tactical threats
General Idea of Quiescent Search
// Quiescence search function, which is called by the main search function with
// depth zero, or recursively with further decreasing depth. With depth <= 0, we
// "should" be using static eval only, but tactical moves may confuse the static eval.
// To fight this horizon effect, we implement this qsearch of tactical moves.
// See https://www.chessprogramming.org/Horizon_Effect
// and https://www.chessprogramming.org/Quiescence_Search
Static Eval means
- Calls
evaluate()function - Looks at current position only
- No lookahead, no move generation
- “Static” = not dynamic, doesn’t change with search
“At depth 0, we COULD just call evaluate() and return. But that would cause horizon effect! So instead, we use qsearch to try captures first.”
Qsearch makes recursive calls as well, just as minmax
value = -qsearch<nodeType>(pos, ss + 1, -beta, -alpha);
// ↑ qsearch calls itself!
Recursive flow:
search(depth=1)
└─ search(depth=0)
└─ qsearch(depth=0) ← Entry point
└─ Try Qxh7
└─ qsearch(depth=-1) ← RECURSIVE CALL!
└─ Try Kh8
└─ qsearch(depth=-2) ← RECURSIVE CALL!
└─ No captures, return evaluate()
Outline of Qsearch Algorithm
qsearch(pos, ss, alpha, beta, depth):
├─ Step 1: Early Exits
│ ├─ Repetition draw check:
│ │ └─ if (upcoming_repetition && alpha < VALUE_DRAW)
│ │ └─ alpha = value_draw
│ │ └─ if (alpha >= beta) → return alpha
│ │
│ ├─ Draw/Max ply check:
│ │ └─ if (is_draw || ply >= MAX_PLY)
│ │ └─ return VALUE_DRAW or evaluate()
│ │
│ └─ Continue to Step 2
│
├─ Step 2: Transposition Table Probe
│ ├─ ttHit, ttData = TT.probe(posKey)
│ ├─ Extract TT move, value, bound
│ │
│ ├─ TT cutoff check (non-PV nodes):
│ │ └─ if (!PvNode && ttDepth >= DEPTH_QS && valid_bound)
│ │ └─ return ttData.value ← Early exit!
│ │
│ └─ Continue to Step 3
│
├─ Step 3: Stand Pat Evaluation
│ │
│ ├─ Case A: In Check
│ │ └─ bestValue = -VALUE_INFINITE (can't stand pat)
│ │ └─ Must try evasion moves
│ │
│ ├─ Case B: Not in Check
│ │ ├─ staticEval = evaluate(pos) or ttData.eval
│ │ ├─ bestValue = staticEval (+ corrections)
│ │ │
│ │ ├─ Stand pat cutoff:
│ │ │ └─ if (bestValue >= beta)
│ │ │ ├─ Save to TT (BOUND_LOWER)
│ │ │ └─ return bestValue ← Exit! (position already good)
│ │ │
│ │ ├─ Raise alpha:
│ │ │ └─ if (bestValue > alpha)
│ │ │ └─ alpha = bestValue (new minimum)
│ │ │
│ │ └─ futilityBase = staticEval + 359
│ │
│ └─ Continue to Step 4
│
├─ Step 4: Move Generation
│ ├─ MovePicker mp(pos, ttMove, DEPTH_QS, ...)
│ │ ├─ Generates: Captures (and checks if depth=0)
│ │ └─ Orders by: MVV-LVA
│ │
│ └─ Continue to Step 5
│
├─ Step 5-8: Move Loop
│ │
│ └─ while (move = mp.next_move()):
│ │
│ ├─ Legality check:
│ │ └─ if (!legal(move)) → continue
│ │
│ ├─ Step 6: Pruning Checks
│ │ │
│ │ ├─ Move count pruning:
│ │ │ └─ if (moveCount > 2 && !givesCheck && ...)
│ │ │ └─ continue ← Skip
│ │ │
│ │ ├─ Futility pruning:
│ │ │ ├─ futilityValue = futilityBase + PieceValue[captured]
│ │ │ └─ if (futilityValue <= alpha)
│ │ │ ├─ bestValue = max(bestValue, futilityValue)
│ │ │ └─ continue ← Skip (won't raise alpha)
│ │ │
│ │ ├─ SEE pruning (captures):
│ │ │ └─ if (!see_ge(move, alpha - futilityBase))
│ │ │ └─ continue ← Skip (loses material)
│ │ │
│ │ ├─ History pruning (quiet checks):
│ │ │ └─ if (bad_history && !capture)
│ │ │ └─ continue ← Skip
│ │ │
│ │ └─ Final SEE check:
│ │ └─ if (!see_ge(move, -75))
│ │ └─ continue ← Skip (too bad)
│ │
│ ├─ Step 7: Make Move and Recurse
│ │ ├─ do_move(move)
│ │ ├─ nodes++
│ │ ├─ ss->currentMove = move
│ │ │
│ │ ├─ RECURSIVE CALL:
│ │ │ └─ value = -qsearch(pos, ss+1, -beta, -alpha)
│ │ │ │
│ │ │ └─ [Entire qsearch runs recursively!]
│ │ │ ├─ depth = -1, -2, -3, ...
│ │ │ └─ Eventually returns when quiet
│ │ │
│ │ └─ undo_move(move)
│ │
│ └─ Step 8: Update Best Value
│ │
│ ├─ if (value > bestValue):
│ │ ├─ bestValue = value
│ │ │
│ │ └─ if (value > alpha):
│ │ ├─ bestMove = move
│ │ ├─ Update PV
│ │ │
│ │ ├─ if (value < beta):
│ │ │ └─ alpha = value ← Raise alpha
│ │ │
│ │ └─ else:
│ │ └─ break ← Beta cutoff! Exit loop
│ │
│ └─ Continue to next move
│
├─ Step 9: After Move Loop
│ │
│ ├─ Checkmate detection:
│ │ └─ if (inCheck && bestValue == -VALUE_INFINITE)
│ │ └─ return mated_in(ply) ← No legal moves when in check
│ │
│ └─ Continue to Step 10
│
└─ Step 10: Save to TT and Return
├─ Determine bound:
│ └─ bound = (bestValue >= beta) ? BOUND_LOWER : BOUND_UPPER
│
├─ TT.write(posKey, bestValue, bound, DEPTH_QS, bestMove, ...)
│
└─ return bestValue
Key Patterns to Remember
Pattern 1: Stand Pat Cutoff
├─ evaluate() >= beta
└─ return immediately (no captures tried)
Pattern 2: Stand Pat Raises Alpha
├─ evaluate() > alpha
├─ alpha = evaluate()
└─ Captures must beat this to be interesting
Pattern 3: Good Capture Found
├─ Capture improves position
├─ value > alpha
├─ Update alpha, bestMove
└─ Continue or cutoff
Pattern 4: All Captures Pruned
├─ Futility, SEE, History checks fail
├─ No captures tried
└─ return stand pat value (bestValue)
Pattern 5: Recursion Depth
├─ depth: 0, -1, -2, -3, -4, ...
├─ Continues until position quiet
└─ Eventually stand pat or no captures
The Code
template<NodeType nodeType>
Value Search::Worker::qsearch(Position& pos, Stack* ss, Value alpha, Value beta) {
static_assert(nodeType != Root);
constexpr bool PvNode = nodeType == PV;
assert(alpha >= -VALUE_INFINITE && alpha < beta && beta <= VALUE_INFINITE);
assert(PvNode || (alpha == beta - 1));
nodeType is a template parameter, not a runtime variable.
That means: Compiler generates separate versions of this function for:
- PV
- NonPV
So this function exists twice in the binary.
What is static_assert?
static_assert(nodeType != Root);
This is a compile-time assertion.
It checks a condition during compilation, not at runtime.
If the condition is false → compilation fails.
In this case:
It ensures:
qsearch is never instantiated with nodeType == Root.
Because:
- Root nodes are only used in normal search.
- Qsearch never runs at root.
What is this line?
constexpr bool PvNode = nodeType == PV;
Why constexpr?
Because nodeType is a template parameter.
So nodeType == PV can be evaluated at compile time.
Therefore PvNode is a compile-time constant.
- Not runtime.
- Not stored.
- No branching cost.
This allows later code like:
if constexpr (PvNode)
or even:
assert(PvNode || (alpha == beta - 1));
to behave differently depending on node type.
The assertions
assert(alpha >= -VALUE_INFINITE && alpha < beta && beta <= VALUE_INFINITE);
This is a runtime assertion.
It ensures:
- Alpha and beta are valid
- Alpha < beta
- Bounds are within legal score range
This protects search correctness.
assert(PvNode || (alpha == beta - 1));
This is very important.
It says:
If this is NOT a PV node, then the search window must be null-window.
Section 1: Setup and Early Exits
// Check if we have an upcoming move that draws by repetition
if (alpha < VALUE_DRAW && pos.upcoming_repetition(ss->ply))
{
alpha = value_draw(this->nodes);
if (alpha >= beta)
return alpha;
}
Repetition detection: If we can force a draw by repetition, use it to raise alpha.
// Step 2. Check for an immediate draw or maximum ply reached
if (pos.is_draw(ss->ply) || ss->ply >= MAX_PLY)
return (ss->ply >= MAX_PLY && !ss->inCheck) ? evaluate(pos) : VALUE_DRAW;
Safety checks:
- 50-move rule, insufficient material, etc.
- Maximum search depth (prevent stack overflow)
Section 2: Transposition Table Probe
// Step 3. Transposition table lookup
posKey = pos.key();
auto [ttHit, ttData, ttWriter] = tt.probe(posKey);
ttData.move = ttHit ? ttData.move : Move::none();
ttData.value = ttHit ? value_from_tt(ttData.value, ss->ply, pos.rule50_count()) : VALUE_NONE;
Same as normal search:
- Check if we’ve seen this position before
- Extract TT move (try it first)
- Adjust mate scores for current ply
// At non-PV nodes we check for an early TT cutoff
if (!PvNode && ttData.depth >= DEPTH_QS
&& is_valid(ttData.value)
&& (ttData.bound & (ttData.value >= beta ? BOUND_LOWER : BOUND_UPPER)))
return ttData.value;
TT cutoff:
- Non-PV nodes can use TT value directly
- Must be searched to at least quiescence depth
- Bound must allow the cutoff
Section 3: Stand Pat (THE KEY CONCEPT!)
// Step 4. Static evaluation of the position
if (ss->inCheck)
bestValue = futilityBase = -VALUE_INFINITE;
else
{
// Get static evaluation (from TT or compute)
if (ss->ttHit)
unadjustedStaticEval = ttData.eval;
else
unadjustedStaticEval = evaluate(pos);
ss->staticEval = bestValue =
to_corrected_static_eval(unadjustedStaticEval, correctionValue);
// Stand pat. Return immediately if static value is at least beta
if (bestValue >= beta)
{
// Save to TT and return
return bestValue;
}
if (bestValue > alpha)
alpha = bestValue;
}
Stand Pat Explained This is what makes qsearch different from normal search!
// Normal search:
// MUST try at least one move
for (move in moves) { ... }
// Quiescence:
// Can choose to NOT capture anything!
bestValue = evaluate(pos); // "I stand pat"
if (bestValue >= beta)
return beta; // Position is already good enough!
if (bestValue > alpha)
alpha = bestValue; // Raise the bar for captures
Why stand pat?
Position: We're up a queen
Static eval: +900 (huge advantage)
Alpha: -50
Beta: +50
Stand pat check:
if (+900 >= +50) // YES!
return +900
No need to try captures!
We're already winning by so much that
even if opponent has good captures,
they can't bring score below beta.
When in check: Can’t stand pat (must move!)
if (ss->inCheck)
bestValue = -VALUE_INFINITE; // Must try evasions
Section 4: Futility Base
futilityBase = ss->staticEval + 359;
This sets up futility pruning (explained in move loop).
Idea:
If static eval + captured piece value < alpha → This capture probably won’t raise alpha → Skip it (prune)
The +359 is a margin (roughly 3.5 pawns)
Section 5: Move Loop Setup
// Initialize MovePicker for quiescence
MovePicker mp(pos, ttData.move, DEPTH_QS, ...);
// Step 5. Loop through all pseudo-legal moves
while ((move = mp.next_move()) != Move::none())
{
if (!pos.legal(move))
continue;
givesCheck = pos.gives_check(move);
capture = pos.capture_stage(move);
moveCount++;
MovePicker in qsearch:
- Constructor uses
DEPTH_QS(quiescence mode) - Only generates captures (and checks at depth 0)
- Orders by MVV-LVA
Section 6: Pruning (THE OPTIMIZATIONS!)
This block decides:
“Is this move worth searching fully?”
It applies multiple cheap filters before doing expensive recursive search.
The pruning layers here are: 1. Futility pruning 2. Move-count pruning 3. SEE-based pruning 4. Continuation history pruning 5. Hard SEE threshold pruning
All inside:
if (!is_loss(bestValue))
Meaning:
Only prune if we haven’t already found a forced loss.
If the position is already terrible, pruning becomes unsafe.
Part 1 — Entry Conditions
if (!givesCheck && move.to_sq() != prevSq && !is_loss(futilityBase)
&& move.type_of() != PROMOTION)
We only prune if:
- Move does NOT give check (checks are tactical)
- Not a recapture square (recaptures are important)
- Futility base itself isn’t already losing
- Not a promotion (promotions are tactical)
So this pruning only applies to quiet / non-critical moves.
Part 2 — Move Count Pruning
if (moveCount > 2)
continue;
After 2 moves already tried:
Later moves are assumed weak.
This is late move pruning (LMP).
If you’re the 3rd+ quiet move, and no tactical signs, skip.
Futility Pruning
Futility pruning is based on this idea:
If even after adding some optimistic margin, this move cannot possibly raise alpha — don’t search it.
// Futility pruning
if (!givesCheck && move.to_sq() != prevSq
&& !is_loss(futilityBase) && move.type_of() != PROMOTION)
{
// Move count pruning: only try first 2 moves
if (moveCount > 2)
continue;
Value futilityValue = futilityBase + PieceValue[pos.piece_on(move.to_sq())];
// If static eval + captured piece << alpha, skip
if (futilityValue <= alpha)
{
bestValue = std::max(bestValue, futilityValue);
continue; // Prune!
}
}
At shallow depths, especially near leaf nodes, we can say:
staticEval + margin < alpha
If that’s true, the move is probably hopeless.
So we skip it.
Why It’s Safe (Mostly)
At low depth (e.g., depth 1 or 2):
If static position is bad, and move is quiet, and margin is small,
chances are very high it won’t suddenly jump above alpha.
So pruning saves a ton of nodes.
Why It’s Dangerous
If margin too small:
- You prune winning moves
If margin too large:
- You prune nothing
So tuning futility margins is critical.
Example:
Static eval: +100
Alpha: +300
Capturing pawn (value 100)
futilityValue = 100 + 100 = +200
if (+200 <= +300) // YES
continue; // Skip this capture
Why? Even if we capture the pawn,
we're at +200, still below alpha (+300).
Unlikely to improve our position enough.
Part 3 — Futility Value Test
Value futilityValue = futilityBase + PieceValue[pos.piece_on(move.to_sq())];
This estimates:
Best case outcome if we capture the piece on target square.
Then:
if (futilityValue <= alpha)
Meaning:
Even optimistically, this move cannot beat alpha.
So prune.
But:
bestValue = std::max(bestValue, futilityValue);
This is subtle.
Even though we prune, we update bestValue so the node returns something reasonable.
This is fail-soft behavior.
SEE Pruning
// If static exchange evaluation is too low, prune
if (!pos.see_ge(move, alpha - futilityBase))
{
bestValue = std::min(alpha, futilityBase);
continue;
}
Example:
Alpha: +300
FutilityBase: +150
Threshold: 300 - 150 = +150
Capture Bxe5 has SEE = -200 (lose bishop for pawn)
if (!see_ge(Bxe5, +150)) // SEE = -200, fails
continue; // Prune bad capture
Part 4 — SEE-based Futility
if (!pos.see_ge(move, alpha - futilityBase))
This asks:
Does static exchange evaluation suggest this move is too bad?
alpha - futilityBase represents how much gain we need.
If SEE says we cannot even gain that much, skip.
This is a more precise tactical filter than static futility.
Part 5 — Continuation History Pruning
if (!capture
&& history score <= 6290)
continue;
This means:
If move historically performs badly, skip it.
Continuation history tracks:
“When previous move was X, this move Y was bad.”
If cumulative history score is low, we assume move is unlikely good.
So prune.
This is learned pruning.
Why All These Layers?
Because search is exponential.
Each layer removes:
- Hopeless quiet moves
- Losing tactical moves
- Historically bad continuations
- Late weak moves
Without these, Stockfish would search billions more nodes.
Why Wrapped Inside !is_loss(bestValue)?
If bestValue already indicates:
“We are getting mated”
Then pruning becomes dangerous.
We must search more thoroughly to find escapes.
So pruning is disabled in losing situations.
Section 7: Make Move and Recurse
// Step 7. Make and search the move
Piece movedPiece = pos.moved_piece(move);
do_move(pos, move, st, givesCheck);
thisThread->nodes.fetch_add(1, std::memory_order_relaxed);
ss->currentMove = move;
// ... update continuation history pointers ...
value = -qsearch<nodeType>(pos, ss + 1, -beta, -alpha);
undo_move(pos, move);
Standard negamax recursion:
- Make the move
- Recursively call qsearch (NOTE: qsearch calls itself!)
- Negate the result
- Undo the move
Identify the moved piece
Piece movedPiece = pos.moved_piece(move);
Why before do_move?
Because after do_move, the board changes. We need the original piece type for history updates.
Make the move
do_move(pos, move, st, givesCheck);
This:
- Updates board
- Updates hash
- Updates material
- Updates rule50
- Updates checkers
- Updates pinned pieces
- Pushes new StateInfo (st)
Important: Search always works by:
- Make move
- Recurse
- Undo move
No board copies.
Increment node counter
thisThread->nodes.fetch_add(1, std::memory_order_relaxed);
Counts visited nodes.
memory_order_relaxed is used because:
- We don’t need strict memory ordering
- Just approximate counting
- Faster atomic increment
Update search stack fields
ss->currentMove = move;
The stack (ss) stores per-ply search data.
Now we record:
“At this ply, we are searching this move.”
This is used for:
- Killer updates
- Countermove learning
- Singular extensions
- LMR decisions
Continuation History
ss->continuationHistory =
&thisThread->continuationHistory[ss->inCheck][capture][movedPiece][move.to_sq()];
This is advanced move ordering learning.
It means:
Given previous move context, how good is this follow-up move historically?
Dimensions include:
- Was previous node in check?
- Was previous move a capture?
- What piece moved?
- To which square?
It allows the engine to learn patterns like:
“After opponent plays e4, d5 is often strong.”
Continuation Correction History
ss->continuationCorrectionHistory =
&thisThread->continuationCorrectionHistory[movedPiece][move.to_sq()];
This adjusts static evaluation biases based on move patterns.
It slightly corrects eval errors discovered during search.
This is part of modern Stockfish tuning.
Recursive Call
value = -qsearch<nodeType>(pos, ss + 1, -beta, -alpha);
This is classic negamax form.
Important things happening:
- We negate score (because opponent’s perspective)
- We pass
ss + 1→ next ply stack frame - Window becomes
(-beta, -alpha)
This is alpha-beta inversion.
Undo move
undo_move(pos, move);
Restores:
- Board
- Hash
- Material
- StateInfo
- Checkers
- etc.
Everything returns exactly as before.
Section 8: Update Best Move
// Step 8. Check for a new best move
if (value > bestValue)
{
bestValue = value;
if (value > alpha)
{
bestMove = move;
if (PvNode)
update_pv(ss->pv, move, (ss + 1)->pv);
if (value < beta)
alpha = value; // Raise alpha
else
break; // Beta cutoff!
}
}
Standard alpha-beta update:
- New best? Update bestValue
- Exceeds alpha? Update alpha and bestMove
- Exceeds beta? Cutoff, stop searching
We just finished:
value = -qsearch(...)
Now we compare it.
Did this move beat the previous best?
if (value > bestValue)
This is purely local bookkeeping.
bestValue tracks the best score among moves tried so far at this node.
If true:
- This move becomes the best move so far.
Did it beat alpha?
if (value > alpha)
This is the important alpha-beta condition.
If value ≤ alpha:
- Move is worse than what we already had.
- Ignore it (but still may be bestValue locally).
If value > alpha:
- This move improves the lower bound.
- It might become part of the PV.
B. Update PV (only for PV nodes)
if (PvNode)
update_pv(ss->pv, move, (ss + 1)->pv);
This builds the principal variation.
Conceptually:
current PV = move + child PV
So:
If child PV is:
[d5, g3, Bg7]
And move is:
Nf3
Then PV becomes:
[Nf3, d5, g3, Bg7]
Only done in PV nodes because only PV nodes track exact line.
Check for Beta Cutoff
if (value < beta)
alpha = value;
else
break; // Beta cutoff!
Case A: value < beta
This means:
alpha < value < beta
So:
- This move improves alpha
- But does not exceed beta
- Continue searching other moves
Alpha is raised:
alpha = value;
Window narrows.
Case B: value ≥ beta
This is fail-high.
Meaning:
This move is so good that opponent will never allow this branch.
So stop searching remaining moves.
This is beta cutoff.
Section 9: Checkmate Detection
// Step 9. Check for mate
if (ss->inCheck && bestValue == -VALUE_INFINITE)
{
assert(!MoveList<LEGAL>(pos).size());
return mated_in(ss->ply);
}
Edge case:
In check, no legal moves (including evasions) → Checkmate! → Return mate score
This section: 1. Detects mate 2. Applies a small fail-soft correction 3. Writes to the transposition table 4. Returns the final score
Mate Detection
if (ss->inCheck && bestValue == -VALUE_INFINITE)
This means:
- We were in check at this node
- No move ever improved bestValue
- So no legal moves were found
Because if any legal move existed, bestValue would have been updated.
So:
In check + no legal moves = checkmate
Safety check
assert(!MoveList<LEGAL>(pos).size());
Just double-verifying that there are no legal moves.
Return mate score
return mated_in(ss->ply);
This returns something like:
-32000 + ply
Meaning:
- We are getting mated
- Distance matters
- Closer mate is worse
Mate scores are always distance-adjusted.
Fail-Soft Adjustment
if (!is_decisive(bestValue) && bestValue > beta)
bestValue = (bestValue + beta) / 2;
This is subtle.
This only happens if:
- Not a mate score
- bestValue exceeded beta
So this is a fail-high case.
In fail-soft search, value can exceed beta.
But extremely large values can:
- Destabilize aspiration windows
- Cause oscillations
So Stockfish compresses it slightly:
Instead of returning raw value, it returns something closer to beta.
This stabilizes search.
Transposition Table Write
ttWriter.write(
posKey,
value_to_tt(bestValue, ss->ply),
pvHit,
bestValue >= beta ? BOUND_LOWER : BOUND_UPPER,
DEPTH_QS,
bestMove,
unadjustedStaticEval,
tt.generation());
This stores:
Key
posKey
Zobrist hash of position.
Value (adjusted)
value_to_tt(bestValue, ss->ply)
This adjusts mate scores by ply.
Important: Mate scores must be stored relative to node.
Otherwise distance breaks when reused.
PV flag
pvHit
Indicates if this node was PV.
Used for replacement decisions.
Bound type
bestValue >= beta ? BOUND_LOWER : BOUND_UPPER
If we failed high: → Lower bound
Else: → Upper bound
Notice: Qsearch rarely stores EXACT bounds.
Because usually it operates in null-window.
Depth
DEPTH_QS
This tells TT:
This entry is quiescence depth only.
Not full depth.
So full search can overwrite it.
Best move
bestMove
For move ordering next time.
Static eval
unadjustedStaticEval
Stored separately for:
- Reverse futility
- Null move pruning
- Other heuristics
Generation
Used for aging entries.
Final Return
return bestValue;
Now the node returns to parent.