feat(official-bots): make HybridBot veto actionable and use it for expert
Build & Test (NowChessSystems) TeamCity build finished
Build & Test (NowChessSystems) TeamCity build finished
When classical and NNUE evals diverge above the veto threshold, HybridBot now re-searches excluding the suspect move and switches to NNUE's preferred alternative instead of merely logging. BotController maps the expert bot to HybridBot so tournament auto-join uses it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,17 @@
|
|||||||
package de.nowchess.bot
|
package de.nowchess.bot
|
||||||
|
|
||||||
import de.nowchess.bot.bots.ClassicalBot
|
import de.nowchess.bot.bots.{ClassicalBot, HybridBot}
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import org.jboss.logging.Logger
|
||||||
|
|
||||||
object BotController:
|
object BotController:
|
||||||
|
private val log = Logger.getLogger(classOf[BotController])
|
||||||
|
|
||||||
private val bots: Map[String, Bot] = Map(
|
private val bots: Map[String, Bot] = Map(
|
||||||
"easy" -> ClassicalBot(BotDifficulty.Easy),
|
"easy" -> ClassicalBot(BotDifficulty.Easy),
|
||||||
"medium" -> ClassicalBot(BotDifficulty.Medium),
|
"medium" -> ClassicalBot(BotDifficulty.Medium),
|
||||||
"hard" -> ClassicalBot(BotDifficulty.Hard),
|
"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)
|
def getBot(name: String): Option[Bot] = bots.get(name.toLowerCase)
|
||||||
|
|||||||
@@ -24,16 +24,24 @@ object HybridBot:
|
|||||||
val search = AlphaBetaSearch(rules, TranspositionTable(), classicalEvaluation)
|
val search = AlphaBetaSearch(rules, TranspositionTable(), classicalEvaluation)
|
||||||
context =>
|
context =>
|
||||||
val blockedMoves = BotMoveRepetition.blockedMoves(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 {
|
book.flatMap(_.probe(context)).filterNot(blockedMoves.contains).orElse {
|
||||||
search.bestMoveWithTime(context, Config.TIME_LIMIT_MS, blockedMoves).map { move =>
|
search.bestMoveWithTime(context, Config.TIME_LIMIT_MS, blockedMoves).map(refine)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())))
|
bot.apply(ctx) should be(Some(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())))
|
||||||
finally Files.deleteIfExists(tempFile)
|
finally Files.deleteIfExists(tempFile)
|
||||||
|
|
||||||
test("HybridBot reports veto when classical and NNUE differ above threshold"):
|
// Classical search picks mateMove (delivers mate); NNUE distrusts it and prefers altMove.
|
||||||
val forcedMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal())
|
private val mateMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())
|
||||||
val oneMoveRules = new RuleSet:
|
private val altMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal())
|
||||||
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:
|
private def vetoRules: RuleSet = new RuleSet:
|
||||||
val CHECKMATE_SCORE: Int = 10_000_000
|
private def fresh(ctx: GameContext): Boolean = ctx.moves.isEmpty
|
||||||
val DRAW_SCORE: Int = 0
|
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
|
||||||
def evaluate(context: GameContext): Int = 0
|
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:
|
// NNUE rates the mate move worse for us (higher = better for opponent) than the alternative.
|
||||||
val CHECKMATE_SCORE: Int = 10_000_000
|
private object DistrustfulNnue extends Evaluation:
|
||||||
val DRAW_SCORE: Int = 0
|
val CHECKMATE_SCORE: Int = 10_000_000
|
||||||
def evaluate(context: GameContext): Int = 10_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 reported = AtomicBoolean(false)
|
||||||
val bot = HybridBot(
|
val bot = HybridBot(
|
||||||
BotDifficulty.Easy,
|
BotDifficulty.Easy,
|
||||||
rules = oneMoveRules,
|
rules = vetoRules,
|
||||||
nnueEvaluation = LowNnue,
|
nnueEvaluation = DistrustfulNnue,
|
||||||
classicalEvaluation = HighClassic,
|
classicalEvaluation = HighClassic,
|
||||||
vetoReporter = _ => reported.set(true),
|
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)
|
reported.get should be(true)
|
||||||
|
|
||||||
test("HybridBot default veto reporter prints when threshold is exceeded"):
|
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(
|
val bot = HybridBot(
|
||||||
BotDifficulty.Easy,
|
BotDifficulty.Easy,
|
||||||
rules = oneMoveRules,
|
rules = vetoRules,
|
||||||
nnueEvaluation = LowNnue,
|
nnueEvaluation = DistrustfulNnue,
|
||||||
classicalEvaluation = HighClassic,
|
classicalEvaluation = HighClassic,
|
||||||
)
|
)
|
||||||
|
|
||||||
val printed = Console.withOut(new java.io.ByteArrayOutputStream()) {
|
val printed = Console.withOut(new java.io.ByteArrayOutputStream()) {
|
||||||
bot.apply(GameContext.initial)
|
bot.apply(GameContext.initial)
|
||||||
}
|
}
|
||||||
printed should be(Some(forcedMove))
|
printed should be(Some(altMove))
|
||||||
|
|||||||
Reference in New Issue
Block a user