Compare commits

...

12 Commits

Author SHA1 Message Date
TeamCity eeae4f01b4 ci: bump version with Build-161 2026-06-30 20:29:13 +00:00
Janis 45b5719d63 feat(bot): clock-aware time management and stream-driven tournament play
Build & Test (NowChessSystems) TeamCity build finished
Tournament bots flagged in 5+3 classical: budgets were fixed and
clock-blind, HybridBot's veto re-search double-spent (up to 4s/move),
and the game loop polled every 1s, burning our clock waiting on the
opponent.

- Bot is now a trait taking a TimeControl (remaining + increment);
  apply(ctx) defaults to Unlimited so local/self-play/tests keep their
  fixed budgets.
- TimeControl.budget derives a per-move budget from the real clock with
  an overhead reserve, a panic mode under 20s, and a hard ceiling, so a
  bot can no longer flag from thinking.
- HybridBot splits one budget across main (0.7) and veto (0.3) searches
  instead of running two full searches.
- TournamentBotGamePlayer reads the server clock (seconds -> ms) and
  plays stream-driven via GET /game/{id}/stream (NDJSON, heartbeat-kept),
  so the opponent's move arrives instantly; polling stays as a fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:12:55 +02:00
Janis b2683a7f5a feat(bot): implement bot-vs-bot harness for NNUE evaluation 2026-06-30 22:12:53 +02:00
Janis 3437dab49b feat(bot): add Lazy SMP parallel search for the NNUE bot
Adds optional multithreaded search behind a thread count that defaults to
1, so the live bot's play is unchanged until explicitly configured.

- ParallelSearch runs N AlphaBetaSearch workers over one shared,
  already-lock-protected TranspositionTable. Each worker has its own NNUE
  evaluator (independent accumulator) and ordering state; helpers only
  deepen the shared TT, the main worker's move is returned.
- AlphaBetaSearch gains bestMoveWithTimeSharedTt: the coordinator clears
  the shared TT once before launching workers, so helpers must not clear.
- EvaluationNNUE.freshEvaluator builds independent evaluators sharing the
  immutable weights (one per thread); the singleton still backs the
  default single-instance path.
- NNUEBot uses ParallelSearch with NNUE_SEARCH_THREADS (default 1).

numThreads <= 1 takes the single-worker clearing path, identical to the
previous sequential search. Strength can be validated by self-play
(threads N vs 1) before promoting the default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:12:52 +02:00
Janis b72e8ec017 refactor(bot): split NNUE into shared weights and per-thread evaluator
Prerequisite for parallel search. NNUE held all state on one instance:
the immutable transposed L1 weight matrix alongside the mutable
accumulator stack, scratch buffers and eval cache. That made concurrent
eval calls corrupt shared buffers.

Extract the read-only parameters into NNUEWeights (heavy to build, safe
to share). NNUE now owns only per-instance mutable buffers and references
the shared weights, so many evaluators can run in parallel over one weight
matrix without duplicating it. Single-instance behaviour is unchanged —
EvaluationNNUE still uses one evaluator, so play is identical.

Also applies scalafmt alignment to the MopUp files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:12:50 +02:00
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
TeamCity 344bed6935 ci: bump version with Build-159 2026-06-29 17:34:14 +00:00
Janis Eccarius 4938560014 fix(bot): include quiet promotions in quiescence search
Build & Test (NowChessSystems) TeamCity build finished
Quiescence tactical filter only flagged capture-promotions, so a quiet
queening on an empty back-rank square was treated as non-tactical and
skipped at the search horizon. A bot could therefore miss a winning
promotion sitting exactly at the horizon and play another move. All bots
(Classical/NNUE/Hybrid) share AlphaBetaSearch and were affected.

Treat every promotion as tactical so quiescence always expands it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:18:31 +02:00
TeamCity 4a397eed7f ci: bump version with Build-158 2026-06-24 20:41:25 +00:00
Janis Eccarius 9d656624d8 fix(official-bots): stream NNUE features as sparse indices to stop host OOM
Build & Test (NowChessSystems) TeamCity build finished
Densifying the 98304-dim HalfKP vector per item filled host RAM and crashed the
Colab runtime even at small batch sizes. The dataset now yields only the ~64
active feature indices; a custom collate carries (row, col) pairs and the
training loop scatters them into a dense [B, INPUT_SIZE] tensor on the GPU. Host
RAM stays tiny; GPU holds one dense batch transiently.

- NNUEDataset.__getitem__ returns indices via new fen_to_indices.
- fen_to_features now derives from fen_to_indices (kept for external callers).
- _collate_sparse builds row/col index batches; loaders use it.
- train/val loops scatter to a GPU dense batch; loss weighting uses batch size.
- Notebook: BATCH_SIZE 4096 -> 8192 (host no longer the limit; GPU is).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:28:53 +02:00
Janis Eccarius e2b4342f60 fix(official-bots): prevent Colab OOM in NNUE training
Build & Test (NowChessSystems) TeamCity build finished
Dense 98304-dim HalfKP features at batch_size=16384 cost ~6.4 GB/batch on the
host; with 8 hardcoded DataLoader workers and prefetch this OOM-killed the Colab
runtime.

- train.py: adaptive DataLoader workers (min(4, cpu_count), Colab free tier = 2),
  overridable via NNUE_LOADER_WORKERS; persistent_workers only when > 0.
