feat: refactor AlphaBetaSearch and ClassicalBot for improved evaluation and organization

This commit is contained in:
2026-04-07 22:46:44 +02:00
parent 558f43d0f6
commit 6a9ac55b31
28 changed files with 3618 additions and 12 deletions
@@ -0,0 +1,16 @@
package de.nowchess.bot.bots.nnue
import de.nowchess.api.game.GameContext
import de.nowchess.bot.ai.Weights
object EvaluationNNUE extends Weights:
private val nnue = NNUE()
val CHECKMATE_SCORE: Int = 10_000_000
val DRAW_SCORE: Int = 0
/** Evaluate the position using NNUE neural network.
* Returns score from the perspective of context.turn (positive = good for the side to move). */
def evaluate(context: GameContext): Int =
nnue.evaluate(context)
@@ -0,0 +1,97 @@
package de.nowchess.bot.bots.nnue
import de.nowchess.api.board.{Board, Color, File, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
class NNUE:
private val l1Weights = NNUEWeights.l1_weights
private val l1Bias = NNUEWeights.l1_bias
private val l2Weights = NNUEWeights.l2_weights
private val l2Bias = NNUEWeights.l2_bias
private val l3Weights = NNUEWeights.l3_weights
private val l3Bias = NNUEWeights.l3_bias
// Pre-allocated buffers for inference
private val features = new Array[Float](768)
private val l1Output = new Array[Float](256)
private val l2Output = new Array[Float](32)
/** Convert a position to 768-dimensional binary feature vector.
* 12 piece types (white pawn to black king) × 64 squares from white's perspective. */
def positionToFeatures(board: Board, sideToMove: Color): Array[Float] =
// Zero out features array
java.util.Arrays.fill(features, 0f)
// Piece type to feature index offset: wp=0, wn=64, wb=128, wr=192, wq=256, wk=320, bp=384, bn=448, bb=512, br=576, bq=640, bk=704
val pieceToFeatureOffset = Array(
0, // White Pawn (0)
64, // White Knight (1)
128, // White Bishop (2)
192, // White Rook (3)
256, // White Queen (4)
320, // White King (5)
384, // Black Pawn (6)
448, // Black Knight (7)
512, // Black Bishop (8)
576, // Black Rook (9)
640, // Black Queen (10)
704 // Black King (11)
)
// Build features: always from white's perspective
for
fileIdx <- 0 until 8
rankIdx <- 0 until 8
do
val file = File.values(fileIdx)
val rank = Rank.values(rankIdx)
val square = Square(file, rank)
val squareNum = rankIdx * 8 + fileIdx
board.pieceAt(square).foreach { piece =>
val featureIdx = if sideToMove == Color.Black then
// Mirror square for black side-to-move
val mirroredSq = squareNum ^ 56
val offset = pieceToFeatureOffset(piece.color.ordinal * 6 + piece.pieceType.ordinal)
offset + mirroredSq
else
val offset = pieceToFeatureOffset(piece.color.ordinal * 6 + piece.pieceType.ordinal)
offset + squareNum
if featureIdx >= 0 && featureIdx < 768 then
features(featureIdx) = 1f
}
features
/** Run NNUE inference on the given position.
* Returns centipawn score from the perspective of the side-to-move.
* No allocations in the hot path (uses pre-allocated buffers). */
def evaluate(context: GameContext): Int =
val features = positionToFeatures(context.board, context.turn)
// Layer 1: Dense(768 -> 256) + ReLU
for i <- 0 until 256 do
var sum = l1Bias(i)
for j <- 0 until 768 do
sum += features(j) * l1Weights(i * 768 + j)
l1Output(i) = if sum > 0f then sum else 0f
// Layer 2: Dense(256 -> 32) + ReLU
for i <- 0 until 32 do
var sum = l2Bias(i)
for j <- 0 until 256 do
sum += l1Output(j) * l2Weights(i * 256 + j)
l2Output(i) = if sum > 0f then sum else 0f
// Layer 3: Dense(32 -> 1), no activation
var output = l3Bias(0)
for j <- 0 until 32 do
output += l2Output(j) * l3Weights(j)
// Convert from sigmoid(output) back to centipawns (output is trained as sigmoid(eval/400))
// Inverse sigmoid: eval/400 = ln(output / (1 - output))
// But for simplicity, just scale directly: output ≈ sigmoid(eval/400), so eval ≈ 400 * (output - 0.5) * 2
val cp = (output * 400f).toInt
math.max(-20000, math.min(20000, cp))
@@ -0,0 +1,25 @@
package de.nowchess.bot.bots.nnue
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.bot.bots.nnue.EvaluationNNUE
import de.nowchess.bot.logic.AlphaBetaSearch
import de.nowchess.bot.util.PolyglotBook
import de.nowchess.bot.{Bot, BotDifficulty}
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
final class NNUEBot(
difficulty: BotDifficulty,
rules: RuleSet = DefaultRules,
book: Option[PolyglotBook] = None
) extends Bot:
private val search: AlphaBetaSearch = AlphaBetaSearch(rules, weights = EvaluationNNUE)
private val TIME_BUDGET_MS = 1000L
override val name: String = s"NNUEBot(${difficulty.toString})"
override def nextMove(context: GameContext): Option[Move] =
book.flatMap(_.probe(context))
.orElse(search.bestMoveWithTime(context, TIME_BUDGET_MS))
@@ -0,0 +1,39 @@
package de.nowchess.bot.bots.nnue
object NNUEWeights:
// PLACEHOLDER: This file is generated by export_weights.py
// Run: python3 modules/bot/python/run_pipeline.sh to generate actual weights
// Layer 1: Input(768) -> Hidden(256)
val l1_weights = Array(
0f
)
// Shape: [256, 768]
val l1_bias = Array(
0f
)
// Shape: [256]
// Layer 2: Hidden(256) -> Hidden(32)
val l2_weights = Array(
0f
)
// Shape: [32, 256]
val l2_bias = Array(
0f
)
// Shape: [32]
// Layer 3: Hidden(32) -> Output(1)
val l3_weights = Array(
0f
)
// Shape: [1, 32]
val l3_bias = Array(
0f
)
// Shape: [1]
@@ -3,6 +3,7 @@ package de.nowchess.bot
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType}
import de.nowchess.bot.bots.classic.EvaluationClassic
import de.nowchess.bot.logic.AlphaBetaSearch
import de.nowchess.rules.RuleSet
import org.scalatest.funsuite.AnyFunSuite
@@ -12,7 +13,7 @@ import de.nowchess.rules.sets.DefaultRules
class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
test("bestMove on initial position returns a move"):
val search = AlphaBetaSearch(DefaultRules)
val search = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
val move = search.bestMove(GameContext.initial, maxDepth = 2)
move should not be None
@@ -20,7 +21,7 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
// Create a simple position: White king on h1, Black rook on a2
// (set up so there's only one legal move available)
// For simplicity, just test that a position with forced mate returns a move
val search = AlphaBetaSearch(DefaultRules)
val search = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
val context = GameContext.initial
val move = search.bestMove(context, maxDepth = 1)
move should not be None
@@ -38,12 +39,12 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
def isFiftyMoveRule(context: GameContext) = false
def applyMove(context: GameContext)(move: Move) = context
val search = AlphaBetaSearch(stubRules)
val search = AlphaBetaSearch(stubRules, weights = EvaluationClassic)
val move = search.bestMove(GameContext.initial, maxDepth = 2)
move should be(None)
test("transposition table is cleared at start of bestMove"):
val search = AlphaBetaSearch(DefaultRules)
val search = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
val context = GameContext.initial
// Call bestMove twice and verify both work independently
val move1 = search.bestMove(context, maxDepth = 1)
@@ -51,7 +52,7 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
move1 should be(move2)
test("quiescence captures are ordered"):
val search = AlphaBetaSearch(DefaultRules)
val search = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
// A position with multiple captures to verify quiescence orders them
val context = GameContext.initial
val move = search.bestMove(context, maxDepth = 2)
@@ -60,13 +61,13 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
test("search respects alpha-beta bounds"):
// This is implicit in the structure, but we test via behavior
val search = AlphaBetaSearch(DefaultRules)
val search = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
val context = GameContext.initial
val move = search.bestMove(context, maxDepth = 3)
move should not be None
test("iterative deepening finds a move at each depth"):
val search = AlphaBetaSearch(DefaultRules)
val search = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
val context = GameContext.initial
// Searching to depth 3 should use iterative deepening (depths 1, 2, 3)
val move = search.bestMove(context, maxDepth = 3)
@@ -85,7 +86,7 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
def isFiftyMoveRule(context: GameContext) = false
def applyMove(context: GameContext)(move: Move) = context
val search = AlphaBetaSearch(stalematRules)
val search = AlphaBetaSearch(stalematRules, weights = EvaluationClassic)
val move = search.bestMove(GameContext.initial, maxDepth = 1)
move should be(None)
@@ -101,7 +102,7 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
def isFiftyMoveRule(context: GameContext) = false
def applyMove(context: GameContext)(move: Move) = context
val search = AlphaBetaSearch(insufficientRules)
val search = AlphaBetaSearch(insufficientRules, weights = EvaluationClassic)
val move = search.bestMove(GameContext.initial, maxDepth = 1)
move should be(None)
@@ -117,7 +118,7 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
def isFiftyMoveRule(context: GameContext) = true
def applyMove(context: GameContext)(move: Move) = context
val search = AlphaBetaSearch(fiftyMoveRules)
val search = AlphaBetaSearch(fiftyMoveRules, weights = EvaluationClassic)
val move = search.bestMove(GameContext.initial, maxDepth = 1)
move should be(None)
@@ -141,7 +142,7 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
def isFiftyMoveRule(context: GameContext) = false
def applyMove(context: GameContext)(move: Move) = context
val search = AlphaBetaSearch(rulesWithCapture)
val search = AlphaBetaSearch(rulesWithCapture, weights = EvaluationClassic)
val move = search.bestMove(context, maxDepth = 1)
move should be(Some(captureMove))
@@ -158,6 +159,6 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
def isFiftyMoveRule(context: GameContext) = false
def applyMove(context: GameContext)(move: Move) = context
val search = AlphaBetaSearch(rulesQuiet)
val search = AlphaBetaSearch(rulesQuiet, weights = EvaluationClassic)
val move = search.bestMove(GameContext.initial, maxDepth = 1)
move should be(Some(quietMove)) // bestMove returns the quiet move since it's the only legal move