fix(bot): seed search with game history, add contempt and NNUE mop-up
Build & Test (NowChessSystems) TeamCity build finished
Build & Test (NowChessSystems) TeamCity build finished
Repetition: alpha-beta seeded the repetition map with only the root position, so search was blind to positions already reached in the real game and would happily shuffle into draws when ahead. Reconstruct the full game-history position hashes by replaying moves and seed the search state with them; treat a twofold occurrence at non-root nodes as a draw. Contempt: draws are now scored CONTEMPT (25cp) away from zero, signed by ply parity, so the bot avoids dead-equal repetitions instead of settling. Endgame: pure NNUE lacks mating knowledge and stalls KX-vs-K conversions. Add a MopUp correction (edge-driving + king-proximity) applied only in lone-king endgames with sufficient mating material; zero elsewhere so middlegame NNUE output is untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@ object EvaluationNNUE extends Evaluation:
|
||||
val DRAW_SCORE: Int = 0
|
||||
|
||||
/** Full-board evaluate — used as fallback and by non-search callers. */
|
||||
def evaluate(context: GameContext): Int = nnue.evaluate(context)
|
||||
def evaluate(context: GameContext): Int = nnue.evaluate(context) + MopUp.score(context)
|
||||
|
||||
// ── Accumulator hooks (incremental L1) ───────────────────────────────────
|
||||
|
||||
@@ -28,4 +28,4 @@ object EvaluationNNUE extends Evaluation:
|
||||
else nnue.pushAccumulator(childPly, move, parent.board, child.board)
|
||||
|
||||
override def evaluateAccumulator(ply: Int, context: GameContext, hash: Long): Int =
|
||||
nnue.evaluateAtPlyWithValidation(ply, context.turn, hash, context.board)
|
||||
nnue.evaluateAtPlyWithValidation(ply, context.turn, hash, context.board) + MopUp.score(context)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.nowchess.bot.bots.nnue
|
||||
|
||||
import de.nowchess.api.board.{Color, PieceType, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
|
||||
/** Endgame "mop-up" correction for the NNUE evaluation.
|
||||
*
|
||||
* Pure NNUE lacks explicit mating knowledge, so KX-vs-K conversions stall. When one side is reduced to a lone king and
|
||||
* the other holds sufficient mating material, this term rewards driving the bare king to the edge and walking the
|
||||
* winning king in. Returns a value from the side-to-move perspective (positive = good for side to move). Zero in any
|
||||
* position that is not a lone-king endgame, so middlegame NNUE output is untouched.
|
||||
*/
|
||||
object MopUp:
|
||||
|
||||
private val EDGE_WEIGHT = 10
|
||||
private val PROXIMITY_WEIGHT = 4
|
||||
private val MIN_WINNER_VALUE = 400
|
||||
|
||||
def score(context: GameContext): Int =
|
||||
loneKingColor(context) match
|
||||
case None => 0
|
||||
case Some(loser) =>
|
||||
val winner = loser.opposite
|
||||
if winnerValue(context, winner) < MIN_WINNER_VALUE then 0
|
||||
else mopUp(context, winner, loser) * (if context.turn == winner then 1 else -1)
|
||||
|
||||
private def mopUp(context: GameContext, winner: Color, loser: Color): Int =
|
||||
(for
|
||||
loserKing <- context.kingSquare(loser)
|
||||
winnerKing <- context.kingSquare(winner)
|
||||
yield EDGE_WEIGHT * centerDistance(loserKing) +
|
||||
PROXIMITY_WEIGHT * (14 - kingDistance(winnerKing, loserKing))).getOrElse(0)
|
||||
|
||||
private def loneKingColor(context: GameContext): Option[Color] =
|
||||
val nonKing = context.board.pieces.values.filter(_.pieceType != PieceType.King)
|
||||
val whiteHasOther = nonKing.exists(_.color == Color.White)
|
||||
val blackHasOther = nonKing.exists(_.color == Color.Black)
|
||||
if whiteHasOther == blackHasOther then None
|
||||
else if whiteHasOther then Some(Color.Black)
|
||||
else Some(Color.White)
|
||||
|
||||
private def winnerValue(context: GameContext, winner: Color): Int =
|
||||
context.board.pieces.values.foldLeft(0) { (sum, piece) =>
|
||||
if piece.color != winner then sum
|
||||
else
|
||||
sum + (piece.pieceType match
|
||||
case PieceType.Queen => 900
|
||||
case PieceType.Rook => 500
|
||||
case PieceType.Bishop => 330
|
||||
case PieceType.Knight => 320
|
||||
case _ => 0)
|
||||
}
|
||||
|
||||
private def centerDistance(sq: Square): Int =
|
||||
val fileDist = math.max(3 - sq.file.ordinal, sq.file.ordinal - 4)
|
||||
val rankDist = math.max(3 - sq.rank.ordinal, sq.rank.ordinal - 4)
|
||||
fileDist + rankDist
|
||||
|
||||
private def kingDistance(a: Square, b: Square): Int =
|
||||
(a.file.ordinal - b.file.ordinal).abs + (a.rank.ordinal - b.rank.ordinal).abs
|
||||
@@ -1,6 +1,6 @@
|
||||
package de.nowchess.bot.logic
|
||||
|
||||
import de.nowchess.api.board.PieceType
|
||||
import de.nowchess.api.board.{CastlingRights, PieceType}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.{Move, MoveType}
|
||||
import de.nowchess.bot.ai.Evaluation
|
||||
@@ -26,6 +26,7 @@ final class AlphaBetaSearch(
|
||||
private val TIME_CHECK_FREQUENCY = 1000
|
||||
private val FUTILITY_MARGIN = 100
|
||||
private val CHECK_EXTENSION = 1
|
||||
private val CONTEMPT = 25
|
||||
|
||||
private val timeStartMs = AtomicLong(0L)
|
||||
private val timeLimitMs = AtomicLong(0L)
|
||||
@@ -67,6 +68,7 @@ final class AlphaBetaSearch(
|
||||
timeLimitMs.set(Long.MaxValue / 4)
|
||||
nodeCount.set(0)
|
||||
val rootHash = ZobristHash.hash(context)
|
||||
val history = historyCounts(context)
|
||||
(1 to maxDepth)
|
||||
.foldLeft((None: Option[Move], 0)) { case ((bestSoFar, prevScore), depth) =>
|
||||
val (alpha, beta) =
|
||||
@@ -78,6 +80,7 @@ final class AlphaBetaSearch(
|
||||
beta,
|
||||
ASPIRATION_DELTA,
|
||||
rootHash,
|
||||
history,
|
||||
excludedRootMoves,
|
||||
hints,
|
||||
)
|
||||
@@ -115,6 +118,7 @@ final class AlphaBetaSearch(
|
||||
timeLimitMs.set(timeBudgetMs)
|
||||
nodeCount.set(0)
|
||||
val rootHash = ZobristHash.hash(context)
|
||||
val history = historyCounts(context)
|
||||
|
||||
@scala.annotation.tailrec
|
||||
def loop(bestSoFar: Option[Move], prevScore: Int, depth: Int, lastDepth: Int): (Option[Move], Int) =
|
||||
@@ -129,6 +133,7 @@ final class AlphaBetaSearch(
|
||||
beta,
|
||||
ASPIRATION_DELTA,
|
||||
rootHash,
|
||||
history,
|
||||
excludedRootMoves,
|
||||
hints,
|
||||
)
|
||||
@@ -154,10 +159,11 @@ final class AlphaBetaSearch(
|
||||
beta: Int,
|
||||
initialWindow: Int,
|
||||
rootHash: Long,
|
||||
history: Map[Long, Int],
|
||||
excludedRootMoves: Set[Move],
|
||||
hints: Map[Move, Int],
|
||||
): (Int, Option[Move]) =
|
||||
val state = SearchState(rootHash, Map(rootHash -> 1))
|
||||
val state = SearchState(rootHash, history)
|
||||
|
||||
@scala.annotation.tailrec
|
||||
def loop(currentAlpha: Int, currentBeta: Int, delta: Int, attempt: Int): (Int, Option[Move]) =
|
||||
@@ -173,6 +179,32 @@ final class AlphaBetaSearch(
|
||||
|
||||
loop(alpha, beta, initialWindow, 0)
|
||||
|
||||
private def drawScore(ply: Int): Int =
|
||||
if ply % 2 == 0 then weights.DRAW_SCORE - CONTEMPT else weights.DRAW_SCORE + CONTEMPT
|
||||
|
||||
private def historyCounts(context: GameContext): Map[Long, Int] =
|
||||
val initialTurn = if context.moves.size % 2 == 0 then context.turn else context.turn.opposite
|
||||
val root = GameContext(
|
||||
board = context.initialBoard,
|
||||
turn = initialTurn,
|
||||
castlingRights = CastlingRights.Initial,
|
||||
enPassantSquare = None,
|
||||
halfMoveClock = 0,
|
||||
moves = List.empty,
|
||||
)
|
||||
val rootHash = ZobristHash.hash(root)
|
||||
context.moves
|
||||
.foldLeft((root, rootHash, List(rootHash))) { case ((cur, curHash, acc), move) =>
|
||||
val next = rules.applyMove(cur)(move)
|
||||
val nextHash = ZobristHash.nextHash(cur, curHash, move, next)
|
||||
(next, nextHash, nextHash :: acc)
|
||||
}
|
||||
._3
|
||||
.groupBy(identity)
|
||||
.view
|
||||
.mapValues(_.size)
|
||||
.toMap
|
||||
|
||||
private def hasNonPawnMaterial(context: GameContext): Boolean =
|
||||
context.board.pieces.values.exists { piece =>
|
||||
piece.color == context.turn &&
|
||||
@@ -226,7 +258,8 @@ final class AlphaBetaSearch(
|
||||
): 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 if params.ply > 0 && params.state.repetitions.getOrElse(params.state.hash, 0) >= 2 then
|
||||
Some((drawScore(params.ply), None))
|
||||
else ttCutoff(params)
|
||||
|
||||
private def ttCutoff(params: SearchParams): Option[(Int, Option[Move])] =
|
||||
@@ -248,12 +281,12 @@ final class AlphaBetaSearch(
|
||||
if legalMoves.isEmpty then
|
||||
Some(
|
||||
(
|
||||
if rules.isCheckmate(params.context) then -(weights.CHECKMATE_SCORE - params.ply) else weights.DRAW_SCORE,
|
||||
if rules.isCheckmate(params.context) then -(weights.CHECKMATE_SCORE - params.ply) else drawScore(params.ply),
|
||||
None,
|
||||
),
|
||||
)
|
||||
else if rules.isInsufficientMaterial(params.context) || rules.isFiftyMoveRule(params.context) then
|
||||
Some((weights.DRAW_SCORE, None))
|
||||
Some((drawScore(params.ply), 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
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package de.nowchess.bot
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.bot.bots.nnue.MopUp
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class MopUpTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def ctx(turn: Color, pieces: (Square, Piece)*): GameContext =
|
||||
GameContext.initial.withBoard(Board(pieces.toMap)).withTurn(turn)
|
||||
|
||||
private val wk = Square(File.E, Rank.R1) -> Piece.WhiteKing
|
||||
private val wq = Square(File.D, Rank.R1) -> Piece.WhiteQueen
|
||||
private val bkCorner = Square(File.H, Rank.R8) -> Piece.BlackKing
|
||||
private val bkCenter = Square(File.D, Rank.R4) -> Piece.BlackKing
|
||||
|
||||
test("zero in a balanced middlegame-like position (both sides have material)"):
|
||||
MopUp.score(ctx(Color.White, wk, wq, bkCorner, Square(File.A, Rank.R8) -> Piece.BlackQueen)) should be(0)
|
||||
|
||||
test("zero when winner lacks mating material (lone king vs king)"):
|
||||
MopUp.score(ctx(Color.White, wk, bkCorner)) should be(0)
|
||||
|
||||
test("positive for the winning side to move in KQ vs K"):
|
||||
MopUp.score(ctx(Color.White, wk, wq, bkCorner)) should be > 0
|
||||
|
||||
test("negative for the bare-king side to move in KQ vs K"):
|
||||
MopUp.score(ctx(Color.Black, wk, wq, bkCorner)) should be < 0
|
||||
|
||||
test("cornered bare king scores higher than centralized bare king"):
|
||||
val cornered = MopUp.score(ctx(Color.White, wk, wq, bkCorner))
|
||||
val centralized = MopUp.score(ctx(Color.White, wk, wq, bkCenter))
|
||||
cornered should be > centralized
|
||||
Reference in New Issue
Block a user