- NNUETraining.ipynb: lower BATCH_SIZE 16384 -> 4096 with a memory-cost note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:18:18 +02:00
20 changed files with 961 additions and 130 deletions
+232
View File
@@ -1146,3 +1146,235 @@
### Reverts
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
## (2026-06-24)
### 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
* 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))
## (2026-06-29)
### 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))
* 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))
## (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))
## (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))
* **bot:** add Lazy SMP parallel search for the NNUE bot ([3437dab](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3437dab49b2cc3f7b7e726febb07ad759e878079))
* **bot:** clock-aware time management and stream-driven tournament play ([45b5719](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/45b5719d630c9480398f51510cc598d2d6ce39db))
* **bot:** implement bot-vs-bot harness for NNUE evaluation ([b2683a7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b2683a7f5ab6f3e41883736ac5eeaaee2e1ea5c1))
* **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))
@@ -92,28 +92,7 @@
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from train import train_nnue, burst_train, DEFAULT_HIDDEN_SIZES\n",
"\n",
"WEIGHTS_DIR = Path(DRIVE_ROOT) / 'weights'\n",
"WEIGHTS_DIR.mkdir(parents=True, exist_ok=True)\n",
"OUTPUT_FILE = str(WEIGHTS_DIR / 'nnue_weights.pt')\n",
"\n",
"# ── Training hyperparameters ──────────────────────────────────────────────────\n",
"HIDDEN_SIZES = DEFAULT_HIDDEN_SIZES # [1536, 1024, 512, 256]\n",
"BATCH_SIZE = 16384\n",
"EPOCHS = 100\n",
"EARLY_STOPPING = 10 # None to disable\n",
"SUBSAMPLE_RATIO = 1.0\n",
"\n",
"# Resume from latest checkpoint if one exists\n",
"checkpoints = sorted(WEIGHTS_DIR.glob('nnue_weights_v*.pt'))\n",
"CHECKPOINT = str(checkpoints[-1]) if checkpoints else None\n",
"if CHECKPOINT:\n",
" print(f'Resuming from checkpoint: {CHECKPOINT}')\n",
"else:\n",
" print('Starting training from scratch.')"
],
"source": "from train import train_nnue, burst_train, DEFAULT_HIDDEN_SIZES\n\nWEIGHTS_DIR = Path(DRIVE_ROOT) / 'weights'\nWEIGHTS_DIR.mkdir(parents=True, exist_ok=True)\nOUTPUT_FILE = str(WEIGHTS_DIR / 'nnue_weights.pt')\n\n# ── Training hyperparameters ──────────────────────────────────────────────────\nHIDDEN_SIZES = DEFAULT_HIDDEN_SIZES\n# Features are streamed as sparse indices and densified on the GPU per batch, so\n# host RAM is no longer the limit — GPU memory is. A dense batch is\n# batch_size * 98304 * 4 bytes on the GPU (~3.2 GB at 8192 on a 16 GB T4).\nBATCH_SIZE = 8192\nEPOCHS = 100\nEARLY_STOPPING = 10 # None to disable\nSUBSAMPLE_RATIO = 1.0\n\n# Resume from latest checkpoint if one exists\ncheckpoints = sorted(WEIGHTS_DIR.glob('nnue_weights_v*.pt'))\nCHECKPOINT = str(checkpoints[-1]) if checkpoints else None\nif CHECKPOINT:\n print(f'Resuming from checkpoint: {CHECKPOINT}')\nelse:\n print('Starting training from scratch.')",
"id": "train-config"
},
{
+54 -21
View File
@@ -13,6 +13,11 @@ import chess
from datetime import datetime, timedelta
import re
import numpy as np
import os
# DataLoader workers: cap to the machine's CPUs (Colab free tier = 2). Too many
# workers each fork the dataset and OOM-kill the runtime.
LOADER_WORKERS = int(os.environ.get("NNUE_LOADER_WORKERS", min(4, os.cpu_count() or 2)))
def _shard_files(data_file):
@@ -77,9 +82,12 @@ class NNUEDataset(Dataset):
def __getitem__(self, idx):
fen = self.positions[idx]
eval_val = self.evals[idx]
features = fen_to_features(fen)
# Return only the active feature indices (~64), not a dense 98304-dim vector.
# The training loop scatters these into a dense batch on the GPU, keeping host
# RAM tiny. Densifying per-item here OOM-kills the runtime.
indices = fen_to_indices(fen)
# Board is flipped for Black-to-move in fen_to_features; negate eval
# Board is flipped for Black-to-move in fen_to_indices; negate eval
# so the label still means "good for the side shown as White after flip"
if ' b ' in fen:
eval_val = -eval_val
@@ -90,7 +98,7 @@ class NNUEDataset(Dataset):
else:
target = torch.sigmoid(torch.tensor(eval_val / 400.0, dtype=torch.float32))
return features, target
return indices, target
# King-relative (HalfKP) encoding: two perspectives, one per side's king.
# Each piece is encoded as: kingSq * 768 + pieceIdx * 64 + sq
@@ -105,14 +113,14 @@ _PIECE_TO_IDX = {
}
def fen_to_features(fen):
"""Convert FEN to 98304-dim king-relative (HalfKP) feature vector.
def fen_to_indices(fen):
"""Active king-relative (HalfKP) feature indices for a FEN (~64 entries).
For Black-to-move positions the board is mirrored (ranks flipped, colours
swapped) so the network always sees the position from the side-to-move's
perspective. The caller is responsible for negating the eval label to match.
perspective. The caller is responsible for negating the eval label to match.
"""
features = torch.zeros(INPUT_SIZE, dtype=torch.float32)
indices = []
try:
board = chess.Board(fen)
# Perspective flip: present all positions as if White is to move
@@ -121,20 +129,41 @@ def fen_to_features(fen):
wk = board.king(chess.WHITE)
bk = board.king(chess.BLACK)
if wk is None or bk is None:
return features
return torch.zeros(0, dtype=torch.long)
for sq in chess.SQUARES:
piece = board.piece_at(sq)
if piece is None:
continue
pidx = _PIECE_TO_IDX[piece.symbol()]
# White-king perspective (indices 0 .. _HALF_SIZE-1)
features[wk * 768 + pidx * 64 + sq] = 1.0
indices.append(wk * 768 + pidx * 64 + sq)
# Black-king perspective (indices _HALF_SIZE .. INPUT_SIZE-1)
features[_HALF_SIZE + bk * 768 + pidx * 64 + sq] = 1.0
indices.append(_HALF_SIZE + bk * 768 + pidx * 64 + sq)
except Exception:
pass
return torch.zeros(0, dtype=torch.long)
return torch.tensor(indices, dtype=torch.long)
def fen_to_features(fen):
"""Dense 98304-dim HalfKP vector. Kept for external callers; training uses the
sparse indices + GPU scatter path instead (see _collate_sparse)."""
features = torch.zeros(INPUT_SIZE, dtype=torch.float32)
features[fen_to_indices(fen)] = 1.0
return features
def _collate_sparse(batch):
"""Collate (indices, target) items into (row_idx, col_idx, batch_size), targets.
Row/col index pairs address the active features of a dense [B, INPUT_SIZE] tensor
that the training loop allocates on the GPU — so the host only ever holds the
sparse indices, never a dense batch."""
idx_list, targets = zip(*batch)
rows = torch.cat([torch.full((idx.numel(),), i, dtype=torch.long)
for i, idx in enumerate(idx_list)])
cols = torch.cat(idx_list)
return (rows, cols, len(idx_list)), torch.stack(targets)
# Smaller hidden layers are appropriate: the L1 input is very sparse (~64 active
# features out of 98304) so the L1 itself is cheap to update incrementally; the
# larger capacity comes from the wider perspective encoding, not deeper layers.
@@ -256,17 +285,19 @@ def _setup_training(data_file, batch_size, subsample_ratio):
train_dataset,
batch_size=batch_size,
sampler=train_sampler,
num_workers=8,
num_workers=LOADER_WORKERS,
pin_memory=True,
persistent_workers=True
persistent_workers=LOADER_WORKERS > 0,
collate_fn=_collate_sparse,
)
val_loader = DataLoader(
val_dataset,
batch_size=batch_size,
shuffle=False,
num_workers=8,
num_workers=LOADER_WORKERS,
pin_memory=True,
persistent_workers=True
persistent_workers=LOADER_WORKERS > 0,
collate_fn=_collate_sparse,
)
return device, dataset, train_dataset, val_dataset, train_loader, val_loader, num_positions
@@ -304,8 +335,9 @@ def _run_training_season(
model.train()
train_loss = 0.0
with tqdm(total=len(train_loader), desc=f"Epoch {epoch_display}/{total_epochs} - Train") as pbar:
for batch_features, batch_targets in train_loader:
batch_features = batch_features.to(device)
for (rows, cols, bsz), batch_targets in train_loader:
batch_features = torch.zeros(bsz, INPUT_SIZE, device=device)
batch_features[rows.to(device), cols.to(device)] = 1.0
batch_targets = batch_targets.to(device).unsqueeze(1)
optimizer.zero_grad()
@@ -318,7 +350,7 @@ def _run_training_season(
scaler.step(optimizer)
scaler.update()
train_loss += loss.item() * batch_features.size(0)
train_loss += loss.item() * bsz
pbar.update(1)
train_loss /= len(train_dataset)
@@ -328,14 +360,15 @@ def _run_training_season(
val_loss = 0.0
with torch.no_grad():
with tqdm(total=len(val_loader), desc=f"Epoch {epoch_display}/{total_epochs} - Val") as pbar:
for batch_features, batch_targets in val_loader:
batch_features = batch_features.to(device)
for (rows, cols, bsz), batch_targets in val_loader:
batch_features = torch.zeros(bsz, INPUT_SIZE, device=device)
batch_features[rows.to(device), cols.to(device)] = 1.0
batch_targets = batch_targets.to(device).unsqueeze(1)
with torch.amp.autocast('cuda' if torch.cuda.is_available() else 'cpu'):
outputs = model(batch_features)
loss = criterion(outputs, batch_targets)
val_loss += loss.item() * batch_features.size(0)
val_loss += loss.item() * bsz
pbar.update(1)
val_loss /= len(val_dataset)
@@ -3,4 +3,33 @@ package de.nowchess.bot
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
type Bot = GameContext => Option[Move]
/** Remaining wall-clock for the side to move and the Fischer increment, both in milliseconds. [[TimeControl.Unlimited]]
* is the sentinel for callers without a real clock (local play, self-play, tests): bots then fall back to their own
* fixed budgets.
*/
final case class TimeControl(remainingMs: Long, incrementMs: Long):
def isClocked: Boolean = remainingMs >= 0L
def budgetMs: Long = TimeControl.budget(remainingMs, incrementMs)
object TimeControl:
val Unlimited: TimeControl = TimeControl(-1L, 0L)
private val OverheadMs = 1500L
private val PanicMs = 20000L
private val MaxBudget = 8000L
private val PanicCap = 2500L
private val FloorMs = 50L
def budget(remainingMs: Long, incrementMs: Long): Long =
val usable = math.max(0L, remainingMs - OverheadMs)
if usable <= 0L then FloorMs
else if remainingMs < PanicMs then clamp(usable / 15 + incrementMs / 2, PanicCap)
else clamp(usable / 30 + incrementMs * 4 / 5, MaxBudget)
private def clamp(value: Long, ceiling: Long): Long =
math.max(FloorMs, math.min(value, ceiling))
trait Bot:
def move(context: GameContext, time: TimeControl): Option[Move]
def apply(context: GameContext): Option[Move] = move(context, TimeControl.Unlimited)
def apply(context: GameContext, time: TimeControl): Option[Move] = move(context, time)
@@ -1,25 +1,28 @@
package de.nowchess.bot.bots
import de.nowchess.bot.Bot
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.api.rules.RuleSet
import de.nowchess.bot.bots.classic.EvaluationClassic
import de.nowchess.bot.logic.AlphaBetaSearch
import de.nowchess.bot.util.PolyglotBook
import de.nowchess.bot.{BotDifficulty, BotMoveRepetition}
import de.nowchess.bot.{Bot, BotDifficulty, BotMoveRepetition, TimeControl}
import de.nowchess.rules.sets.DefaultRules
object ClassicalBot:
private val defaultBudgetMs = 1000L
def apply(
difficulty: BotDifficulty,
rules: RuleSet = DefaultRules,
book: Option[PolyglotBook] = None,
): Bot =
val search = AlphaBetaSearch(rules, weights = EvaluationClassic)
val timeBudgetMs = 1000L
context =>
val blockedMoves = BotMoveRepetition.blockedMoves(context)
book
.flatMap(_.probe(context))
.filterNot(blockedMoves.contains)
.orElse(search.bestMoveWithTime(context, timeBudgetMs, blockedMoves))
val search = AlphaBetaSearch(rules, weights = EvaluationClassic)
new Bot:
def move(context: GameContext, time: TimeControl): Option[Move] =
val budget = if time.isClocked then time.budgetMs else defaultBudgetMs
val blockedMoves = BotMoveRepetition.blockedMoves(context)
book
.flatMap(_.probe(context))
.filterNot(blockedMoves.contains)
.orElse(search.bestMoveWithTime(context, budget, blockedMoves))
@@ -7,12 +7,20 @@ import de.nowchess.api.rules.RuleSet
import de.nowchess.bot.ai.Evaluation
import de.nowchess.bot.bots.classic.EvaluationClassic
import de.nowchess.bot.bots.nnue.EvaluationNNUE
import de.nowchess.bot.logic.{AlphaBetaSearch, TranspositionTable}
import de.nowchess.bot.logic.{ParallelSearch, TranspositionTable}
import de.nowchess.bot.util.PolyglotBook
import de.nowchess.bot.{BotDifficulty, BotMoveRepetition, Config}
import de.nowchess.bot.{Bot, BotDifficulty, BotMoveRepetition, Config, TimeControl}
import de.nowchess.rules.sets.DefaultRules
object HybridBot:
private def defaultThreads: Int =
sys.env.get("NNUE_SEARCH_THREADS").flatMap(_.toIntOption).filter(_ >= 1).getOrElse(1)
// The veto re-search must share the move's budget, not double it: give the main search the bulk and
// reserve a slice for the at-most-one veto re-search so a vetoed move never costs two full budgets.
private val MainSearchShare = 0.7
def apply(
difficulty: BotDifficulty,
rules: RuleSet = DefaultRules,
@@ -20,28 +28,34 @@ object HybridBot:
nnueEvaluation: Evaluation = EvaluationNNUE,
classicalEvaluation: Evaluation = EvaluationClassic,
vetoReporter: String => Unit = println(_),
searchThreads: Int = defaultThreads,
): Bot =
val search = AlphaBetaSearch(rules, TranspositionTable(), classicalEvaluation)
context =>
val blockedMoves = BotMoveRepetition.blockedMoves(context)
// Use ParallelSearch to enable multi-threaded (SMP) search similar to NNUEBot
val search = ParallelSearch(rules, TranspositionTable(), () => classicalEvaluation, searchThreads)
new Bot:
def move(context: GameContext, time: TimeControl): Option[Move] =
val totalBudget = if time.isClocked then time.budgetMs else Config.TIME_LIMIT_MS
val mainBudget = math.max(1L, (totalBudget * MainSearchShare).toLong)
val vetoBudget = math.max(1L, totalBudget - mainBudget)
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 nnueScore(m: Move): Int = nnueEvaluation.evaluate(rules.applyMove(context)(m))
def classicalScore(m: Move): Int = classicalEvaluation.evaluate(rules.applyMove(context)(m))
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)
def refine(m: Move): Move =
val moveNnue = nnueScore(m)
if (classicalScore(m) - moveNnue).abs <= Config.VETO_THRESHOLD then m
else
search
.bestMoveWithTime(context, vetoBudget, blockedMoves + m)
.filterNot(blockedMoves.contains)
.filter(alt => nnueScore(alt) < moveNnue)
.map { alt =>
vetoReporter(f"[Veto] ${m.from}->${m.to} replaced by ${alt.from}->${alt.to} — NNUE prefers it")
alt
}
.getOrElse(m)
book.flatMap(_.probe(context)).filterNot(blockedMoves.contains).orElse {
search.bestMoveWithTime(context, Config.TIME_LIMIT_MS, blockedMoves).map(refine)
}
book.flatMap(_.probe(context)).filterNot(blockedMoves.contains).orElse {
search.bestMoveWithTime(context, mainBudget, blockedMoves).map(refine)
}
@@ -1,37 +1,41 @@
package de.nowchess.bot.bots
import de.nowchess.bot.Bot
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.api.rules.RuleSet
import de.nowchess.bot.bots.nnue.EvaluationNNUE
import de.nowchess.bot.logic.AlphaBetaSearch
import de.nowchess.bot.logic.{ParallelSearch, TranspositionTable}
import de.nowchess.bot.util.{PolyglotBook, ZobristHash}
import de.nowchess.bot.{BotDifficulty, BotMoveRepetition}
import de.nowchess.bot.{Bot, BotDifficulty, BotMoveRepetition, TimeControl}
import de.nowchess.rules.sets.DefaultRules
object NNUEBot:
private def defaultThreads: Int =
sys.env.get("NNUE_SEARCH_THREADS").flatMap(_.toIntOption).filter(_ >= 1).getOrElse(1)
def apply(
difficulty: BotDifficulty,
rules: RuleSet = DefaultRules,
book: Option[PolyglotBook] = None,
fixedMoveTimeMs: Option[Long] = None,
searchThreads: Int = defaultThreads,
): Bot =
val search = AlphaBetaSearch(rules, weights = EvaluationNNUE)
context =>
val blockedMoves = BotMoveRepetition.blockedMoves(context)
book
.flatMap(_.probe(context))
.filterNot(blockedMoves.contains)
.orElse {
val moves = BotMoveRepetition.filterAllowed(context, rules.allLegalMoves(context))
if moves.isEmpty then None
else
val scored = batchEvaluateRoot(rules, context, moves)
val bestMove = scored.maxBy(_._2)._1
val budget = fixedMoveTimeMs.getOrElse(allocateTime(scored))
search.bestMoveWithTime(context, budget, blockedMoves, scored.toMap).orElse(Some(bestMove))
}
val search = ParallelSearch(rules, TranspositionTable(), () => EvaluationNNUE.freshEvaluator(), searchThreads)
new Bot:
def move(context: GameContext, time: TimeControl): Option[Move] =
val blockedMoves = BotMoveRepetition.blockedMoves(context)
book
.flatMap(_.probe(context))
.filterNot(blockedMoves.contains)
.orElse {
val moves = BotMoveRepetition.filterAllowed(context, rules.allLegalMoves(context))
if moves.isEmpty then None
else
val scored = batchEvaluateRoot(rules, context, moves)
val bestMove = scored.maxBy(_._2)._1
val budget = fixedMoveTimeMs.getOrElse(if time.isClocked then time.budgetMs else allocateTime(scored))
search.bestMoveWithTime(context, budget, blockedMoves, scored.toMap).orElse(Some(bestMove))
}
private def batchEvaluateRoot(rules: RuleSet, context: GameContext, moves: List[Move]): List[(Move, Int)] =
EvaluationNNUE.initAccumulator(context)
@@ -4,15 +4,17 @@ import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.bot.ai.Evaluation
object EvaluationNNUE extends Evaluation:
private val nnue = NNUE(NbaiLoader.loadDefault())
/** One independent NNUE evaluator: wraps its own [[NNUE]] (own accumulator stack, scratch buffers and eval cache) plus
* the endgame mop-up correction. Independent instances may run concurrently as long as they share only the read-only
* [[NNUEWeights]].
*/
final class NNUEEvaluator(nnue: NNUE) extends Evaluation:
val CHECKMATE_SCORE: Int = 10_000_000
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 +30,29 @@ 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)
/** Default singleton evaluator plus a factory for independent per-thread evaluators that share the loaded weights. */
object EvaluationNNUE extends Evaluation:
private val weights = NNUEWeights(NbaiLoader.loadDefault())
private val default = NNUEEvaluator(NNUE(weights))
/** Build a fresh evaluator backed by its own [[NNUE]] but sharing the immutable [[weights]] — one per search thread.
*/
def freshEvaluator(): Evaluation = NNUEEvaluator(NNUE(weights))
val CHECKMATE_SCORE: Int = default.CHECKMATE_SCORE
val DRAW_SCORE: Int = default.DRAW_SCORE
def evaluate(context: GameContext): Int = default.evaluate(context)
override def initAccumulator(context: GameContext): Unit = default.initAccumulator(context)
override def copyAccumulator(parentPly: Int, childPly: Int): Unit = default.copyAccumulator(parentPly, childPly)
override def pushAccumulator(childPly: Int, move: Move, parent: GameContext, child: GameContext): Unit =
default.pushAccumulator(childPly, move, parent, child)
override def evaluateAccumulator(ply: Int, context: GameContext, hash: Long): Int =
default.evaluateAccumulator(ply, context, hash)
@@ -0,0 +1,61 @@
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
@@ -4,20 +4,20 @@ import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
class NNUE(model: NbaiModel):
object NNUE:
def apply(model: NbaiModel): NNUE = new NNUE(NNUEWeights(model))
def apply(weights: NNUEWeights): NNUE = new NNUE(weights)
/** Per-thread NNUE evaluator: owns the mutable accumulator stack, scratch buffers and eval cache, while sharing the
* read-only [[NNUEWeights]]. Construct one instance per search thread (cheap — only buffer allocation); they may all
* share a single weights instance.
*/
class NNUE(weights: NNUEWeights):
import weights.{accSize, l1WeightsT, model, HALF_SIZE}
private val HALF_SIZE = 49152 // 64 king-squares × 12 piece-types × 64 piece-squares
private val featureSize = model.layers(0).inputSize // 98304 (= HALF_SIZE * 2) for king-relative
private val accSize = model.layers(0).outputSize
private val validateAccum = sys.env.contains("NNUE_VALIDATE")
// Column-major L1 weights: l1WeightsT(featureIdx * accSize + outputIdx)
private val l1WeightsT: Array[Float] =
val w = model.weights(0).weights
val t = new Array[Float](featureSize * accSize)
for j <- 0 until featureSize; i <- 0 until accSize do t(j * accSize + i) = w(i * featureSize + j)
t
// ── Accumulator stack ────────────────────────────────────────────────────
private val MAX_PLY = 128
@@ -0,0 +1,21 @@
package de.nowchess.bot.bots.nnue
/** Immutable, shareable NNUE parameters.
*
* Heavy to build (transposes the L1 weight matrix once, ~98304 × accSize floats) but read-only thereafter, so a single
* instance is safely shared across many per-thread [[NNUE]] evaluators. Holds no accumulator or scratch state — those
* live on each [[NNUE]] instance — which is what makes parallel search (independent evaluators sharing these weights)
* possible without duplicating the weight matrix.
*/
class NNUEWeights(val model: NbaiModel):
val HALF_SIZE: Int = 49152 // 64 king-squares × 12 piece-types × 64 piece-squares
val featureSize: Int = model.layers(0).inputSize // 98304 (= HALF_SIZE * 2) for king-relative
val accSize: Int = model.layers(0).outputSize
// Column-major L1 weights: l1WeightsT(featureIdx * accSize + outputIdx)
val l1WeightsT: Array[Float] =
val w = model.weights(0).weights
val t = new Array[Float](featureSize * accSize)
for j <- 0 until featureSize; i <- 0 until accSize do t(j * accSize + i) = w(i * featureSize + j)
t
@@ -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,
)
@@ -92,7 +95,7 @@ final class AlphaBetaSearch(
bestMoveWithTime(context, timeBudgetMs, Set.empty)
def bestMoveWithTime(context: GameContext, timeBudgetMs: Long, excludedRootMoves: Set[Move]): Option[Move] =
doTimedSearch(context, timeBudgetMs, excludedRootMoves, Map.empty)
doTimedSearch(context, timeBudgetMs, excludedRootMoves, Map.empty, clearTt = true)
def bestMoveWithTime(
context: GameContext,
@@ -100,21 +103,34 @@ final class AlphaBetaSearch(
excludedRootMoves: Set[Move],
hints: Map[Move, Int],
): Option[Move] =
doTimedSearch(context, timeBudgetMs, excludedRootMoves, hints)
doTimedSearch(context, timeBudgetMs, excludedRootMoves, hints, clearTt = true)
/** Timed search over a transposition table that is shared with other workers (Lazy SMP): the caller is responsible
* for clearing it once before launching all workers, so this worker must not clear it.
*/
def bestMoveWithTimeSharedTt(
context: GameContext,
timeBudgetMs: Long,
excludedRootMoves: Set[Move],
hints: Map[Move, Int],
): Option[Move] =
doTimedSearch(context, timeBudgetMs, excludedRootMoves, hints, clearTt = false)
private def doTimedSearch(
context: GameContext,
timeBudgetMs: Long,
excludedRootMoves: Set[Move],
hints: Map[Move, Int],
clearTt: Boolean,
): Option[Move] =
tt.clear()
if clearTt then tt.clear()
ordering.clear()
weights.initAccumulator(context)
timeStartMs.set(System.currentTimeMillis)
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 +145,7 @@ final class AlphaBetaSearch(
beta,
ASPIRATION_DELTA,
rootHash,
history,
excludedRootMoves,
hints,
)
@@ -154,10 +171,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 +191,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 +270,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 +293,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
@@ -468,5 +513,5 @@ final class AlphaBetaSearch(
private def isCapture(context: GameContext, move: Move): Boolean = move.moveType match
case MoveType.Normal(true) => true
case MoveType.EnPassant => true
case MoveType.Promotion(_) => context.board.pieceAt(move.to).exists(_.color != context.turn)
case MoveType.Promotion(_) => true
case _ => false
@@ -0,0 +1,56 @@
package de.nowchess.bot.logic
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.api.rules.RuleSet
import de.nowchess.bot.ai.Evaluation
import de.nowchess.rules.sets.DefaultRules
import java.util.concurrent.{Callable, Executors}
import scala.jdk.CollectionConverters.*
/** Lazy SMP search coordinator.
*
* Runs `numThreads` independent [[AlphaBetaSearch]] workers over one shared transposition table for the same time
* budget. Every worker has its own evaluator (independent NNUE accumulator) and move-ordering state, but they share
* the thread-safe TT, so faster-progressing threads deepen entries the others reuse. Only the main worker's move is
* returned; helpers exist purely to enrich the shared TT.
*
* `numThreads <= 1` runs a single worker via the ordinary clearing entry point, byte-identical to sequential
* [[AlphaBetaSearch]].
*/
final class ParallelSearch(
rules: RuleSet = DefaultRules,
tt: TranspositionTable = TranspositionTable(),
evalFactory: () => Evaluation,
numThreads: Int = 1,
):
private val threadCount = math.max(1, numThreads)
private val workers = Vector.fill(threadCount)(AlphaBetaSearch(rules, tt, evalFactory()))
def bestMoveWithTime(
context: GameContext,
timeBudgetMs: Long,
excludedRootMoves: Set[Move] = Set.empty,
hints: Map[Move, Int] = Map.empty,
): Option[Move] =
if threadCount == 1 then workers.head.bestMoveWithTime(context, timeBudgetMs, excludedRootMoves, hints)
else runParallel(context, timeBudgetMs, excludedRootMoves, hints)
private def runParallel(
context: GameContext,
timeBudgetMs: Long,
excludedRootMoves: Set[Move],
hints: Map[Move, Int],
): Option[Move] =
tt.clear()
val pool = Executors.newFixedThreadPool(threadCount)
try
val tasks = workers.map { worker =>
new Callable[Option[Move]]:
def call(): Option[Move] =
worker.bestMoveWithTimeSharedTt(context, timeBudgetMs, excludedRootMoves, hints)
}
pool.invokeAll(tasks.asJava).get(0).get()
finally pool.shutdownNow()
@@ -0,0 +1,180 @@
package de.nowchess.bot.selfplay
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.api.rules.RuleSet
import de.nowchess.api.board.Color
import de.nowchess.bot.{Bot, BotDifficulty}
import de.nowchess.bot.bots.{HybridBot, NNUEBot}
import de.nowchess.io.fen.FenExporter
import de.nowchess.rules.sets.DefaultRules
import java.io.{BufferedWriter, FileWriter}
import java.nio.file.{Files, Path}
import scala.collection.mutable
import scala.util.Random
enum GameResult:
case Bot1Wins
case Bot2Wins
case Draw
/** Standalone bot-vs-bot harness. Runs two NNUEBots against each other from randomised openings and writes the visited
* positions as one FEN per line. Each bot can use different difficulty levels and weight files.
*
* Games run sequentially. Bots alternate: bot1 plays white, then bot1 plays black, alternating per game to ensure fair
* evaluation.
*/
object BotVsBotMain:
private case class Config(
games: Int = 1,
out: String = "modules/official-bots/python/data/botvbot.txt",
weights1: Option[String] = None,
weights2: Option[String] = None,
difficulty1: String = "Hard",
difficulty2: String = "Hard",
moveTimeMs: Long = 1000L,
randomPlies: Int = 8,
maxPlies: Int = 200,
seed: Long = System.nanoTime(),
)
def main(args: Array[String]): Unit =
val config = parse(args.toList, Config())
val rules = DefaultRules
val difficulty1 = parseDifficulty(config.difficulty1)
val difficulty2 = parseDifficulty(config.difficulty2)
config.weights1.foreach(System.setProperty("nnue.weights", _))
val bot1 = HybridBot(difficulty1, rules, searchThreads = 6)
config.weights2.foreach(System.setProperty("nnue.weights", _))
val bot2 = HybridBot(difficulty2, rules)
val rng = new Random(config.seed)
val seen = mutable.HashSet.empty[String]
var bot1Wins = 0
var bot2Wins = 0
var draws = 0
Files.createDirectories(Path.of(config.out).toAbsolutePath.getParent)
val writer = new BufferedWriter(new FileWriter(config.out))
try
var game = 0
while game < config.games do
val bot1AsWhite = game % 2 == 0
val result = playGame(rules, bot1, bot2, rng, config, seen, writer, bot1AsWhite)
game += 1
result match
case Some(GameResult.Bot1Wins) =>
bot1Wins += 1
println(s"Game $game: Bot1 wins${if bot1AsWhite then " (as white)" else " (as black)"}")
case Some(GameResult.Bot2Wins) =>
bot2Wins += 1
println(s"Game $game: Bot2 wins${if !bot1AsWhite then " (as white)" else " (as black)"}")
case Some(GameResult.Draw) =>
draws += 1
println(s"Game $game: Draw")
case None => ()
if game % 25 == 0 then
writer.flush()
println(
s" Progress: $game/${config.games} | Positions: ${seen.size} | Bot1: $bot1Wins, Bot2: $bot2Wins, Draws: $draws\n",
)
finally writer.close()
println(s"\nFinal Statistics:")
println(s" Bot1 wins: $bot1Wins")
println(s" Bot2 wins: $bot2Wins")
println(s" Draws: $draws")
println(s" Positions: ${seen.size} -> ${config.out}")
private def playGame(
rules: RuleSet,
bot1: Bot,
bot2: Bot,
rng: Random,
config: Config,
seen: mutable.HashSet[String],
writer: BufferedWriter,
bot1AsWhite: Boolean,
): Option[GameResult] =
randomOpening(rules, rng, config.randomPlies, GameContext.initial) match
case None => None
case Some(start) =>
var ctx = start
var plies = config.randomPlies
var live = true
while live && plies < config.maxPlies do
if isTerminal(rules, ctx) then live = false
else
val currentBot =
if (plies - config.randomPlies) % 2 == 0 then if bot1AsWhite then bot1 else bot2
else if bot1AsWhite then bot2
else bot1
currentBot(ctx) match
case None => live = false
case Some(move) =>
ctx = rules.applyMove(ctx)(move)
plies += 1
record(rules, ctx, seen, writer)
determineWinner(rules, ctx, bot1AsWhite)
private def determineWinner(rules: RuleSet, ctx: GameContext, bot1AsWhite: Boolean): Option[GameResult] =
val legalMoves = rules.allLegalMoves(ctx)
if legalMoves.nonEmpty then Some(GameResult.Draw)
else if rules.isCheck(ctx) then
if ctx.turn == (if bot1AsWhite then Color.Black else Color.White) then Some(GameResult.Bot1Wins)
else Some(GameResult.Bot2Wins)
else if rules.isFiftyMoveRule(ctx) || rules.isThreefoldRepetition(ctx) || rules.isInsufficientMaterial(ctx) then
Some(GameResult.Draw)
else Some(GameResult.Draw)
private def randomOpening(rules: RuleSet, rng: Random, plies: Int, start: GameContext): Option[GameContext] =
var ctx = start
var i = 0
while i < plies do
val legal = rules.allLegalMoves(ctx)
if legal.isEmpty then return None
ctx = rules.applyMove(ctx)(legal(rng.nextInt(legal.size)))
i += 1
Some(ctx)
private def record(rules: RuleSet, ctx: GameContext, seen: mutable.HashSet[String], writer: BufferedWriter): Unit =
if !rules.isCheck(ctx) && !isTerminal(rules, ctx) then
val fen = FenExporter.gameContextToFen(ctx)
if seen.add(fen) then
writer.write(fen)
writer.newLine()
private def isTerminal(rules: RuleSet, ctx: GameContext): Boolean =
rules.allLegalMoves(ctx).isEmpty ||
rules.isInsufficientMaterial(ctx) ||
rules.isFiftyMoveRule(ctx) ||
rules.isThreefoldRepetition(ctx)
private def parse(args: List[String], acc: Config): Config = args match
case "--games" :: v :: rest => parse(rest, acc.copy(games = v.toInt))
case "--out" :: v :: rest => parse(rest, acc.copy(out = v))
case "--weights1" :: v :: rest => parse(rest, acc.copy(weights1 = Some(v)))
case "--weights2" :: v :: rest => parse(rest, acc.copy(weights2 = Some(v)))
case "--difficulty1" :: v :: rest => parse(rest, acc.copy(difficulty1 = v))
case "--difficulty2" :: v :: rest => parse(rest, acc.copy(difficulty2 = v))
case "--move-ms" :: v :: rest => parse(rest, acc.copy(moveTimeMs = v.toLong))
case "--random-plies" :: v :: rest => parse(rest, acc.copy(randomPlies = v.toInt))
case "--max-plies" :: v :: rest => parse(rest, acc.copy(maxPlies = v.toInt))
case "--seed" :: v :: rest => parse(rest, acc.copy(seed = v.toLong))
case Nil => acc
case unknown :: rest => println(s"Ignoring unknown arg: $unknown"); parse(rest, acc)
private def parseDifficulty(name: String): BotDifficulty = name match
case "Easy" => BotDifficulty.Easy
case "Medium" => BotDifficulty.Medium
case "Expert" => BotDifficulty.Expert
case _ => BotDifficulty.Hard
@@ -3,7 +3,7 @@ package de.nowchess.bot.selfplay
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.api.rules.RuleSet
import de.nowchess.bot.BotDifficulty
import de.nowchess.bot.{Bot, BotDifficulty}
import de.nowchess.bot.bots.NNUEBot
import de.nowchess.io.fen.FenExporter
import de.nowchess.rules.sets.DefaultRules
@@ -55,7 +55,7 @@ object SelfPlayMain:
private def playGame(
rules: RuleSet,
bot: GameContext => Option[Move],
bot: Bot,
rng: Random,
config: Config,
seen: mutable.HashSet[String],
@@ -2,7 +2,7 @@ package de.nowchess.bot.service
import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.bot.{Bot, BotController}
import de.nowchess.bot.{Bot, BotController, TimeControl}
import de.nowchess.bot.client.AccountServiceClient
import de.nowchess.bot.config.RedisConfig
import de.nowchess.io.fen.FenParser
@@ -20,6 +20,7 @@ import scala.jdk.CollectionConverters.*
import scala.util.{Failure, Success, Try}
import java.io.{BufferedReader, InputStream, InputStreamReader}
import java.util.concurrent.{ConcurrentHashMap, ExecutorService, Executors}
import java.util.concurrent.atomic.AtomicReference
@Startup
@ApplicationScoped
@@ -42,6 +43,8 @@ class TournamentBotGamePlayer:
private val hardestDifficulty = "expert"
private val autoJoinIntervalMs = 15000L
// Detect the opponent's move fast: every poll spent waiting runs our clock without us thinking.
private val pollIntervalMs = 250L
private val gameTerminalStatuses =
Set("checkmate", "stalemate", "draw", "resigned", "timeout", "aborted", "finished")
@@ -393,12 +396,58 @@ class TournamentBotGamePlayer:
private def playGame(cfg: TournamentBotConfig, gameId: String, color: String): Unit =
Try {
log.infof("Playing game %s as %s", gameId, color)
pollGameLoop(cfg, gameId, color)
if !streamGameLoop(cfg, gameId, color) then
log.infof("Stream unavailable for game %s — falling back to polling", gameId)
pollGameLoop(cfg, gameId, color)
activeGames.remove(gameId)
} match
case Failure(ex) => log.errorf(ex, "Game %s crashed", gameId); activeGames.remove(gameId)
case Success(_) => ()
// Push-based play: the game stream delivers the opponent's move the instant it lands, so our clock
// is not burned waiting between polls. Heartbeats (every 10s) keep the NDJSON connection flushing.
// Returns true if the game was driven to completion via the stream; false to fall back to polling.
private def streamGameLoop(cfg: TournamentBotConfig, gameId: String, color: String): Boolean =
val myColor = resolveColor(cfg, gameId).getOrElse(color)
val lastFen = AtomicReference("")
Try {
val response = authed(cfg, target(cfg).path("game").path(gameId).path("stream"))
.header("Accept", "application/x-ndjson")
.get()
try
if response.getStatus != 200 then
log.warnf("Game stream %s returned status %d", gameId, response.getStatus)
false
else
log.infof("Streaming game %s as %s", gameId, myColor)
forEachLine(response.readEntity(classOf[InputStream])): line =>
parse(line).foreach(node => handleStreamEvent(cfg, gameId, myColor, node, lastFen))
true
finally response.close()
} match
case Success(completed) => completed
case Failure(ex) => log.warnf(ex, "Game stream %s failed", gameId); false
private def handleStreamEvent(
cfg: TournamentBotConfig,
gameId: String,
myColor: String,
node: JsonNode,
lastFen: AtomicReference[String],
): Unit =
val eventType = node.path("type").asText()
if eventType == "move" || eventType == "gameState" then
val status = node.path("status").asText("ongoing")
val turn = node.path("turn").asText()
val fen = node.path("fen").asText()
if !gameTerminalStatuses.contains(status) && turn == myColor && fen.nonEmpty && fen != lastFen.get then
lastFen.set(fen)
val time = readTimeControl(node, myColor)
log.infof("Our turn (stream) in game %s — computing move (fen=%s, budget=%dms)", gameId, fen, time.budgetMs)
computeUci(cfg, fen, time) match
case None => log.warnf("No move found for game %s (fen=%s)", gameId, fen)
case Some(uci) => submitMove(cfg, gameId, uci)
// The native JAX-RS client buffers streaming responses, so reading the NDJSON game stream blocks
// forever. Poll the game state with plain GETs (which work) and move when it is our turn.
private def pollGameLoop(cfg: TournamentBotConfig, gameId: String, color: String): Unit =
@@ -426,16 +475,28 @@ class TournamentBotGamePlayer:
val fen = node.path("fen").asText()
if turn == myColor && status == "ongoing" && fen.nonEmpty && fen != lastFen then
lastFen = fen
log.infof("Our turn in game %s — computing move (fen=%s)", gameId, fen)
computeUci(cfg, fen) match
val time = readTimeControl(node, myColor)
log.infof("Our turn in game %s — computing move (fen=%s, budget=%dms)", gameId, fen, time.budgetMs)
computeUci(cfg, fen, time) match
case None => log.warnf("No move found for game %s (fen=%s)", gameId, fen)
case Some(uci) => submitMove(cfg, gameId, uci)
sleep(1000)
sleep(pollIntervalMs)
private def computeUci(cfg: TournamentBotConfig, fen: String): Option[String] =
// Server clock is reported in seconds; convert to a millisecond TimeControl so the engine can
// size its move budget against the real clock instead of a fixed guess.
private def readTimeControl(node: JsonNode, myColor: String): TimeControl =
val clock = node.path("clock")
if clock.isMissingNode || clock.isNull then TimeControl.Unlimited
else
val field = if myColor == "white" then "whiteTime" else "blackTime"
val remainingMs = (clock.path(field).asDouble(0.0) * 1000.0).toLong
val incrementMs = (clock.path("increment").asDouble(0.0) * 1000.0).toLong
TimeControl(remainingMs, incrementMs)
private def computeUci(cfg: TournamentBotConfig, fen: String, time: TimeControl): Option[String] =
FenParser.parseFen(fen) match
case Left(err) => log.warnf("FEN parse failed: %s (%s)", fen, err.toString); None
case Right(context) => engine(cfg).apply(context).map(toUci)
case Right(context) => engine(cfg).move(context, time).map(toUci)
private def submitMove(cfg: TournamentBotConfig, gameId: String, uci: String): Unit =
Try {
@@ -253,6 +253,30 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
val search = AlphaBetaSearch(promoCaptureRules, weights = EvaluationClassic)
search.bestMove(ctx, maxDepth = 1) should be(Some(promoCapture))
test("quiet promotion is treated as tactical in quiescence"):
// Pawn pushes to an empty back-rank square (no capture). Must still be searched in
// quiescence so a bot does not skip queening at the search horizon.
val quietPromo = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Queen))
val board = Board(
Map(
Square(File.E, Rank.R7) -> Piece.WhitePawn,
),
)
val ctx = GameContext.initial.withBoard(board).withTurn(Color.White)
val quietPromoRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
def allLegalMoves(context: GameContext): List[Move] = List(quietPromo)
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
val search = AlphaBetaSearch(quietPromoRules, weights = EvaluationClassic)
search.bestMove(ctx, maxDepth = 1) should be(Some(quietPromo))
test("draw when isInsufficientMaterial with legal moves present"):
val legalMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())
val drawRules = new RuleSet:
@@ -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
@@ -0,0 +1,28 @@
package de.nowchess.bot
import de.nowchess.api.game.GameContext
import de.nowchess.bot.bots.classic.EvaluationClassic
import de.nowchess.bot.logic.{ParallelSearch, TranspositionTable}
import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class ParallelSearchTest extends AnyFunSuite with Matchers:
private def search(threads: Int): ParallelSearch =
ParallelSearch(DefaultRules, TranspositionTable(), () => EvaluationClassic, threads)
test("single-threaded coordinator returns a legal move on the initial position"):
val move = search(1).bestMoveWithTime(GameContext.initial, 200L)
move should not be None
DefaultRules.allLegalMoves(GameContext.initial) should contain(move.get)
test("multi-threaded Lazy SMP returns a legal move and does not crash under concurrency"):
val parallel = search(4)
for _ <- 1 to 5 do
val move = parallel.bestMoveWithTime(GameContext.initial, 200L)
move should not be None
DefaultRules.allLegalMoves(GameContext.initial) should contain(move.get)
test("numThreads below one is clamped to a single worker"):
search(0).bestMoveWithTime(GameContext.initial, 100L) should not be None
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=39
MINOR=43
PATCH=0