From faf7eb38ea7a0d3bc41ae4c2ef9a5195822f390c Mon Sep 17 00:00:00 2001 From: Janis Date: Tue, 30 Jun 2026 12:04:27 +0200 Subject: [PATCH] fix(bot): seed search with game history, add contempt and NNUE mop-up 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 --- .../bot/bots/nnue/EvaluationNNUE.scala | 4 +- .../de/nowchess/bot/bots/nnue/MopUp.scala | 60 +++++++++++++++++++ .../nowchess/bot/logic/AlphaBetaSearch.scala | 43 +++++++++++-- .../scala/de/nowchess/bot/MopUpTest.scala | 34 +++++++++++ 4 files changed, 134 insertions(+), 7 deletions(-) create mode 100644 modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/MopUp.scala create mode 100644 modules/official-bots/src/test/scala/de/nowchess/bot/MopUpTest.scala diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/EvaluationNNUE.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/EvaluationNNUE.scala index b56f9dc..e45ae3a 100644 --- a/modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/EvaluationNNUE.scala +++ b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/EvaluationNNUE.scala @@ -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) diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/MopUp.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/MopUp.scala new file mode 100644 index 0000000..c2695c3 --- /dev/null +++ b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/MopUp.scala @@ -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 diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala index b486609..3f7fefa 100644 --- a/modules/official-bots/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala +++ b/modules/official-bots/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala @@ -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 diff --git a/modules/official-bots/src/test/scala/de/nowchess/bot/MopUpTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/MopUpTest.scala new file mode 100644 index 0000000..756158b --- /dev/null +++ b/modules/official-bots/src/test/scala/de/nowchess/bot/MopUpTest.scala @@ -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