feat: Refactor alpha-beta search to improve search parameters and quiescence handling
Build & Test (NowChessSystems) TeamCity build failed

This commit is contained in:
2026-04-17 21:41:11 +02:00
parent 4f6cc2c0f8
commit 473c62666a
12 changed files with 502 additions and 169 deletions
+1
View File
@@ -51,6 +51,7 @@ val versions = mapOf(
"SCALAFX" to "21.0.0-R32",
"JAVAFX" to "21.0.1",
"JUNIT_BOM" to "5.13.4",
"ONNXRUNTIME" to "1.19.2",
"SCALA_PARSER_COMBINATORS" to "2.4.0",
"FASTPARSE" to "3.0.2",
"JACKSON" to "2.17.2",
+20 -3
View File
@@ -1,6 +1,6 @@
plugins {
id("scala")
id("org.scoverage") version "8.1"
id("org.scoverage")
}
group = "de.nowchess"
@@ -19,6 +19,23 @@ scala {
scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
excludedPackages.set(
listOf(
"de\\.nowchess\\.bot\\.bots\\.NNUEBot",
"de\\.nowchess\\.bot\\.bots\\.nnue\\..*",
"de\\.nowchess\\.bot\\.util\\.PolyglotBook",
)
)
excludedFiles.set(
listOf(
".*NNUE\\.scala",
".*NNUEBot\\.scala",
".*NbaiLoader\\.scala",
".*NbaiMigrator\\.scala",
".*NbaiWriter\\.scala",
".*PolyglotBook\\.scala",
)
)
}
tasks.withType<ScalaCompile> {
@@ -41,9 +58,9 @@ dependencies {
implementation(project(":modules:api"))
implementation(project(":modules:io"))
implementation(project(":modules:rule"))
implementation("com.microsoft.onnxruntime:onnxruntime:1.19.2")
implementation("com.microsoft.onnxruntime:onnxruntime:${versions["ONNXRUNTIME"]!!}")
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation(platform("org.junit:junit-bom:${versions["JUNIT_BOM"]!!}"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
@@ -3,6 +3,7 @@ package de.nowchess.bot.bots
import de.nowchess.api.bot.Bot
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.bot.ai.Evaluation
import de.nowchess.bot.bots.classic.EvaluationClassic
import de.nowchess.bot.bots.nnue.EvaluationNNUE
import de.nowchess.bot.logic.{AlphaBetaSearch, TranspositionTable}
@@ -15,9 +16,12 @@ final class HybridBot(
difficulty: BotDifficulty,
rules: RuleSet = DefaultRules,
book: Option[PolyglotBook] = None,
nnueEvaluation: Evaluation = EvaluationNNUE,
classicalEvaluation: Evaluation = EvaluationClassic,
vetoReporter: String => Unit = println(_),
) extends Bot:
private val search = AlphaBetaSearch(rules, TranspositionTable(), EvaluationClassic)
private val search = AlphaBetaSearch(rules, TranspositionTable(), classicalEvaluation)
override val name: String = s"HybridBot(${difficulty.toString})"
@@ -28,11 +32,11 @@ final class HybridBot(
private def searchWithVeto(context: GameContext, blockedMoves: Set[Move]): Option[Move] =
search.bestMoveWithTime(context, Config.TIME_LIMIT_MS, blockedMoves).map { move =>
val next = rules.applyMove(context)(move)
val staticNnue = EvaluationNNUE.evaluate(next)
val classical = EvaluationClassic.evaluate(next)
val staticNnue = nnueEvaluation.evaluate(next)
val classical = classicalEvaluation.evaluate(next)
val diff = (classical - staticNnue).abs
if diff > Config.VETO_THRESHOLD then
println(
vetoReporter(
f"[Veto] ${move.from}->${move.to}: nnue=$staticNnue classical=$classical diff=$diff — flagged but trusted (deep search)",
)
move
@@ -187,7 +187,7 @@ object EvaluationClassic extends Evaluation:
(mg + doubled * doubledMg + isolated * isolatedMg, eg + doubled * doubledEg + isolated * isolatedEg)
}
private def positionalBonuses(context: GameContext, phase: Int, isEg: Boolean = false): Int =
private def positionalBonuses(context: GameContext, phase: Int, isEg: Boolean): Int =
context.board.pieces.foldLeft(0) { case (score, (sq, piece)) =>
val bonus = piece.pieceType match
case PieceType.Pawn =>
@@ -281,30 +281,44 @@ object EvaluationClassic extends Evaluation:
taper(mg, eg, phase)
private def rookAndBishopBonuses(context: GameContext, phase: Int): Int =
val friendlyBishops =
context.board.pieces.filter((_, p) => p.color == context.turn && p.pieceType == PieceType.Bishop)
val enemyBishops = context.board.pieces.filter((_, p) => p.color != context.turn && p.pieceType == PieceType.Bishop)
val (baseMg, baseEg) = bishopPairBase(context)
val (rookMg, rookEg) = rookOn7thDelta(context)
taper(baseMg + rookMg, baseEg + rookEg, phase)
val friendlyHasPair =
friendlyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 0) &&
friendlyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 1)
val enemyHasPair =
enemyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 0) &&
enemyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 1)
private def bishopPairBase(context: GameContext): (Int, Int) =
val friendlyHasPair = hasBishopPair(context, context.turn)
val enemyHasPair = hasBishopPair(context, context.turn.opposite)
val mg = pairDelta(friendlyHasPair, enemyHasPair, bishopPairMg)
val eg = pairDelta(friendlyHasPair, enemyHasPair, bishopPairEg)
(mg, eg)
val baseMg = (if friendlyHasPair then bishopPairMg else 0) - (if enemyHasPair then bishopPairMg else 0)
val baseEg = (if friendlyHasPair then bishopPairEg else 0) - (if enemyHasPair then bishopPairEg else 0)
val (mg, eg) = context.board.pieces.foldLeft((baseMg, baseEg)) { case ((mg, eg), (sq, piece)) =>
if piece.pieceType == PieceType.Rook then
val is7th = if piece.color == Color.White then sq.rank.ordinal == 6 else sq.rank.ordinal == 1
if is7th then
if piece.color == context.turn then (mg + rookOn7thMg, eg + rookOn7thEg)
else (mg - rookOn7thMg, eg - rookOn7thEg)
else (mg, eg)
else (mg, eg)
private def hasBishopPair(context: GameContext, color: Color): Boolean =
val bishopSquares = context.board.pieces.collect {
case (sq, piece) if piece.color == color && piece.pieceType == PieceType.Bishop => sq
}
taper(mg, eg, phase)
bishopSquares.exists(isEvenSquare) && bishopSquares.exists(sq => !isEvenSquare(sq))
private def isEvenSquare(square: Square): Boolean =
(square.file.ordinal + square.rank.ordinal) % 2 == 0
private def pairDelta(friendlyHasPair: Boolean, enemyHasPair: Boolean, bonus: Int): Int =
(if friendlyHasPair then bonus else 0) - (if enemyHasPair then bonus else 0)
private def rookOn7thDelta(context: GameContext): (Int, Int) =
context.board.pieces.foldLeft((0, 0)) { case ((mg, eg), (sq, piece)) =>
rookOn7thContribution(piece, sq, context.turn).fold((mg, eg)) { case (dMg, dEg) =>
(mg + dMg, eg + dEg)
}
}
private def rookOn7thContribution(piece: de.nowchess.api.board.Piece, sq: Square, turn: Color): Option[(Int, Int)] =
Option.when(piece.pieceType == PieceType.Rook && isRookOn7th(piece.color, sq)) {
val sign = if piece.color == turn then 1 else -1
(sign * rookOn7thMg, sign * rookOn7thEg)
}
private def isRookOn7th(color: Color, sq: Square): Boolean =
if color == Color.White then sq.rank.ordinal == 6 else sq.rank.ordinal == 1
private def endgameBonus(context: GameContext): Int =
val friendlyKing = context.board.pieces.find((_, p) => p.color == context.turn && p.pieceType == PieceType.King)
@@ -334,13 +348,14 @@ object EvaluationClassic extends Evaluation:
private def materialCount(context: GameContext, color: Color): Int =
context.board.pieces.foldLeft(0) { case (sum, (_, piece)) =>
if piece.color == color && piece.pieceType != PieceType.King && piece.pieceType != PieceType.Pawn then
if piece.color == color then
sum + (piece.pieceType match
case PieceType.Knight => 300
case PieceType.Bishop => 300
case PieceType.Rook => 500
case PieceType.Queen => 900
case _ => 0
case PieceType.Pawn => 0
case PieceType.King => 0
)
else sum
}
@@ -4,7 +4,6 @@ import de.nowchess.api.board.PieceType
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType}
import de.nowchess.bot.ai.Evaluation
import de.nowchess.bot.logic.{MoveOrdering, TTEntry, TTFlag, TranspositionTable}
import de.nowchess.bot.util.ZobristHash
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
@@ -31,6 +30,14 @@ final class AlphaBetaSearch(
private val nodeCount = AtomicInteger(0)
private val ordering = MoveOrdering.OrderingContext()
private final case class QuiescenceNode(
context: GameContext,
ply: Int,
alpha: Int,
beta: Int,
hash: Long,
)
/** Return the best move for the side to move, searching to maxDepth plies. Uses iterative deepening with aspiration
* windows.
*/
@@ -111,14 +118,14 @@ final class AlphaBetaSearch(
val state = SearchState(rootHash, Map(rootHash -> 1))
@scala.annotation.tailrec
def loop(currentAlpha: Int, currentBeta: Int, window: Int, attempt: Int): (Int, Option[Move]) =
if attempt >= 3 || attempt >= depth then search(context, depth, 0, -INF, INF, state, excludedRootMoves)
def loop(currentAlpha: Int, currentBeta: Int, delta: Int, attempt: Int): (Int, Option[Move]) =
if attempt >= 3 || attempt >= depth then search(context, depth, 0, Window(-INF, INF), state, excludedRootMoves)
else
val (score, move) = search(context, depth, 0, currentAlpha, currentBeta, state, excludedRootMoves)
val (score, move) = search(context, depth, 0, Window(currentAlpha, currentBeta), state, excludedRootMoves)
if score > currentAlpha && score < currentBeta then (score, move)
else if score <= currentAlpha then
loop(score - window, currentBeta, math.min(window * 2, ASPIRATION_DELTA_MAX), attempt + 1)
else loop(currentAlpha, score + window, math.min(window * 2, ASPIRATION_DELTA_MAX), attempt + 1)
loop(score - delta, currentBeta, math.min(delta * 2, ASPIRATION_DELTA_MAX), attempt + 1)
else loop(currentAlpha, score + delta, math.min(delta * 2, ASPIRATION_DELTA_MAX), attempt + 1)
loop(alpha, beta, initialWindow, 0)
@@ -144,7 +151,7 @@ final class AlphaBetaSearch(
val nullState = state.advance(ZobristHash.hash(nullCtx))
val reductionDepth = math.max(0, depth - 1 - NULL_MOVE_R)
weights.copyAccumulator(ply, ply + 1)
val (score, _) = search(nullCtx, reductionDepth, ply + 1, -beta, -beta + 1, nullState, excludedRootMoves)
val (score, _) = search(nullCtx, reductionDepth, ply + 1, Window(-beta, -beta + 1), nullState, excludedRootMoves)
if -score >= beta then Some(beta) else None
/** Negamax alpha-beta search returning (score, best move). */
@@ -152,116 +159,169 @@ final class AlphaBetaSearch(
context: GameContext,
depth: Int,
ply: Int,
alpha: Int,
beta: Int,
window: Window,
state: SearchState,
excludedRootMoves: Set[Move],
): (Int, Option[Move]) =
val params = SearchParams(context, depth, ply, window, state, excludedRootMoves)
searchNode(params)
private def searchNode(params: SearchParams): (Int, Option[Move]) =
val count = nodeCount.incrementAndGet()
if count % TIME_CHECK_FREQUENCY == 0 && isOutOfTime then (weights.evaluateAccumulator(ply, context, state.hash), None)
else if state.repetitions.getOrElse(state.hash, 0) >= 3 then (weights.DRAW_SCORE, None)
else
val ttCutoff = tt.probe(state.hash).filter(_.depth >= depth).flatMap { entry =>
immediateSearchResult(params, count).getOrElse {
val legalMoves = rules.allLegalMoves(params.context)
terminalSearchResult(params, legalMoves).getOrElse(searchDeeper(params, legalMoves))
}
private def immediateSearchResult(
params: SearchParams,
count: Int,
): Option[(Int, Option[Move])] =
if count % TIME_CHECK_FREQUENCY == 0 && isOutOfTime then
Some((weights.evaluateAccumulator(params.ply, params.context, params.state.hash), None))
else if params.state.repetitions.getOrElse(params.state.hash, 0) >= 3 then Some((weights.DRAW_SCORE, None))
else ttCutoff(params)
private def ttCutoff(params: SearchParams): Option[(Int, Option[Move])] =
tt.probe(params.state.hash).filter(_.depth >= params.depth).flatMap { entry =>
entry.flag match
case TTFlag.Exact => Some((entry.score, entry.bestMove))
case TTFlag.Lower =>
val newAlpha = math.max(alpha, entry.score)
Option.when(newAlpha >= beta)((entry.score, entry.bestMove))
val newAlpha = math.max(params.window.alpha, entry.score)
Option.when(newAlpha >= params.window.beta)((entry.score, entry.bestMove))
case TTFlag.Upper =>
val newBeta = math.min(beta, entry.score)
Option.when(alpha >= newBeta)((entry.score, entry.bestMove))
}
ttCutoff.getOrElse {
val legalMoves = rules.allLegalMoves(context)
if legalMoves.isEmpty then
(if rules.isCheckmate(context) then -(weights.CHECKMATE_SCORE - ply) else weights.DRAW_SCORE, None)
else if rules.isInsufficientMaterial(context) || rules.isFiftyMoveRule(context) then (weights.DRAW_SCORE, None)
else if depth == 0 then (quiescence(context, ply, alpha, beta, state.hash), None)
else
val nullResult = Option
.when(depth >= 3 && !rules.isCheck(context) && hasNonPawnMaterial(context)) {
tryNullMove(context, depth, ply, beta, state, excludedRootMoves)
}
.flatten
nullResult.map((_, None)).getOrElse {
val ttBest = tt.probe(state.hash).flatMap(_.bestMove)
val ordered = MoveOrdering.sort(context, legalMoves, ttBest, ply, ordering)
searchSequential(context, depth, ply, alpha, beta, ordered, state, excludedRootMoves)
}
val newBeta = math.min(params.window.beta, entry.score)
Option.when(params.window.alpha >= newBeta)((entry.score, entry.bestMove))
}
private def searchSequential(
context: GameContext,
depth: Int,
ply: Int,
alpha: Int,
beta: Int,
ordered: List[Move],
state: SearchState,
excludedRootMoves: Set[Move],
private def terminalSearchResult(
params: SearchParams,
legalMoves: List[Move],
): Option[(Int, Option[Move])] =
if legalMoves.isEmpty then
Some((if rules.isCheckmate(params.context) then -(weights.CHECKMATE_SCORE - params.ply) else weights.DRAW_SCORE, None))
else if rules.isInsufficientMaterial(params.context) || rules.isFiftyMoveRule(params.context) then Some((weights.DRAW_SCORE, None))
else if params.depth == 0 then
Some((quiescence(params.context, params.ply, params.window.alpha, params.window.beta, params.state.hash), None))
else None
private def searchDeeper(
params: SearchParams,
legalMoves: List[Move],
): (Int, Option[Move]) =
@scala.annotation.tailrec
def loop(
idx: Int,
bestMove: Option[Move],
bestScore: Int,
a: Int,
moveNumber: Int,
): (Option[Move], Int, Boolean) =
if idx >= ordered.length then (bestMove, bestScore, false)
else
val move = ordered(idx)
val skipRootMove = ply == 0 && excludedRootMoves.contains(move)
val isQuiet = !isCapture(context, move) &&
val nullResult =
Option.when(canTryNullMove(params))(tryNullMove(params.context, params.depth, params.ply, params.window.beta, params.state, params.excludedRootMoves)).flatten
nullResult.map((_, None)).getOrElse {
val ttBest = tt.probe(params.state.hash).flatMap(_.bestMove)
val ordered = MoveOrdering.sort(params.context, legalMoves, ttBest, params.ply, ordering)
searchSequential(
params.context,
params.depth,
params.ply,
params.window,
ordered,
params.state,
params.excludedRootMoves,
)
}
private def canTryNullMove(params: SearchParams): Boolean =
params.depth >= 3 &&
!rules.isCheck(params.context) &&
hasNonPawnMaterial(params.context)
private def isQuietMove(context: GameContext, move: Move): Boolean =
!isCapture(context, move) &&
move.moveType != MoveType.CastleKingside &&
move.moveType != MoveType.CastleQueenside
val pruneByFutility = depth == 1 && isQuiet && moveNumber > 2 &&
weights.evaluateAccumulator(ply, context, state.hash) + FUTILITY_MARGIN < alpha
if skipRootMove || pruneByFutility then loop(idx + 1, bestMove, bestScore, a, moveNumber + 1)
else
val child = rules.applyMove(context)(move)
val childHash = ZobristHash.nextHash(context, state.hash, move, child)
weights.pushAccumulator(ply + 1, move, context, child)
val childState = state.advance(childHash)
val givesCheck = rules.isCheck(child)
val extension = if givesCheck then CHECK_EXTENSION else 0
val reduction = if moveNumber > 4 && depth >= 3 && isQuiet then 1 else 0
val score =
private def scoreMove(
child: GameContext,
childState: SearchState,
params: SearchParams,
extension: Int,
reduction: Int,
a: Int,
): Int =
val betaNeg = -params.window.beta
if reduction > 0 then
val reducedDepth = math.max(0, depth - 1 - reduction + extension)
val (reducedScore, _) = search(child, reducedDepth, ply + 1, -a - 1, -a, childState, excludedRootMoves)
val s = -reducedScore
val (rs, _) = search(child, math.max(0, params.depth - 1 - reduction + extension), params.ply + 1, Window(-a - 1, -a), childState, params.excludedRootMoves)
val s = -rs
if s > a then
val fullDepth = math.max(0, depth - 1 + extension)
val (fullScore, _) = search(child, fullDepth, ply + 1, -beta, -a, childState, excludedRootMoves)
-fullScore
val (fs, _) = search(child, math.max(0, params.depth - 1 + extension), params.ply + 1, Window(betaNeg, -a), childState, params.excludedRootMoves)
-fs
else s
else
val fullDepth = math.max(0, depth - 1 + extension)
val (rawScore, _) = search(child, fullDepth, ply + 1, -beta, -a, childState, excludedRootMoves)
-rawScore
val (rs, _) = search(child, math.max(0, params.depth - 1 + extension), params.ply + 1, Window(betaNeg, -a), childState, params.excludedRootMoves)
-rs
val newBestScore = math.max(bestScore, score)
val newBestMove = if score > bestScore then Some(move) else bestMove
val newA = math.max(a, score)
private def evalSingleMove(
move: Move,
moveNumber: Int,
a: Int,
params: SearchParams,
): Option[(Int, Boolean)] =
val skipRoot = params.ply == 0 && params.excludedRootMoves.contains(move)
val isQuiet = isQuietMove(params.context, move)
val futility = params.depth == 1 && isQuiet && moveNumber > 2 &&
weights.evaluateAccumulator(params.ply, params.context, params.state.hash) + FUTILITY_MARGIN < params.window.alpha
if skipRoot || futility then None
else
val child = rules.applyMove(params.context)(move)
val childHash = ZobristHash.nextHash(params.context, params.state.hash, move, child)
weights.pushAccumulator(params.ply + 1, move, params.context, child)
val childState = params.state.advance(childHash)
val extension = if rules.isCheck(child) then CHECK_EXTENSION else 0
val reduction = if moveNumber > 4 && params.depth >= 3 && isQuiet then 1 else 0
Some((scoreMove(child, childState, params, extension, reduction, a), isQuiet))
if newA >= beta then
if isQuiet then
private def recordCutoff(move: Move, depth: Int, ply: Int): Unit =
ordering.addHistory(
move.from.rank.ordinal * 8 + move.from.file.ordinal,
move.to.rank.ordinal * 8 + move.to.file.ordinal,
depth * depth,
)
ordering.addKillerMove(ply, move)
(newBestMove, newBestScore, true)
else loop(idx + 1, newBestMove, newBestScore, newA, moveNumber + 1)
val (bestMove, bestScore, cutoff) = loop(0, None, -INF, alpha, 0)
@scala.annotation.tailrec
private def searchLoop(
idx: Int,
moveNumber: Int,
acc: LoopAcc,
params: SearchParams,
ordered: List[Move],
): (Option[Move], Int, Boolean) =
if idx >= ordered.length then (acc.bestMove, acc.bestScore, false)
else
val move = ordered(idx)
evalSingleMove(move, moveNumber, acc.a, params) match
case None => searchLoop(idx + 1, moveNumber + 1, acc, params, ordered)
case Some((score, isQuiet)) =>
val newAcc = LoopAcc(
if score > acc.bestScore then Some(move) else acc.bestMove,
math.max(acc.bestScore, score),
math.max(acc.a, score),
)
if newAcc.a >= params.window.beta then
if isQuiet then recordCutoff(move, params.depth, params.ply)
(newAcc.bestMove, newAcc.bestScore, true)
else searchLoop(idx + 1, moveNumber + 1, newAcc, params, ordered)
private def searchSequential(
context: GameContext,
depth: Int,
ply: Int,
window: Window,
ordered: List[Move],
state: SearchState,
excludedRootMoves: Set[Move],
): (Int, Option[Move]) =
val params = SearchParams(context, depth, ply, window, state, excludedRootMoves)
val (bestMove, bestScore, cutoff) = searchLoop(0, 0, LoopAcc(None, -INF, window.alpha), params, ordered)
val flag =
if cutoff then TTFlag.Lower
else if bestScore <= alpha then TTFlag.Upper
else if bestScore <= window.alpha then TTFlag.Upper
else TTFlag.Exact
tt.store(TTEntry(state.hash, depth, bestScore, flag, bestMove))
(bestScore, bestMove)
@@ -274,35 +334,45 @@ final class AlphaBetaSearch(
beta: Int,
hash: Long,
): Int =
val inCheck = rules.isCheck(context)
val standPat = if inCheck then -INF else weights.evaluateAccumulator(ply, context, hash)
quiescenceNode(QuiescenceNode(context, ply, alpha, beta, hash))
if !inCheck && standPat >= beta then beta
else
val a0 = if inCheck then alpha else math.max(alpha, standPat)
private def quiescenceNode(node: QuiescenceNode): Int =
val inCheck = rules.isCheck(node.context)
val standPat = if inCheck then -INF else weights.evaluateAccumulator(node.ply, node.context, node.hash)
if ply >= MAX_QUIESCENCE_PLY then if inCheck then weights.evaluateAccumulator(ply, context, hash) else standPat
if !inCheck && standPat >= node.beta then node.beta
else if node.ply >= MAX_QUIESCENCE_PLY then quiescenceAtDepthLimit(node, inCheck, standPat)
else
val moves = tacticalMoves(node.context, inCheck)
if inCheck && moves.isEmpty then -(weights.CHECKMATE_SCORE - node.ply)
else
val ordered = MoveOrdering.sort(node.context, moves, None)
val a0 = if inCheck then node.alpha else math.max(node.alpha, standPat)
quiescenceLoop(node, ordered, 0, a0)
private def quiescenceAtDepthLimit(node: QuiescenceNode, inCheck: Boolean, standPat: Int): Int =
if inCheck then weights.evaluateAccumulator(node.ply, node.context, node.hash) else standPat
private def tacticalMoves(context: GameContext, inCheck: Boolean): List[Move] =
val allMoves = rules.allLegalMoves(context)
val tacticalMoves = if inCheck then allMoves else allMoves.filter(m => isCapture(context, m))
if inCheck && tacticalMoves.isEmpty then -(weights.CHECKMATE_SCORE - ply)
else
val ordered = MoveOrdering.sort(context, tacticalMoves, None)
if inCheck then allMoves else allMoves.filter(m => isCapture(context, m))
@scala.annotation.tailrec
def loop(idx: Int, a: Int): Int =
private def quiescenceLoop(
node: QuiescenceNode,
ordered: List[Move],
idx: Int,
a: Int,
): Int =
if idx >= ordered.length then a
else
val move = ordered(idx)
val child = rules.applyMove(context)(move)
val childHash = ZobristHash.nextHash(context, hash, move, child)
weights.pushAccumulator(ply + 1, move, context, child)
val score = -quiescence(child, ply + 1, -beta, -a, childHash)
if score >= beta then beta
else loop(idx + 1, math.max(a, score))
loop(0, a0)
val child = rules.applyMove(node.context)(move)
val childHash = ZobristHash.nextHash(node.context, node.hash, move, child)
weights.pushAccumulator(node.ply + 1, move, node.context, child)
val score = -quiescence(child, node.ply + 1, -node.beta, -a, childHash)
if score >= node.beta then node.beta
else quiescenceLoop(node, ordered, idx + 1, math.max(a, score))
private def isCapture(context: GameContext, move: Move): Boolean = move.moveType match
case MoveType.Normal(true) => true
@@ -107,7 +107,7 @@ object MoveOrdering:
val target = move.to
val initialGain = victimValue(context, move)
movedPieceAfterMove(context, move).fold(initialGain) { moved =>
val boardAfterMove = applySeeMove(context.board, context, move, moved)
val boardAfterMove = applySeeMove(context.board, move, moved)
exchangeGain(boardAfterMove, target, context.turn.opposite, pieceValue(moved), Vector(initialGain))
}
@@ -132,7 +132,7 @@ object MoveOrdering:
}
.head
private def applySeeMove(board: Board, context: GameContext, move: Move, moved: Piece): Board =
private def applySeeMove(board: Board, move: Move, moved: Piece): Board =
move.moveType match
case MoveType.EnPassant =>
val capturedSquare = Square(move.to.file, move.from.rank)
@@ -169,8 +169,7 @@ object MoveOrdering:
private def clearLine(board: Board, from: Square, target: Square, df: Int, dr: Int, diagonal: Boolean): Boolean =
val valid =
if diagonal then math.abs(df) == math.abs(dr) && df != 0 else (df == 0 && dr != 0) || (dr == 0 && df != 0)
if !valid then false
else pathClear(board, from, target, Integer.compare(df, 0), Integer.compare(dr, 0))
valid && pathClear(board, from, target, Integer.compare(df, 0), Integer.compare(dr, 0))
@tailrec
private def pathClear(board: Board, from: Square, target: Square, stepF: Int, stepR: Int): Boolean =
@@ -1,7 +1,21 @@
package de.nowchess.bot.logic
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
final case class Window(alpha: Int, beta: Int)
final case class LoopAcc(bestMove: Option[Move], bestScore: Int, a: Int)
final case class SearchParams(
context: GameContext,
depth: Int,
ply: Int,
window: Window,
state: SearchState,
excludedRootMoves: Set[Move],
)
final case class SearchState(hash: Long, repetitions: Map[Long, Int]):
def advance(nextHash: Long): SearchState =
SearchState(nextHash, repetitions.updatedWith(nextHash) {
@@ -33,7 +33,7 @@ final class PolyglotBook(path: String):
/** Probe the book for a move in the given position. Returns a weighted random move, or None if not in book. */
def probe(context: GameContext): Option[Move] =
val hash = PolyglotHash.hash(context)
println(f"0x${hash}%016X")
println(f"0x$hash%016X")
entries.get(hash).flatMap { bookEntries =>
if bookEntries.isEmpty then None
else
@@ -49,7 +49,7 @@ final class PolyglotBook(path: String):
val key = input.readLong()
val move = input.readShort()
val weight = input.readShort()
val learn = input.readInt()
input.readInt() // learning data (unused)
val entry = BookEntry(key, move, weight)
result.updateWith(key) {
@@ -1,8 +1,9 @@
package de.nowchess.bot
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.bot.ai.Evaluation
import de.nowchess.bot.bots.classic.EvaluationClassic
import de.nowchess.bot.logic.AlphaBetaSearch
import de.nowchess.rules.RuleSet
@@ -12,6 +13,11 @@ import de.nowchess.rules.sets.DefaultRules
class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
private object ZeroEval extends Evaluation:
val CHECKMATE_SCORE: Int = 1_000_000
val DRAW_SCORE: Int = 0
def evaluate(context: GameContext): Int = 0
test("bestMove on initial position returns a move"):
val search = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
val move = search.bestMove(GameContext.initial, maxDepth = 2)
@@ -260,3 +266,72 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
def applyMove(context: GameContext)(move: Move): GameContext = context
val search = AlphaBetaSearch(drawRules, weights = EvaluationClassic)
search.bestMove(GameContext.initial, maxDepth = 2) should be(None)
test("repetition cutoff is reached on forced self-loop positions"):
// Use a no-op move from an empty square so nextHash alternates between a tiny set of hashes.
// This forces repetition counts >= 3 and exercises immediateSearchResult's repetition cutoff.
val loopMove = Move(Square(File.A, Rank.R3), Square(File.A, Rank.R4), MoveType.Normal())
val loopRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = List(loopMove)
def legalMoves(context: GameContext)(square: Square): List[Move] = List(loopMove)
def allLegalMoves(context: GameContext): List[Move] = List(loopMove)
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
val search = AlphaBetaSearch(loopRules, weights = ZeroEval)
search.bestMove(GameContext.initial, maxDepth = 8) should be(Some(loopMove))
test("quiescence returns checkmate score when side is in check and has no tactical moves"):
val rootMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal())
val capMove = Move(Square(File.D, Rank.R2), Square(File.D, Rank.R3), MoveType.Normal(true))
val qRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = allLegalMoves(context)
def legalMoves(context: GameContext)(square: Square): List[Move] = allLegalMoves(context)
def allLegalMoves(context: GameContext): List[Move] =
context.moves.length match
case 0 => List(rootMove)
case 1 => List(capMove)
case _ => Nil
def isCheck(context: GameContext): Boolean =
context.moves.length >= 2
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext =
context.copy(turn = context.turn.opposite, moves = context.moves :+ move)
val search = AlphaBetaSearch(qRules, weights = ZeroEval)
search.bestMove(GameContext.initial, maxDepth = 1) should be(Some(rootMove))
test("quiescence depth-limit in-check branch is exercised"):
val rootMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal())
val capMove = Move(Square(File.D, Rank.R2), Square(File.D, Rank.R3), MoveType.Normal(true))
var firstChildCheckCall = true
val deepQRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = allLegalMoves(context)
def legalMoves(context: GameContext)(square: Square): List[Move] = allLegalMoves(context)
def allLegalMoves(context: GameContext): List[Move] =
if context.moves.isEmpty then List(rootMove) else List(capMove)
def isCheck(context: GameContext): Boolean =
if context.moves.length == 1 && firstChildCheckCall then
firstChildCheckCall = false
false
else context.moves.nonEmpty
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext =
context.copy(turn = context.turn.opposite, moves = context.moves :+ move)
val search = AlphaBetaSearch(deepQRules, weights = ZeroEval)
search.bestMove(GameContext.initial, maxDepth = 1) should be(Some(rootMove))
@@ -3,13 +3,17 @@ package de.nowchess.bot
import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType}
import de.nowchess.bot.ai.Evaluation
import de.nowchess.bot.bots.HybridBot
import de.nowchess.bot.util.PolyglotBook
import de.nowchess.bot.util.{PolyglotBook, PolyglotHash}
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import java.io.{DataOutputStream, FileOutputStream}
import java.nio.file.Files
import scala.util.Using
class HybridBotTest extends AnyFunSuite with Matchers:
test("HybridBot name includes difficulty"):
@@ -60,3 +64,97 @@ class HybridBotTest extends AnyFunSuite with Matchers:
val ctx = GameContext.initial.copy(moves = List(repeatedMove, repeatedMove, repeatedMove))
val bot = HybridBot(BotDifficulty.Easy, onlyMoveRules)
bot.nextMove(ctx) should be(None)
test("HybridBot uses book move when available"):
val tempFile = Files.createTempFile("hybrid_book", ".bin")
try
val ctx = GameContext.initial
val hash = PolyglotHash.hash(ctx)
val e2e4: Short = (4 | (3 << 3) | (4 << 6) | (1 << 9)).toShort
Using(DataOutputStream(FileOutputStream(tempFile.toFile))) { dos =>
dos.writeLong(hash)
dos.writeShort(e2e4)
dos.writeShort(100)
dos.writeInt(0)
}.get
val book = PolyglotBook(tempFile.toString)
val bot = HybridBot(BotDifficulty.Easy, book = Some(book))
bot.nextMove(ctx) should be(Some(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())))
finally Files.deleteIfExists(tempFile)
test("HybridBot reports veto when classical and NNUE differ above threshold"):
val forcedMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal())
val oneMoveRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = List(forcedMove)
def legalMoves(context: GameContext)(square: Square): List[Move] = List(forcedMove)
def allLegalMoves(context: GameContext): List[Move] = List(forcedMove)
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext =
context.copy(turn = context.turn.opposite, moves = context.moves :+ move)
object LowNnue extends Evaluation:
val CHECKMATE_SCORE: Int = 10_000_000
val DRAW_SCORE: Int = 0
def evaluate(context: GameContext): Int = 0
object HighClassic extends Evaluation:
val CHECKMATE_SCORE: Int = 10_000_000
val DRAW_SCORE: Int = 0
def evaluate(context: GameContext): Int = 10_000
var reported = false
val bot = HybridBot(
BotDifficulty.Easy,
rules = oneMoveRules,
nnueEvaluation = LowNnue,
classicalEvaluation = HighClassic,
vetoReporter = _ => reported = true,
)
bot.nextMove(GameContext.initial) should be(Some(forcedMove))
reported should be(true)
test("HybridBot default veto reporter prints when threshold is exceeded"):
val forcedMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal())
val oneMoveRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = List(forcedMove)
def legalMoves(context: GameContext)(square: Square): List[Move] = List(forcedMove)
def allLegalMoves(context: GameContext): List[Move] = List(forcedMove)
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext =
context.copy(turn = context.turn.opposite, moves = context.moves :+ move)
object LowNnue extends Evaluation:
val CHECKMATE_SCORE: Int = 10_000_000
val DRAW_SCORE: Int = 0
def evaluate(context: GameContext): Int = 0
object HighClassic extends Evaluation:
val CHECKMATE_SCORE: Int = 10_000_000
val DRAW_SCORE: Int = 0
def evaluate(context: GameContext): Int = 10_000
val bot = HybridBot(
BotDifficulty.Easy,
rules = oneMoveRules,
nnueEvaluation = LowNnue,
classicalEvaluation = HighClassic,
)
val printed = Console.withOut(new java.io.ByteArrayOutputStream()) {
bot.nextMove(GameContext.initial)
}
printed should be(Some(forcedMove))
@@ -9,6 +9,16 @@ import org.scalatest.matchers.should.Matchers
class MoveOrderingTest extends AnyFunSuite with Matchers:
private val moveOrderingClass = Class.forName("de.nowchess.bot.logic.MoveOrdering$")
private val moveOrderingObject = moveOrderingClass.getField("MODULE$").get(null)
private def invokeMoveOrderingPrivate[T](methodPrefix: String, args: Seq[AnyRef]): T =
val method = moveOrderingClass.getDeclaredMethods
.find(m => (m.getName == methodPrefix || m.getName.startsWith(methodPrefix + "$")) && m.getParameterCount == args.length)
.getOrElse(throw RuntimeException(s"Method not found: $methodPrefix/${args.length}"))
method.setAccessible(true)
method.invoke(moveOrderingObject, args*).asInstanceOf[T]
test("queen capture ranks higher than rook capture"):
val board = Board(
Map(
@@ -197,3 +207,37 @@ class MoveOrderingTest extends AnyFunSuite with Matchers:
MoveOrdering.score(context, knightPromo, None) should be > 0
MoveOrdering.score(context, bishopPromo, None) should be > 0
MoveOrdering.score(context, rookPromo, None) should be > 0
test("negative SEE capture path is scored below neutral capture baseline"):
val board = Board(
Map(
Square(File.D, Rank.R4) -> Piece.WhiteQueen,
Square(File.D, Rank.R5) -> Piece.BlackPawn,
Square(File.D, Rank.R8) -> Piece.BlackRook,
),
)
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
val move = Move(Square(File.D, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true))
MoveOrdering.score(context, move, None) should be < 100_000
test("private fallback branches in MoveOrdering are covered"):
val board = Board(Map(Square(File.E, Rank.R1) -> Piece.WhiteKing, Square(File.A, Rank.R8) -> Piece.BlackKing))
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
val castle = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)
val victim = invokeMoveOrderingPrivate[Int]("victimValue", Seq[AnyRef](context, castle))
victim should be(0)
val capture = invokeMoveOrderingPrivate[Boolean]("isCapture", Seq[AnyRef](context, castle))
capture should be(false)
val see = invokeMoveOrderingPrivate[Int]("staticExchange", Seq[AnyRef](context, castle))
see should be(0)
val clear = invokeMoveOrderingPrivate[Boolean](
"pathClear",
Seq[AnyRef](board, Square(File.A, Rank.R1), Square(File.H, Rank.R1), Int.box(-1), Int.box(0)),
)
clear should be(false)
+1 -5
View File
@@ -23,11 +23,7 @@ scala {
scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
excludedPackages.set(listOf(
"de.nowchess.ui.gui",
"de.nowchess.ui.terminal",
"de.nowchess.ui.Main",
))
excludedPackages.set(listOf("de\\.nowchess\\.ui\\..*"))
}
application {