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