Compare commits

..

2 Commits

Author SHA1 Message Date
TeamCity 7136803c7e ci: bump version with Build-160 2026-06-30 10:21:06 +00:00
Janis faf7eb38ea fix(bot): seed search with game history, add contempt and NNUE mop-up
Build & Test (NowChessSystems) TeamCity build finished
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 <noreply@anthropic.com>
2026-06-30 12:04:27 +02:00
6 changed files with 193 additions and 8 deletions
+58
View File
@@ -1259,3 +1259,61 @@
### Reverts
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
## (2026-06-30)
### Features
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
* **analytics:** add Spark batch analytics module ([#70](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/70)) ([39f1657](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39f1657e1db6e84889af338c43be8cb5c03c3ec3))
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
* **ncs-110:** feed NNUE root-move scores into search move ordering ([#83](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/83)) ([e4fee85](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e4fee8513430093d46957970618935e99591519f))
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
* **official-bots:** activate opening book in expert bot (native-safe) ([260db25](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/260db25803ec55ce99e55782791eabdc190dfed4))
* **official-bots:** add Google Colab notebook for NNUE training (NCS-111) ([#81](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/81)) ([fa10852](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fa10852bc98451d4068ec6fb9e7a486b5e53ef5c))
* **official-bots:** consume GameOver stream for bot cleanup ([#67](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/67)) ([db9d153](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/db9d1533912f4b41c4d1ca80ccffdde5d23d6ff6))
* **official-bots:** implement king-relative (HalfKP) encoding in NNUE (NCS-109) ([#80](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/80)) ([44f376f](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/44f376f03221f086b898741436e13c93fd314dd1))
* **official-bots:** make HybridBot veto actionable and use it for expert ([1df29cf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1df29cf3a6e21af3f396b2b7a6da67d978f941ae))
* **official-bots:** park expert bot on tournament server at startup ([#75](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/75)) ([30295a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/30295a4bb95855ee8261c92278bb9ebc80ee12ee))
* **official-bots:** resolve tournament bot token from Redis and account service ([386ddc5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/386ddc5c19f8f893b16c6422aa5393b54c872e45))
* **official-bots:** standalone self-play + one-shot dataset builder for NNUE training ([1c80abd](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1c80abdb8a45814d642d43c633cde81ce7374c4f))
* **tournament:** auto-join external tournaments and publish created ones ([#77](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/77)) ([9978b7e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9978b7ea78eb658a225a461b9cd339386c0c14f3))
* **tournament:** federate tournaments across clusters with DB replication ([5b000a6](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5b000a6e5f04ea6770d1c7ab6bfdaded77a99172))
* **tournament:** seed external server registry from env var on startup ([845dc9c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/845dc9c2935c8bc1be42541dfaf31c9a861d3272))
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
### Bug Fixes
* **bot:** include quiet promotions in quiescence search ([4938560](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/49385600147021cd29f00a8eecc6be7ba8470717))
* **bot:** seed search with game history, add contempt and NNUE mop-up ([faf7eb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/faf7eb38ea7a0d3bc41ae4c2ef9a5195822f390c))
* enable official bots to connect to external tournament server ([#71](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/71)) ([688d30e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/688d30e2b10026923372be5fca3c63eaaee2de2a))
* modified training pipeline ([9f9140c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9f9140cb585345cd244a1dfee1a06e51a5f7f7a8))
* **official-bots:** configure JWT verification ([#72](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/72)) ([98c64fc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/98c64fc0d56dc542beb31c75f4b9056d91de03cd))
* **official-bots:** correct parkOn path from /api/bots to /api/account/bots ([1be9949](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1be9949c0b5c6a1db535696620d77735050d6c93))
* **official-bots:** derive tournament game color from game endpoint ([#79](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/79)) ([bfc4672](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/bfc46723e615bb9b65f7f9bba5f53877c4f079a7))
* **official-bots:** discover tournament games by polling, not just the stream ([10113fd](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/10113fd0579b614d15870798d933bc9c495d2049))
* **official-bots:** make botToken optional, fall back to env, fix 502 status ([f43d193](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f43d1930d80670d810c57b54eaa3789854fa082c))
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
* **official-bots:** park on external tournament servers using correct endpoint and token ([3188241](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/31882417377468b41bbe3ff94506aa4928024450))
* **official-bots:** play games by polling state instead of NDJSON stream ([bfb15c7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/bfb15c7299bd471d5e064a577ed10af98e2ea90a))
* **official-bots:** play only own tournament games with correct color ([4651bb7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/4651bb796f07a21bd013d9521b2dfe2e1078cebb))
* **official-bots:** prevent Colab OOM in NNUE training ([e2b4342](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e2b4342f602215b5e8de6fccafc4105525a1ddd1))
* **official-bots:** prioritize Redis token over stale env var in joinTournament ([83dd2d4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/83dd2d4335ca48eb3e5aa234a75367574276ba63))
* **official-bots:** register with tournament server directly to get correct token ([64b5d55](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/64b5d5567f110c2fe152558c7de275a1e0b30e21))
* **official-bots:** resolve per-difficulty bot token on tournament join ([fdf4c94](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fdf4c94811d086996447bb4657fac1d9bd6e5a93))
* **official-bots:** resume tournaments already joined after restart ([285b73e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/285b73efbd6dd98cec410ade9eead9881d693a8f))
* **official-bots:** stream NNUE features as sparse indices to stop host OOM ([9d65662](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9d656624d85889f55746faa5704578e248f9b088))
* **official-bots:** sync bots before token fetch on first startup after DB wipe ([b0ddb27](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0ddb274d23bca8b1b3f691ce0d643f33e0b54cd))
* **official-bots:** use ThreadLocalRandom in PolyglotBook for native image ([1b30c3b](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1b30c3be393d25712c8743d3d9057207f8bbb67c))
### Reverts
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
@@ -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)
@@ -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
@@ -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
@@ -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
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=41
MINOR=42
PATCH=0