diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/BotController.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/BotController.scala index 3e21103..e8df5b6 100644 --- a/modules/official-bots/src/main/scala/de/nowchess/bot/BotController.scala +++ b/modules/official-bots/src/main/scala/de/nowchess/bot/BotController.scala @@ -1,14 +1,17 @@ package de.nowchess.bot -import de.nowchess.bot.bots.ClassicalBot +import de.nowchess.bot.bots.{ClassicalBot, HybridBot} import jakarta.enterprise.context.ApplicationScoped +import org.jboss.logging.Logger object BotController: + private val log = Logger.getLogger(classOf[BotController]) + private val bots: Map[String, Bot] = Map( "easy" -> ClassicalBot(BotDifficulty.Easy), "medium" -> ClassicalBot(BotDifficulty.Medium), "hard" -> ClassicalBot(BotDifficulty.Hard), - "expert" -> ClassicalBot(BotDifficulty.Expert), + "expert" -> HybridBot(BotDifficulty.Expert, vetoReporter = log.debug(_)), ) def getBot(name: String): Option[Bot] = bots.get(name.toLowerCase) diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/bots/HybridBot.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/HybridBot.scala index 2884e88..6af9d72 100644 --- a/modules/official-bots/src/main/scala/de/nowchess/bot/bots/HybridBot.scala +++ b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/HybridBot.scala @@ -24,16 +24,24 @@ object HybridBot: val search = AlphaBetaSearch(rules, TranspositionTable(), classicalEvaluation) context => val blockedMoves = BotMoveRepetition.blockedMoves(context) + + def nnueScore(move: Move): Int = nnueEvaluation.evaluate(rules.applyMove(context)(move)) + def classicalScore(move: Move): Int = classicalEvaluation.evaluate(rules.applyMove(context)(move)) + + def refine(move: Move): Move = + val moveNnue = nnueScore(move) + if (classicalScore(move) - moveNnue).abs <= Config.VETO_THRESHOLD then move + else + search + .bestMoveWithTime(context, Config.TIME_LIMIT_MS, blockedMoves + move) + .filterNot(blockedMoves.contains) + .filter(alt => nnueScore(alt) < moveNnue) + .map { alt => + vetoReporter(f"[Veto] ${move.from}->${move.to} replaced by ${alt.from}->${alt.to} — NNUE prefers it") + alt + } + .getOrElse(move) + book.flatMap(_.probe(context)).filterNot(blockedMoves.contains).orElse { - search.bestMoveWithTime(context, Config.TIME_LIMIT_MS, blockedMoves).map { move => - val next = rules.applyMove(context)(move) - val staticNnue = nnueEvaluation.evaluate(next) - val classical = classicalEvaluation.evaluate(next) - val diff = (classical - staticNnue).abs - if diff > Config.VETO_THRESHOLD then - vetoReporter( - f"[Veto] ${move.from}->${move.to}: nnue=$staticNnue classical=$classical diff=$diff — flagged but trusted (deep search)", - ) - move - } + search.bestMoveWithTime(context, Config.TIME_LIMIT_MS, blockedMoves).map(refine) } diff --git a/modules/official-bots/src/test/scala/de/nowchess/bot/HybridBotTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/HybridBotTest.scala index 900a85f..4dab771 100644 --- a/modules/official-bots/src/test/scala/de/nowchess/bot/HybridBotTest.scala +++ b/modules/official-bots/src/test/scala/de/nowchess/bot/HybridBotTest.scala @@ -80,76 +80,58 @@ class HybridBotTest extends AnyFunSuite with Matchers: bot.apply(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) + // Classical search picks mateMove (delivers mate); NNUE distrusts it and prefers altMove. + private val mateMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()) + private val altMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal()) - object LowNnue extends Evaluation: - val CHECKMATE_SCORE: Int = 10_000_000 - val DRAW_SCORE: Int = 0 - def evaluate(context: GameContext): Int = 0 + private def vetoRules: RuleSet = new RuleSet: + private def fresh(ctx: GameContext): Boolean = ctx.moves.isEmpty + def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil + def legalMoves(context: GameContext)(square: Square): List[Move] = Nil + def allLegalMoves(context: GameContext): List[Move] = + if fresh(context) then List(mateMove, altMove) else Nil + def isCheck(context: GameContext): Boolean = false + def isCheckmate(context: GameContext): Boolean = context.moves.lastOption.contains(mateMove) + def isStalemate(context: GameContext): Boolean = context.moves.lastOption.contains(altMove) + 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 HighClassic extends Evaluation: - val CHECKMATE_SCORE: Int = 10_000_000 - val DRAW_SCORE: Int = 0 - def evaluate(context: GameContext): Int = 10_000 + // NNUE rates the mate move worse for us (higher = better for opponent) than the alternative. + private object DistrustfulNnue extends Evaluation: + val CHECKMATE_SCORE: Int = 10_000_000 + val DRAW_SCORE: Int = 0 + def evaluate(context: GameContext): Int = if context.moves.lastOption.contains(mateMove) then 5_000 else 0 + private object HighClassic extends Evaluation: + val CHECKMATE_SCORE: Int = 10_000_000 + val DRAW_SCORE: Int = 0 + def evaluate(context: GameContext): Int = if context.moves.lastOption.contains(mateMove) then 10_000 else 0 + + test("HybridBot switches to NNUE's preferred move and reports veto when evals diverge"): val reported = AtomicBoolean(false) val bot = HybridBot( BotDifficulty.Easy, - rules = oneMoveRules, - nnueEvaluation = LowNnue, + rules = vetoRules, + nnueEvaluation = DistrustfulNnue, classicalEvaluation = HighClassic, vetoReporter = _ => reported.set(true), ) - bot.apply(GameContext.initial) should be(Some(forcedMove)) + bot.apply(GameContext.initial) should be(Some(altMove)) reported.get 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, + rules = vetoRules, + nnueEvaluation = DistrustfulNnue, classicalEvaluation = HighClassic, ) val printed = Console.withOut(new java.io.ByteArrayOutputStream()) { bot.apply(GameContext.initial) } - printed should be(Some(forcedMove)) + printed should be(Some(altMove))