So what I try to do is not extend moves that seem to lead nowhere. Not extending in QS means you might not search them because you ran out of depth, but if they really lead nowhere, pruning them should not upset the stability of the tree much.
If I capture something, and on the next move my opponent gains more than I captured, this usually leads nowhere; it means I am just burning material. So I don't extend a move that seems to gain more than was captured on the previous ply (and which is passed to the next level as a parameter 'threshold' to Searc()). The gain is estimated as victim value (for unprotected victims) or as victim value minus attacker value (if it is protected). If there is no depth left to search such a move unextended, it is assumed to fail high (auto-fail-high). Otherwise it is searched at depth-1.
That some captures take depth, means that it will take some depth to do a reasonable-quality QS. So in stead of just having depth=0 as the QS level, there are now multiple levels. (Which was exactly what we are striving for; simple tactics will be already found at the lowest depth, but complex tactics, e.g. where capturing a hanging piece unexpectedly turns sour because you were pinned on something higher, or would in general be confronted with a recapture on a different square, would require a larger depth. But if the simple tactics does the job, the complex tactics would become irrelevant, so it will only be searched if it is indeed essential.)
Note that a branch RxB - NxR - QxN would count as simple tactics, because the NxR (protected by Q) only has an estimated gain of the exchange (+2), which is lower than the B value (3) in previous RxB, so it would be extended. Even the QxN would be extended, assuming the N was the only protector of the B, because N < R.
With 5 levels of QS (0-4), you would get something like this:
Code: Select all
#define QS 4
int Search(int alpha, int beta, int depth, threshold)
{
if(depth < 0) return -INF; // abort branch
if(depth <= QS) { // in QS, try stand pat
score = Evaluate();
if(score > alpha) alpha = score;
}
if(alpha >= beta) return beta; // stand-pat cutoff
for(ALL_MOVES) {
if(iterDepth <= QS ! IsCapture(move)) continue; // in QS skip non-captures
int gain = value[victim];
if( IsProtected(toSqr) ) gain -= value[piece];
int extend = 0;
if(iterDepth <= QS && gain <= threshold) extend = 1;
MakeMove(move);
score = -Search(-beta, -alpha, depth - 1 + extend, depth <= QS ? value[victim] : INF);
UnMake(move);
if(score > alpha) {
alpha = score;
if(score >= beta) return beta; // beta cutoff
}
}
return alpha;
}
Note that equal captures do have zero 'gain', always <= threshold, so they are always extended, and thus part of simple tactics.
The pseudo-code example doesn't pay any attention to move sorting; it is essential, however, that the search of all non-extended captures (or their auto-fail-high) would be done before the search of all extended captures: they are reduced and expected to fail high. If you generate the captures in MVV order, they can only be non-extended as long as the victim value is above 'threshold'. For protected victims the estimated gain can then still below threshold (so the capture would be extended), and such moves in this stage would have to be postponed, while captures of unprotected victims can always be searched immediately. The postponed captures can be searched before you get to any captures with victim value below 'threshold'.
Also essential (and not shown) is to do depth bootstrapping, as sub-trees that did not run into any auto-fail-highs (a simple stand-pat being the simplest example) are immediately valid to depth = QS = 4, even if they were obtained in a depth=0 search, so that it would be pointless to re-search them at depth = 1, 2, 3, 4.
