Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8cbcdca3b | |||
| e4fee85134 | |||
| b4709b4a33 | |||
| 9f9140cb58 | |||
| fa10852bc9 | |||
| 44f376f032 |
@@ -987,3 +987,108 @@
|
||||
### 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-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))
|
||||
* **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:** 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:** 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-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))
|
||||
* **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:** 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:** 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))
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
{
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5,
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"version": "3.10.0"
|
||||
},
|
||||
"colab": {
|
||||
"provenance": [],
|
||||
"gpuType": "T4"
|
||||
},
|
||||
"accelerator": "GPU"
|
||||
},
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# NNUE Training Pipeline\n",
|
||||
"\n",
|
||||
"End-to-end notebook: data generation → Stockfish labeling → training → `.nbai` export.\n",
|
||||
"\n",
|
||||
"**Runtime:** GPU (T4 or better). Runtime → Change runtime type → T4 GPU.\n",
|
||||
"\n",
|
||||
"**Persistence:** Checkpoints and datasets are saved to Google Drive so training can resume after session timeout."
|
||||
],
|
||||
"id": "intro-md"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"## ⚙️ 1 — Setup"
|
||||
],
|
||||
"id": "setup-md"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Mount Google Drive for checkpoint persistence\n",
|
||||
"from google.colab import drive\n",
|
||||
"drive.mount('/content/drive')"
|
||||
],
|
||||
"id": "mount-drive"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import os\n",
|
||||
"\n",
|
||||
"# ── Configure these paths once ───────────────────────────────────────────────\n",
|
||||
"REPO_URL = 'https://git.janis-eccarius.de/NowChess/NowChessSystems.git'\n",
|
||||
"DRIVE_ROOT = '/content/drive/MyDrive/NowChess'\n",
|
||||
"REPO_DIR = f'{DRIVE_ROOT}/NowChessSystems'\n",
|
||||
"PYTHON_DIR = f'{REPO_DIR}/modules/official-bots/python'\n",
|
||||
"# ─────────────────────────────────────────────────────────────────────────────\n",
|
||||
"\n",
|
||||
"os.makedirs(DRIVE_ROOT, exist_ok=True)\n",
|
||||
"\n",
|
||||
"if not os.path.isdir(REPO_DIR):\n",
|
||||
" !git clone --depth=1 \"{REPO_URL}\" \"{REPO_DIR}\"\n",
|
||||
" print('Repo cloned to Drive.')\n",
|
||||
"else:\n",
|
||||
" !git -C \"{REPO_DIR}\" pull --ff-only\n",
|
||||
" print('Repo updated.')"
|
||||
],
|
||||
"id": "clone-repo"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Install Python dependencies\n",
|
||||
"!pip install -q chess tqdm rich zstandard\n",
|
||||
"\n",
|
||||
"# Stockfish for position labeling\n",
|
||||
"!apt-get install -q -y stockfish\n",
|
||||
"import shutil\n",
|
||||
"STOCKFISH_PATH = shutil.which('stockfish') or '/usr/games/stockfish'\n",
|
||||
"print(f'Stockfish: {STOCKFISH_PATH}')\n",
|
||||
"\n",
|
||||
"# Add pipeline source to path\n",
|
||||
"import sys\n",
|
||||
"sys.path.insert(0, f'{PYTHON_DIR}/src')\n",
|
||||
"sys.path.insert(0, PYTHON_DIR)\n",
|
||||
"print('Python path configured.')"
|
||||
],
|
||||
"id": "install-deps"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"## 🗄️ 2 — Data\n",
|
||||
"\n",
|
||||
"Choose **one** of the two options below:\n",
|
||||
"- **Option A** — generate FEN positions with random play, then label them with Stockfish.\n",
|
||||
"- **Option B** — upload an existing `labeled.jsonl` from your machine or Drive."
|
||||
],
|
||||
"id": "data-md"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from pathlib import Path\n",
|
||||
"\n",
|
||||
"# Paths (all on Drive so they survive session restarts)\n",
|
||||
"DATA_DIR = Path(DRIVE_ROOT) / 'training_data'\n",
|
||||
"DATA_DIR.mkdir(parents=True, exist_ok=True)\n",
|
||||
"POSITIONS_FILE = DATA_DIR / 'positions.txt' # raw FENs\n",
|
||||
"LABELED_FILE = DATA_DIR / 'labeled.jsonl' # FEN + eval pairs\n",
|
||||
"\n",
|
||||
"print(f'Data directory: {DATA_DIR}')"
|
||||
],
|
||||
"id": "data-paths"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Option A: Generate + label ────────────────────────────────────────────────\n",
|
||||
"# Adjust NUM_POSITIONS to taste. 50 000 trains in ~10 min on T4;\n",
|
||||
"# 200 000+ gives better generalisation.\n",
|
||||
"NUM_POSITIONS = 50_000\n",
|
||||
"STOCKFISH_DEPTH = 12\n",
|
||||
"LABEL_WORKERS = 4 # parallel Stockfish processes\n",
|
||||
"MIN_MOVE = 5 # skip opening book moves\n",
|
||||
"MAX_MOVE = 60\n",
|
||||
"\n",
|
||||
"from generate import play_random_game_and_collect_positions\n",
|
||||
"from label import label_positions_with_stockfish\n",
|
||||
"\n",
|
||||
"print(f'Generating {NUM_POSITIONS:,} positions...')\n",
|
||||
"count = play_random_game_and_collect_positions(\n",
|
||||
" str(POSITIONS_FILE),\n",
|
||||
" total_positions=NUM_POSITIONS,\n",
|
||||
" samples_per_game=1,\n",
|
||||
" min_move=MIN_MOVE,\n",
|
||||
" max_move=MAX_MOVE,\n",
|
||||
" num_workers=4,\n",
|
||||
")\n",
|
||||
"print(f'{count:,} positions written to {POSITIONS_FILE}')\n",
|
||||
"\n",
|
||||
"print('Labeling with Stockfish (this is the slow step)...')\n",
|
||||
"ok = label_positions_with_stockfish(\n",
|
||||
" str(POSITIONS_FILE),\n",
|
||||
" str(LABELED_FILE),\n",
|
||||
" STOCKFISH_PATH,\n",
|
||||
" depth=STOCKFISH_DEPTH,\n",
|
||||
" num_workers=LABEL_WORKERS,\n",
|
||||
")\n",
|
||||
"if ok:\n",
|
||||
" print(f'Labeled dataset saved: {LABELED_FILE}')\n",
|
||||
"else:\n",
|
||||
" print('ERROR: labeling failed')"
|
||||
],
|
||||
"id": "option-a-generate"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Option B: Upload existing labeled.jsonl ───────────────────────────────────\n",
|
||||
"# Run this cell instead of Option A if you already have a labeled dataset.\n",
|
||||
"#\n",
|
||||
"# To upload from local machine:\n",
|
||||
"# from google.colab import files\n",
|
||||
"# uploaded = files.upload() # pick your labeled.jsonl\n",
|
||||
"# import shutil, os\n",
|
||||
"# shutil.move(next(iter(uploaded)), str(LABELED_FILE))\n",
|
||||
"#\n",
|
||||
"# Or copy from Drive:\n",
|
||||
"# import shutil\n",
|
||||
"# shutil.copy('/content/drive/MyDrive/path/to/labeled.jsonl', str(LABELED_FILE))\n",
|
||||
"\n",
|
||||
"import os\n",
|
||||
"if LABELED_FILE.exists():\n",
|
||||
" lines = sum(1 for _ in open(LABELED_FILE))\n",
|
||||
" print(f'Ready: {lines:,} labeled positions at {LABELED_FILE}')\n",
|
||||
"else:\n",
|
||||
" print('No labeled.jsonl found — run Option A first or upload one.')"
|
||||
],
|
||||
"id": "option-b-upload"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"## 🏋️ 3 — Train\n",
|
||||
"\n",
|
||||
"Standard training runs a fixed number of epochs. \n",
|
||||
"**Burst mode** is better for Colab: it repeatedly restarts from the best checkpoint within a time budget, surviving session disconnects gracefully."
|
||||
],
|
||||
"id": "train-md"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"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.')"
|
||||
],
|
||||
"id": "train-config"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Standard training ─────────────────────────────────────────────────────────\n",
|
||||
"# Use this when you have a reliable long-running session.\n",
|
||||
"\n",
|
||||
"train_nnue(\n",
|
||||
" data_file=str(LABELED_FILE),\n",
|
||||
" output_file=OUTPUT_FILE,\n",
|
||||
" epochs=EPOCHS,\n",
|
||||
" batch_size=BATCH_SIZE,\n",
|
||||
" checkpoint=CHECKPOINT,\n",
|
||||
" use_versioning=True,\n",
|
||||
" early_stopping_patience=EARLY_STOPPING,\n",
|
||||
" subsample_ratio=SUBSAMPLE_RATIO,\n",
|
||||
" hidden_sizes=HIDDEN_SIZES,\n",
|
||||
")"
|
||||
],
|
||||
"id": "standard-train"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Burst training (recommended for Colab free tier) ─────────────────────────\n",
|
||||
"# Restarts from the global best each time early stopping fires.\n",
|
||||
"# Set BURST_MINUTES to slightly less than the Colab session limit (~70 min).\n",
|
||||
"\n",
|
||||
"BURST_MINUTES = 70\n",
|
||||
"EPOCHS_PER_SEASON = 30\n",
|
||||
"BURST_PATIENCE = 8\n",
|
||||
"\n",
|
||||
"burst_train(\n",
|
||||
" data_file=str(LABELED_FILE),\n",
|
||||
" output_file=OUTPUT_FILE,\n",
|
||||
" duration_minutes=BURST_MINUTES,\n",
|
||||
" epochs_per_season=EPOCHS_PER_SEASON,\n",
|
||||
" early_stopping_patience=BURST_PATIENCE,\n",
|
||||
" batch_size=BATCH_SIZE,\n",
|
||||
" initial_checkpoint=CHECKPOINT,\n",
|
||||
" use_versioning=True,\n",
|
||||
" subsample_ratio=SUBSAMPLE_RATIO,\n",
|
||||
" hidden_sizes=HIDDEN_SIZES,\n",
|
||||
")"
|
||||
],
|
||||
"id": "burst-train"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"## 📦 4 — Export\n",
|
||||
"\n",
|
||||
"Convert the best `.pt` checkpoint to the `.nbai` binary format read by `NbaiLoader` in Scala."
|
||||
],
|
||||
"id": "export-md"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from export import export_to_nbai\n",
|
||||
"\n",
|
||||
"NBAI_FILE = Path(DRIVE_ROOT) / 'nnue_weights.nbai'\n",
|
||||
"\n",
|
||||
"# Pick the latest versioned checkpoint\n",
|
||||
"checkpoints = sorted(WEIGHTS_DIR.glob('nnue_weights_v*.pt'))\n",
|
||||
"if not checkpoints:\n",
|
||||
" raise FileNotFoundError('No checkpoints found in ' + str(WEIGHTS_DIR))\n",
|
||||
"\n",
|
||||
"latest = checkpoints[-1]\n",
|
||||
"print(f'Exporting {latest.name} → {NBAI_FILE.name}')\n",
|
||||
"\n",
|
||||
"export_to_nbai(\n",
|
||||
" weights_file=str(latest),\n",
|
||||
" output_file=str(NBAI_FILE),\n",
|
||||
" trained_by='colab',\n",
|
||||
")\n",
|
||||
"print('Export complete.')"
|
||||
],
|
||||
"id": "export-cell"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"## ⬇️ 5 — Download\n",
|
||||
"\n",
|
||||
"Download the `.nbai` weights file and the latest `.pt` checkpoint to your local machine.\n",
|
||||
"\n",
|
||||
"Place `nnue_weights.nbai` in `modules/official-bots/src/main/resources/` and rebuild the native image."
|
||||
],
|
||||
"id": "download-md"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from google.colab import files\n",
|
||||
"\n",
|
||||
"if NBAI_FILE.exists():\n",
|
||||
" files.download(str(NBAI_FILE))\n",
|
||||
" print(f'Downloading {NBAI_FILE.name}')\n",
|
||||
"else:\n",
|
||||
" print('No .nbai file found — run the Export cell first.')\n",
|
||||
"\n",
|
||||
"checkpoints = sorted(WEIGHTS_DIR.glob('nnue_weights_v*.pt'))\n",
|
||||
"if checkpoints:\n",
|
||||
" latest = checkpoints[-1]\n",
|
||||
" files.download(str(latest))\n",
|
||||
" print(f'Downloading checkpoint {latest.name}')\n",
|
||||
"else:\n",
|
||||
" print('No .pt checkpoint found.')"
|
||||
],
|
||||
"id": "download-cell"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -53,6 +53,11 @@ class NNUEDataset(Dataset):
|
||||
eval_val = self.evals[idx]
|
||||
features = fen_to_features(fen)
|
||||
|
||||
# Board is flipped for Black-to-move in fen_to_features; negate eval
|
||||
# so the label still means "good for the side shown as White after flip"
|
||||
if ' b ' in fen:
|
||||
eval_val = -eval_val
|
||||
|
||||
# Use evaluation as-is if normalized, otherwise apply sigmoid scaling
|
||||
if self.is_normalized:
|
||||
target = torch.tensor(eval_val, dtype=torch.float32)
|
||||
@@ -61,38 +66,59 @@ class NNUEDataset(Dataset):
|
||||
|
||||
return features, target
|
||||
|
||||
# King-relative (HalfKP) encoding: two perspectives, one per side's king.
|
||||
# Each piece is encoded as: kingSq * 768 + pieceIdx * 64 + sq
|
||||
# White perspective uses white king square; black perspective uses black king square.
|
||||
# Total input dimension = 2 × 64 × 12 × 64 = 98304.
|
||||
_HALF_SIZE = 64 * 12 * 64 # 49152 features per perspective
|
||||
INPUT_SIZE = _HALF_SIZE * 2 # 98304
|
||||
|
||||
_PIECE_TO_IDX = {
|
||||
'p': 0, 'n': 1, 'b': 2, 'r': 3, 'q': 4, 'k': 5,
|
||||
'P': 6, 'N': 7, 'B': 8, 'R': 9, 'Q': 10, 'K': 11,
|
||||
}
|
||||
|
||||
|
||||
def fen_to_features(fen):
|
||||
"""Convert FEN to 768-dimensional binary feature vector."""
|
||||
# Piece type to index: pawn=0, knight=1, bishop=2, rook=3, queen=4, king=5
|
||||
piece_to_idx = {'p': 0, 'n': 1, 'b': 2, 'r': 3, 'q': 4, 'k': 5,
|
||||
'P': 6, 'N': 7, 'B': 8, 'R': 9, 'Q': 10, 'K': 11}
|
||||
|
||||
features = torch.zeros(768, dtype=torch.float32)
|
||||
"""Convert FEN to 98304-dim king-relative (HalfKP) feature vector.
|
||||
|
||||
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.
|
||||
"""
|
||||
features = torch.zeros(INPUT_SIZE, dtype=torch.float32)
|
||||
try:
|
||||
board = chess.Board(fen)
|
||||
|
||||
# 12 piece types × 64 squares = 768
|
||||
for square in chess.SQUARES:
|
||||
piece = board.piece_at(square)
|
||||
if piece is not None:
|
||||
piece_char = piece.symbol()
|
||||
if piece_char in piece_to_idx:
|
||||
piece_idx = piece_to_idx[piece_char]
|
||||
feature_idx = piece_idx * 64 + square
|
||||
features[feature_idx] = 1.0
|
||||
except:
|
||||
# Perspective flip: present all positions as if White is to move
|
||||
if board.turn == chess.BLACK:
|
||||
board = board.mirror()
|
||||
wk = board.king(chess.WHITE)
|
||||
bk = board.king(chess.BLACK)
|
||||
if wk is None or bk is None:
|
||||
return features
|
||||
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
|
||||
# Black-king perspective (indices _HALF_SIZE .. INPUT_SIZE-1)
|
||||
features[_HALF_SIZE + bk * 768 + pidx * 64 + sq] = 1.0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return features
|
||||
|
||||
DEFAULT_HIDDEN_SIZES = [1536, 1024, 512, 256]
|
||||
# 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.
|
||||
DEFAULT_HIDDEN_SIZES = [512, 256, 128]
|
||||
|
||||
|
||||
class NNUE(nn.Module):
|
||||
"""NNUE neural network with configurable hidden layers.
|
||||
|
||||
Architecture: 768 → hidden_sizes[0] → ... → hidden_sizes[-1] → 1
|
||||
Architecture: INPUT_SIZE → hidden_sizes[0] → ... → hidden_sizes[-1] → 1
|
||||
Layer attributes follow the naming l1, l2, ..., lN so export.py can
|
||||
infer the architecture directly from the state_dict.
|
||||
"""
|
||||
@@ -102,7 +128,7 @@ class NNUE(nn.Module):
|
||||
if hidden_sizes is None:
|
||||
hidden_sizes = DEFAULT_HIDDEN_SIZES
|
||||
self.hidden_sizes = list(hidden_sizes)
|
||||
sizes = [768] + self.hidden_sizes + [1]
|
||||
sizes = [INPUT_SIZE] + self.hidden_sizes + [1]
|
||||
num_hidden = len(self.hidden_sizes)
|
||||
|
||||
for i in range(num_hidden):
|
||||
|
||||
Binary file not shown.
@@ -28,7 +28,7 @@ object NNUEBot:
|
||||
else
|
||||
val scored = batchEvaluateRoot(rules, context, moves)
|
||||
val bestMove = scored.maxBy(_._2)._1
|
||||
search.bestMoveWithTime(context, allocateTime(scored), blockedMoves).orElse(Some(bestMove))
|
||||
search.bestMoveWithTime(context, allocateTime(scored), blockedMoves, scored.toMap).orElse(Some(bestMove))
|
||||
}
|
||||
|
||||
private def batchEvaluateRoot(rules: RuleSet, context: GameContext, moves: List[Move]): List[(Move, Int)] =
|
||||
|
||||
@@ -23,9 +23,9 @@ object EvaluationNNUE extends Evaluation:
|
||||
nnue.copyAccumulator(parentPly, childPly)
|
||||
|
||||
override def pushAccumulator(childPly: Int, move: Move, parent: GameContext, child: GameContext): Unit =
|
||||
// Use incremental updates, but recompute from scratch every 10 plies to prevent accumulation errors
|
||||
// Recompute every 10 plies to prevent floating-point drift; king moves always recompute internally
|
||||
if childPly % 10 == 0 then nnue.recomputeAccumulator(childPly, child.board)
|
||||
else nnue.pushAccumulator(childPly, move, parent.board)
|
||||
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)
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
package de.nowchess.bot.bots.nnue
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Square}
|
||||
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):
|
||||
|
||||
private val featureSize = model.layers(0).inputSize
|
||||
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") // Enable with NNUE_VALIDATE=1
|
||||
private val validateAccum = sys.env.contains("NNUE_VALIDATE")
|
||||
|
||||
// Column-major L1 weights for cache-friendly sparse & incremental updates.
|
||||
// l1WeightsT(featureIdx * accSize + outputIdx) = l1Weights(outputIdx * featureSize + featureIdx)
|
||||
// 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)
|
||||
@@ -23,7 +23,6 @@ class NNUE(model: NbaiModel):
|
||||
private val MAX_PLY = 128
|
||||
private val l1Stack: Array[Array[Float]] = Array.fill(MAX_PLY + 1)(new Array[Float](accSize))
|
||||
|
||||
// Shared evaluation buffers: index i holds the output of layers(i) (all except the scalar output layer).
|
||||
private val evalBuffers: Array[Array[Float]] = model.layers.init.map(l => new Array[Float](l.outputSize))
|
||||
|
||||
// ── Eval cache ───────────────────────────────────────────────────────────
|
||||
@@ -36,9 +35,29 @@ class NNUE(model: NbaiModel):
|
||||
|
||||
private def squareNum(sq: Square): Int = sq.rank.ordinal * 8 + sq.file.ordinal
|
||||
|
||||
private def featureIndex(piece: Piece, sqNum: Int): Int =
|
||||
val colorOffset = if piece.color == Color.White then 6 else 0
|
||||
(colorOffset + piece.pieceType.ordinal) * 64 + sqNum
|
||||
// Mirror square vertically (rank 0 ↔ rank 7) for the perspective flip
|
||||
private def flipSqNum(sqNum: Int): Int = (7 - sqNum / 8) * 8 + sqNum % 8
|
||||
|
||||
private def pieceIdx(piece: Piece): Int =
|
||||
if piece.color == Color.White then 6 + piece.pieceType.ordinal else piece.pieceType.ordinal
|
||||
|
||||
// White-king perspective: index in [0, HALF_SIZE)
|
||||
private def featureIdxWhite(piece: Piece, sqNum: Int, wkSq: Int): Int =
|
||||
wkSq * 768 + pieceIdx(piece) * 64 + sqNum
|
||||
|
||||
// Black-king perspective: index in [HALF_SIZE, featureSize)
|
||||
private def featureIdxBlack(piece: Piece, sqNum: Int, bkSq: Int): Int =
|
||||
HALF_SIZE + bkSq * 768 + pieceIdx(piece) * 64 + sqNum
|
||||
|
||||
private def wkSqOf(board: Board): Int =
|
||||
board.pieces
|
||||
.collectFirst { case (sq, p) if p.pieceType == PieceType.King && p.color == Color.White => squareNum(sq) }
|
||||
.getOrElse(0)
|
||||
|
||||
private def bkSqOf(board: Board): Int =
|
||||
board.pieces
|
||||
.collectFirst { case (sq, p) if p.pieceType == PieceType.King && p.color == Color.Black => squareNum(sq) }
|
||||
.getOrElse(0)
|
||||
|
||||
private def addColumn(l1Pre: Array[Float], featureIdx: Int): Unit =
|
||||
val offset = featureIdx * accSize
|
||||
@@ -48,92 +67,96 @@ class NNUE(model: NbaiModel):
|
||||
val offset = featureIdx * accSize
|
||||
for i <- 0 until accSize do l1Pre(i) -= l1WeightsT(offset + i)
|
||||
|
||||
private def addPiece(l1: Array[Float], piece: Piece, sqNum: Int, wkSq: Int, bkSq: Int): Unit =
|
||||
addColumn(l1, featureIdxWhite(piece, sqNum, wkSq))
|
||||
addColumn(l1, featureIdxBlack(piece, sqNum, bkSq))
|
||||
|
||||
private def removePiece(l1: Array[Float], piece: Piece, sqNum: Int, wkSq: Int, bkSq: Int): Unit =
|
||||
subtractColumn(l1, featureIdxWhite(piece, sqNum, wkSq))
|
||||
subtractColumn(l1, featureIdxBlack(piece, sqNum, bkSq))
|
||||
|
||||
// ── Accumulator init ─────────────────────────────────────────────────────
|
||||
|
||||
def initAccumulator(board: Board): Unit =
|
||||
val wkSq = wkSqOf(board)
|
||||
val bkSq = bkSqOf(board)
|
||||
System.arraycopy(model.weights(0).bias, 0, l1Stack(0), 0, accSize)
|
||||
for (sq, piece) <- board.pieces do addColumn(l1Stack(0), featureIndex(piece, squareNum(sq)))
|
||||
for (sq, piece) <- board.pieces do addPiece(l1Stack(0), piece, squareNum(sq), wkSq, bkSq)
|
||||
|
||||
// ── Accumulator push (incremental updates) ───────────────────────────────
|
||||
|
||||
def pushAccumulator(childPly: Int, move: Move, board: Board): Unit =
|
||||
def pushAccumulator(childPly: Int, move: Move, parentBoard: Board, childBoard: Board): Unit =
|
||||
System.arraycopy(l1Stack(childPly - 1), 0, l1Stack(childPly), 0, accSize)
|
||||
val l1 = l1Stack(childPly)
|
||||
move.moveType match
|
||||
case MoveType.Normal(_) => applyNormalDelta(l1, move, board)
|
||||
case MoveType.EnPassant => applyEnPassantDelta(l1, move, board)
|
||||
case MoveType.CastleKingside | MoveType.CastleQueenside => applyCastleDelta(l1, move, board)
|
||||
case MoveType.Promotion(p) => applyPromotionDelta(l1, move, p, board)
|
||||
if isKingMove(move, parentBoard) then recomputeAccumulatorInto(l1Stack(childPly), childBoard)
|
||||
else applyNonKingDelta(l1Stack(childPly), move, parentBoard)
|
||||
|
||||
private def isKingMove(move: Move, board: Board): Boolean =
|
||||
move.moveType == MoveType.CastleKingside ||
|
||||
move.moveType == MoveType.CastleQueenside ||
|
||||
board.pieceAt(move.from).exists(_.pieceType == PieceType.King)
|
||||
|
||||
def copyAccumulator(parentPly: Int, childPly: Int): Unit =
|
||||
System.arraycopy(l1Stack(parentPly), 0, l1Stack(childPly), 0, accSize)
|
||||
|
||||
def recomputeAccumulator(ply: Int, board: Board): Unit =
|
||||
System.arraycopy(model.weights(0).bias, 0, l1Stack(ply), 0, accSize)
|
||||
for (sq, piece) <- board.pieces do addColumn(l1Stack(ply), featureIndex(piece, squareNum(sq)))
|
||||
recomputeAccumulatorInto(l1Stack(ply), board)
|
||||
|
||||
private def recomputeAccumulatorInto(l1: Array[Float], board: Board): Unit =
|
||||
val wkSq = wkSqOf(board)
|
||||
val bkSq = bkSqOf(board)
|
||||
System.arraycopy(model.weights(0).bias, 0, l1, 0, accSize)
|
||||
for (sq, piece) <- board.pieces do addPiece(l1, piece, squareNum(sq), wkSq, bkSq)
|
||||
|
||||
def validateAccumulator(ply: Int, board: Board): Boolean =
|
||||
// Compute what L1 should be from scratch
|
||||
val expectedL1 = new Array[Float](accSize)
|
||||
System.arraycopy(model.weights(0).bias, 0, expectedL1, 0, accSize)
|
||||
for (sq, piece) <- board.pieces do addColumn(expectedL1, featureIndex(piece, squareNum(sq)))
|
||||
|
||||
// Compare with actual L1
|
||||
val expected = new Array[Float](accSize)
|
||||
val wkSq = wkSqOf(board)
|
||||
val bkSq = bkSqOf(board)
|
||||
System.arraycopy(model.weights(0).bias, 0, expected, 0, accSize)
|
||||
for (sq, piece) <- board.pieces do addPiece(expected, piece, squareNum(sq), wkSq, bkSq)
|
||||
val actual = l1Stack(ply)
|
||||
val maxError =
|
||||
(0 until accSize).foldLeft(0f) { (currentMax, i) =>
|
||||
val error = math.abs(actual(i) - expectedL1(i))
|
||||
math.max(currentMax, error)
|
||||
}
|
||||
(0 until accSize).forall(i => math.abs(actual(i) - expected(i)) < 0.001f)
|
||||
|
||||
maxError < 0.001f // Allow small floating-point errors
|
||||
// ── Non-king incremental deltas ──────────────────────────────────────────
|
||||
|
||||
private def applyNormalDelta(l1: Array[Float], move: Move, board: Board): Unit =
|
||||
// Extract source and destination square indices early
|
||||
val fromNum = squareNum(move.from)
|
||||
val toNum = squareNum(move.to)
|
||||
private def applyNonKingDelta(l1: Array[Float], move: Move, board: Board): Unit =
|
||||
val wkSq = wkSqOf(board)
|
||||
val bkSq = bkSqOf(board)
|
||||
move.moveType match
|
||||
case MoveType.Normal(_) => applyNormalDelta(l1, move, board, wkSq, bkSq)
|
||||
case MoveType.EnPassant => applyEnPassantDelta(l1, move, board, wkSq, bkSq)
|
||||
case MoveType.Promotion(p) => applyPromotionDelta(l1, move, p, board, wkSq, bkSq)
|
||||
case _ => () // king moves handled before this point
|
||||
|
||||
// Get the moving piece
|
||||
private def applyNormalDelta(l1: Array[Float], move: Move, board: Board, wkSq: Int, bkSq: Int): Unit =
|
||||
board.pieceAt(move.from).foreach { mover =>
|
||||
subtractColumn(l1, featureIndex(mover, fromNum))
|
||||
|
||||
// If there's a capture, subtract the captured piece
|
||||
board.pieceAt(move.to).foreach { cap =>
|
||||
subtractColumn(l1, featureIndex(cap, toNum))
|
||||
}
|
||||
|
||||
// Add the piece to its new location
|
||||
addColumn(l1, featureIndex(mover, toNum))
|
||||
val fromNum = squareNum(move.from)
|
||||
val toNum = squareNum(move.to)
|
||||
removePiece(l1, mover, fromNum, wkSq, bkSq)
|
||||
board.pieceAt(move.to).foreach(cap => removePiece(l1, cap, toNum, wkSq, bkSq))
|
||||
addPiece(l1, mover, toNum, wkSq, bkSq)
|
||||
}
|
||||
|
||||
private def applyEnPassantDelta(l1: Array[Float], move: Move, board: Board): Unit =
|
||||
private def applyEnPassantDelta(l1: Array[Float], move: Move, board: Board, wkSq: Int, bkSq: Int): Unit =
|
||||
board.pieceAt(move.from).foreach { pawn =>
|
||||
val capturedSq = Square(move.to.file, move.from.rank)
|
||||
subtractColumn(l1, featureIndex(pawn, squareNum(move.from)))
|
||||
board.pieceAt(capturedSq).foreach(cap => subtractColumn(l1, featureIndex(cap, squareNum(capturedSq))))
|
||||
addColumn(l1, featureIndex(pawn, squareNum(move.to)))
|
||||
removePiece(l1, pawn, squareNum(move.from), wkSq, bkSq)
|
||||
board.pieceAt(capturedSq).foreach(cap => removePiece(l1, cap, squareNum(capturedSq), wkSq, bkSq))
|
||||
addPiece(l1, pawn, squareNum(move.to), wkSq, bkSq)
|
||||
}
|
||||
|
||||
private def applyCastleDelta(l1: Array[Float], move: Move, board: Board): Unit =
|
||||
board.pieceAt(move.from).foreach { king =>
|
||||
val rank = move.from.rank
|
||||
val kingside = move.moveType == MoveType.CastleKingside
|
||||
val (rookFrom, rookTo) =
|
||||
if kingside then (Square(File.H, rank), Square(File.F, rank))
|
||||
else (Square(File.A, rank), Square(File.D, rank))
|
||||
val rook = Piece(king.color, PieceType.Rook)
|
||||
subtractColumn(l1, featureIndex(king, squareNum(move.from)))
|
||||
addColumn(l1, featureIndex(king, squareNum(move.to)))
|
||||
subtractColumn(l1, featureIndex(rook, squareNum(rookFrom)))
|
||||
addColumn(l1, featureIndex(rook, squareNum(rookTo)))
|
||||
}
|
||||
|
||||
private def applyPromotionDelta(l1: Array[Float], move: Move, promo: PromotionPiece, board: Board): Unit =
|
||||
private def applyPromotionDelta(
|
||||
l1: Array[Float],
|
||||
move: Move,
|
||||
promo: PromotionPiece,
|
||||
board: Board,
|
||||
wkSq: Int,
|
||||
bkSq: Int,
|
||||
): Unit =
|
||||
board.pieceAt(move.from).foreach { pawn =>
|
||||
val toNum = squareNum(move.to)
|
||||
subtractColumn(l1, featureIndex(pawn, squareNum(move.from)))
|
||||
board.pieceAt(move.to).foreach(cap => subtractColumn(l1, featureIndex(cap, toNum)))
|
||||
addColumn(l1, featureIndex(Piece(pawn.color, promotedType(promo)), toNum))
|
||||
removePiece(l1, pawn, squareNum(move.from), wkSq, bkSq)
|
||||
board.pieceAt(move.to).foreach(cap => removePiece(l1, cap, toNum, wkSq, bkSq))
|
||||
addPiece(l1, Piece(pawn.color, promotedType(promo)), toNum, wkSq, bkSq)
|
||||
}
|
||||
|
||||
private def promotedType(promo: PromotionPiece): PieceType = promo match
|
||||
@@ -154,7 +177,6 @@ class NNUE(model: NbaiModel):
|
||||
score
|
||||
|
||||
def evaluateAtPlyWithValidation(ply: Int, turn: Color, hash: Long, board: Board): Int =
|
||||
// For debugging: validate that incremental accumulator matches recomputation
|
||||
if validateAccum && ply > 0 && ply % 10 != 0 then
|
||||
val isValid = validateAccumulator(ply, board)
|
||||
if !isValid then System.err.println(s"WARNING: NNUE accumulator diverged at ply $ply")
|
||||
@@ -206,9 +228,23 @@ class NNUE(model: NbaiModel):
|
||||
private val legacyL1 = new Array[Float](accSize)
|
||||
|
||||
def evaluate(context: GameContext): Int =
|
||||
// Match training: for Black-to-move positions, mirror the board (ranks flipped,
|
||||
// colours swapped) so the model always sees from the side-to-move's perspective.
|
||||
// The scoreFromOutput negation then converts back to White's absolute perspective.
|
||||
val (wkSq, bkSq, pieces, turn) =
|
||||
if context.turn == Color.Black then
|
||||
val wk = flipSqNum(bkSqOf(context.board)) // flipped Black king → new "White" king
|
||||
val bk = flipSqNum(wkSqOf(context.board)) // flipped White king → new "Black" king
|
||||
val flipped = context.board.pieces.map { case (sq, p) =>
|
||||
(sq, Piece(p.color.opposite, p.pieceType))
|
||||
}
|
||||
(wk, bk, flipped, Color.Black) // pass Black so scoreFromOutput negates the result
|
||||
else (wkSqOf(context.board), bkSqOf(context.board), context.board.pieces, context.turn)
|
||||
System.arraycopy(model.weights(0).bias, 0, legacyL1, 0, accSize)
|
||||
for (sq, piece) <- context.board.pieces do addColumn(legacyL1, featureIndex(piece, squareNum(sq)))
|
||||
runL2toOutput(legacyL1, context.turn)
|
||||
for (sq, piece) <- pieces do
|
||||
val sqNum = if turn == Color.Black then flipSqNum(squareNum(sq)) else squareNum(sq)
|
||||
addPiece(legacyL1, piece, sqNum, wkSq, bkSq)
|
||||
runL2toOutput(legacyL1, turn)
|
||||
|
||||
def benchmark(): Unit =
|
||||
val context = GameContext.initial
|
||||
|
||||
@@ -32,6 +32,8 @@ final class AlphaBetaSearch(
|
||||
private val nodeCount = AtomicInteger(0)
|
||||
private val ordering = MoveOrdering.OrderingContext()
|
||||
|
||||
def lastNodeCount: Int = nodeCount.get()
|
||||
|
||||
private final case class QuiescenceNode(
|
||||
context: GameContext,
|
||||
ply: Int,
|
||||
@@ -47,6 +49,17 @@ final class AlphaBetaSearch(
|
||||
bestMove(context, maxDepth, Set.empty)
|
||||
|
||||
def bestMove(context: GameContext, maxDepth: Int, excludedRootMoves: Set[Move]): Option[Move] =
|
||||
doDepthSearch(context, maxDepth, excludedRootMoves, Map.empty)
|
||||
|
||||
def bestMove(context: GameContext, maxDepth: Int, excludedRootMoves: Set[Move], hints: Map[Move, Int]): Option[Move] =
|
||||
doDepthSearch(context, maxDepth, excludedRootMoves, hints)
|
||||
|
||||
private def doDepthSearch(
|
||||
context: GameContext,
|
||||
maxDepth: Int,
|
||||
excludedRootMoves: Set[Move],
|
||||
hints: Map[Move, Int],
|
||||
): Option[Move] =
|
||||
tt.clear()
|
||||
ordering.clear()
|
||||
weights.initAccumulator(context)
|
||||
@@ -66,6 +79,7 @@ final class AlphaBetaSearch(
|
||||
ASPIRATION_DELTA,
|
||||
rootHash,
|
||||
excludedRootMoves,
|
||||
hints,
|
||||
)
|
||||
(move.orElse(bestSoFar), score)
|
||||
}
|
||||
@@ -78,6 +92,22 @@ 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)
|
||||
|
||||
def bestMoveWithTime(
|
||||
context: GameContext,
|
||||
timeBudgetMs: Long,
|
||||
excludedRootMoves: Set[Move],
|
||||
hints: Map[Move, Int],
|
||||
): Option[Move] =
|
||||
doTimedSearch(context, timeBudgetMs, excludedRootMoves, hints)
|
||||
|
||||
private def doTimedSearch(
|
||||
context: GameContext,
|
||||
timeBudgetMs: Long,
|
||||
excludedRootMoves: Set[Move],
|
||||
hints: Map[Move, Int],
|
||||
): Option[Move] =
|
||||
tt.clear()
|
||||
ordering.clear()
|
||||
weights.initAccumulator(context)
|
||||
@@ -100,6 +130,7 @@ final class AlphaBetaSearch(
|
||||
ASPIRATION_DELTA,
|
||||
rootHash,
|
||||
excludedRootMoves,
|
||||
hints,
|
||||
)
|
||||
loop(move.orElse(bestSoFar), score, depth + 1, depth)
|
||||
|
||||
@@ -124,14 +155,17 @@ final class AlphaBetaSearch(
|
||||
initialWindow: Int,
|
||||
rootHash: Long,
|
||||
excludedRootMoves: Set[Move],
|
||||
hints: Map[Move, Int],
|
||||
): (Int, Option[Move]) =
|
||||
val state = SearchState(rootHash, Map(rootHash -> 1))
|
||||
|
||||
@scala.annotation.tailrec
|
||||
def loop(currentAlpha: Int, currentBeta: Int, delta: Int, attempt: Int): (Int, Option[Move]) =
|
||||
if attempt >= 3 || attempt >= depth then search(context, depth, 0, Window(-INF, INF), state, excludedRootMoves)
|
||||
if attempt >= 3 || attempt >= depth then
|
||||
search(context, depth, 0, Window(-INF, INF), state, excludedRootMoves, hints)
|
||||
else
|
||||
val (score, move) = search(context, depth, 0, Window(currentAlpha, currentBeta), state, excludedRootMoves)
|
||||
val (score, move) =
|
||||
search(context, depth, 0, Window(currentAlpha, currentBeta), state, excludedRootMoves, hints)
|
||||
if score > currentAlpha && score < currentBeta then (score, move)
|
||||
else if score <= currentAlpha then
|
||||
loop(score - delta, currentBeta, math.min(delta * 2, ASPIRATION_DELTA_MAX), attempt + 1)
|
||||
@@ -156,12 +190,14 @@ final class AlphaBetaSearch(
|
||||
beta: Int,
|
||||
state: SearchState,
|
||||
excludedRootMoves: Set[Move],
|
||||
hints: Map[Move, Int],
|
||||
): Option[Int] =
|
||||
val nullCtx = nullMoveContext(context)
|
||||
val nullState = state.advance(ZobristHash.hash(nullCtx))
|
||||
val reductionDepth = math.max(0, depth - 1 - NULL_MOVE_R)
|
||||
weights.copyAccumulator(ply, ply + 1)
|
||||
val (score, _) = search(nullCtx, reductionDepth, ply + 1, Window(-beta, -beta + 1), nullState, excludedRootMoves)
|
||||
val (score, _) =
|
||||
search(nullCtx, reductionDepth, ply + 1, Window(-beta, -beta + 1), nullState, excludedRootMoves, hints)
|
||||
if -score >= beta then Some(beta) else None
|
||||
|
||||
/** Negamax alpha-beta search returning (score, best move). */
|
||||
@@ -172,8 +208,9 @@ final class AlphaBetaSearch(
|
||||
window: Window,
|
||||
state: SearchState,
|
||||
excludedRootMoves: Set[Move],
|
||||
hints: Map[Move, Int],
|
||||
): (Int, Option[Move]) =
|
||||
val params = SearchParams(context, depth, ply, window, state, excludedRootMoves)
|
||||
val params = SearchParams(context, depth, ply, window, state, excludedRootMoves, hints)
|
||||
searchNode(params)
|
||||
|
||||
private def searchNode(params: SearchParams): (Int, Option[Move]) =
|
||||
@@ -235,13 +272,14 @@ final class AlphaBetaSearch(
|
||||
params.window.beta,
|
||||
params.state,
|
||||
params.excludedRootMoves,
|
||||
params.rootHints,
|
||||
),
|
||||
)
|
||||
.flatten
|
||||
|
||||
nullResult.map((_, None)).getOrElse {
|
||||
val ttBest = tt.probe(params.state.hash).flatMap(_.bestMove)
|
||||
val ordered = MoveOrdering.sort(params.context, legalMoves, ttBest, params.ply, ordering)
|
||||
val ordered = MoveOrdering.sort(params.context, legalMoves, ttBest, params.ply, ordering, params.rootHints)
|
||||
searchSequential(
|
||||
params.context,
|
||||
params.depth,
|
||||
@@ -250,6 +288,7 @@ final class AlphaBetaSearch(
|
||||
ordered,
|
||||
params.state,
|
||||
params.excludedRootMoves,
|
||||
params.rootHints,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -280,6 +319,7 @@ final class AlphaBetaSearch(
|
||||
Window(-a - 1, -a),
|
||||
childState,
|
||||
params.excludedRootMoves,
|
||||
params.rootHints,
|
||||
)
|
||||
val s = -rs
|
||||
if s > a then
|
||||
@@ -290,6 +330,7 @@ final class AlphaBetaSearch(
|
||||
Window(betaNeg, -a),
|
||||
childState,
|
||||
params.excludedRootMoves,
|
||||
params.rootHints,
|
||||
)
|
||||
-fs
|
||||
else s
|
||||
@@ -301,6 +342,7 @@ final class AlphaBetaSearch(
|
||||
Window(betaNeg, -a),
|
||||
childState,
|
||||
params.excludedRootMoves,
|
||||
params.rootHints,
|
||||
)
|
||||
-rs
|
||||
|
||||
@@ -364,8 +406,9 @@ final class AlphaBetaSearch(
|
||||
ordered: List[Move],
|
||||
state: SearchState,
|
||||
excludedRootMoves: Set[Move],
|
||||
rootHints: Map[Move, Int] = Map.empty,
|
||||
): (Int, Option[Move]) =
|
||||
val params = SearchParams(context, depth, ply, window, state, excludedRootMoves)
|
||||
val params = SearchParams(context, depth, ply, window, state, excludedRootMoves, rootHints)
|
||||
val (bestMove, bestScore, cutoff) = searchLoop(0, 0, LoopAcc(None, -INF, window.alpha), params, ordered)
|
||||
val flag =
|
||||
if cutoff then TTFlag.Lower
|
||||
|
||||
@@ -38,8 +38,10 @@ object MoveOrdering:
|
||||
ttBestMove: Option[Move],
|
||||
ply: Int = 0,
|
||||
ordering: OrderingContext = new OrderingContext(),
|
||||
rootHints: Map[Move, Int] = Map.empty,
|
||||
): Int =
|
||||
if ttBestMove.exists(m => m.from == move.from && m.to == move.to) then Int.MaxValue
|
||||
else if ply == 0 && rootHints.nonEmpty then rootHints.getOrElse(move, Int.MinValue / 2)
|
||||
else
|
||||
move.moveType match
|
||||
case MoveType.Promotion(PromotionPiece.Queen) =>
|
||||
@@ -56,8 +58,9 @@ object MoveOrdering:
|
||||
ttBestMove: Option[Move],
|
||||
ply: Int = 0,
|
||||
ordering: OrderingContext = new OrderingContext(),
|
||||
rootHints: Map[Move, Int] = Map.empty,
|
||||
): List[Move] =
|
||||
moves.sortBy(m => -score(context, m, ttBestMove, ply, ordering))
|
||||
moves.sortBy(m => -score(context, m, ttBestMove, ply, ordering, rootHints))
|
||||
|
||||
private def scoreQuietMove(move: Move, ply: Int, ordering: OrderingContext): Int =
|
||||
val isKiller = ordering.getKillerMoves(ply).exists(k => k.from == move.from && k.to == move.to)
|
||||
|
||||
@@ -14,6 +14,7 @@ final case class SearchParams(
|
||||
window: Window,
|
||||
state: SearchState,
|
||||
excludedRootMoves: Set[Move],
|
||||
rootHints: Map[Move, Int] = Map.empty,
|
||||
)
|
||||
|
||||
final case class SearchState(hash: Long, repetitions: Map[Long, Int]):
|
||||
|
||||
@@ -312,6 +312,24 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
|
||||
val search = AlphaBetaSearch(qRules, weights = ZeroEval)
|
||||
search.bestMove(GameContext.initial, maxDepth = 1) should be(Some(rootMove))
|
||||
|
||||
test("bestMove with root hints returns a valid move without regression"):
|
||||
val context = GameContext.initial
|
||||
val legalMoves = DefaultRules.allLegalMoves(context)
|
||||
val hints = legalMoves.zipWithIndex.map { case (m, i) => m -> (legalMoves.length - i) }.toMap
|
||||
val withHints = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
|
||||
.bestMove(context, maxDepth = 2, Set.empty, hints)
|
||||
withHints should not be None
|
||||
legalMoves should contain(withHints.get)
|
||||
|
||||
test("bestMoveWithTime with root hints returns a valid move without regression"):
|
||||
val context = GameContext.initial
|
||||
val legalMoves = DefaultRules.allLegalMoves(context)
|
||||
val hints = legalMoves.zipWithIndex.map { case (m, i) => m -> (legalMoves.length - i) }.toMap
|
||||
val withHints = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
|
||||
.bestMoveWithTime(context, 500L, Set.empty, hints)
|
||||
withHints should not be None
|
||||
legalMoves should contain(withHints.get)
|
||||
|
||||
test("quiescence depth-limit in-check branch is exercised"):
|
||||
val rootMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal())
|
||||
val capMove = Move(Square(File.D, Rank.R2), Square(File.D, Rank.R3), MoveType.Normal(true))
|
||||
|
||||
@@ -85,17 +85,17 @@ class HybridBotTest extends AnyFunSuite with Matchers:
|
||||
private val altMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal())
|
||||
|
||||
private def vetoRules: RuleSet = new RuleSet:
|
||||
private def fresh(ctx: GameContext): Boolean = ctx.moves.isEmpty
|
||||
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
|
||||
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
|
||||
def allLegalMoves(context: GameContext): List[Move] =
|
||||
private def fresh(ctx: GameContext): Boolean = ctx.moves.isEmpty
|
||||
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
|
||||
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
|
||||
def allLegalMoves(context: GameContext): List[Move] =
|
||||
if fresh(context) then List(mateMove, altMove) else Nil
|
||||
def isCheck(context: GameContext): Boolean = false
|
||||
def isCheckmate(context: GameContext): Boolean = context.moves.lastOption.contains(mateMove)
|
||||
def isStalemate(context: GameContext): Boolean = context.moves.lastOption.contains(altMove)
|
||||
def isInsufficientMaterial(context: GameContext): Boolean = false
|
||||
def isFiftyMoveRule(context: GameContext): Boolean = false
|
||||
def isThreefoldRepetition(context: GameContext): Boolean = false
|
||||
def isCheck(context: GameContext): Boolean = false
|
||||
def isCheckmate(context: GameContext): Boolean = context.moves.lastOption.contains(mateMove)
|
||||
def isStalemate(context: GameContext): Boolean = context.moves.lastOption.contains(altMove)
|
||||
def isInsufficientMaterial(context: GameContext): Boolean = false
|
||||
def isFiftyMoveRule(context: GameContext): Boolean = false
|
||||
def isThreefoldRepetition(context: GameContext): Boolean = false
|
||||
def applyMove(context: GameContext)(move: Move): GameContext =
|
||||
context.copy(turn = context.turn.opposite, moves = context.moves :+ move)
|
||||
|
||||
|
||||
@@ -217,3 +217,60 @@ class MoveOrderingTest extends AnyFunSuite with Matchers:
|
||||
val castle = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)
|
||||
|
||||
MoveOrdering.score(context, castle, None) should be(0)
|
||||
|
||||
test("root hints override capture heuristics at ply 0"):
|
||||
val board = Board(
|
||||
Map(
|
||||
Square(File.E, Rank.R4) -> Piece.WhiteQueen,
|
||||
Square(File.E, Rank.R5) -> Piece.BlackPawn,
|
||||
Square(File.D, Rank.R5) -> Piece.BlackRook,
|
||||
),
|
||||
)
|
||||
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
|
||||
val quietMove = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R6))
|
||||
val rookCapture = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true))
|
||||
val hints = Map(quietMove -> 500, rookCapture -> 100)
|
||||
|
||||
MoveOrdering.score(context, quietMove, None, ply = 0, rootHints = hints) should equal(500)
|
||||
MoveOrdering.score(context, rookCapture, None, ply = 0, rootHints = hints) should equal(100)
|
||||
MoveOrdering.score(context, rookCapture, None, ply = 0, rootHints = hints) should be <
|
||||
MoveOrdering.score(context, quietMove, None, ply = 0, rootHints = hints)
|
||||
|
||||
test("root hints ignored at ply > 0"):
|
||||
val board = Board(Map(Square(File.E, Rank.R4) -> Piece.WhiteQueen, Square(File.E, Rank.R5) -> Piece.BlackPawn))
|
||||
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
|
||||
val capture = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5), MoveType.Normal(true))
|
||||
val quiet = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R4))
|
||||
val hints = Map(quiet -> 99999, capture -> -99999)
|
||||
|
||||
val captureScore = MoveOrdering.score(context, capture, None, ply = 1, rootHints = hints)
|
||||
val quietScore = MoveOrdering.score(context, quiet, None, ply = 1, rootHints = hints)
|
||||
captureScore should be > quietScore
|
||||
|
||||
test("move absent from root hints gets Int.MinValue / 2 fallback"):
|
||||
val board = Board(Map(Square(File.E, Rank.R4) -> Piece.WhiteQueen))
|
||||
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
|
||||
val move1 = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R6))
|
||||
val move2 = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5))
|
||||
val hints = Map(move1 -> 0)
|
||||
|
||||
MoveOrdering.score(context, move2, None, ply = 0, rootHints = hints) should equal(Int.MinValue / 2)
|
||||
|
||||
test("sort uses root hints at ply 0 to reorder moves"):
|
||||
val board = Board(
|
||||
Map(
|
||||
Square(File.E, Rank.R4) -> Piece.WhiteQueen,
|
||||
Square(File.E, Rank.R5) -> Piece.BlackPawn,
|
||||
Square(File.D, Rank.R5) -> Piece.BlackRook,
|
||||
),
|
||||
)
|
||||
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
|
||||
val rookCapture = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true))
|
||||
val pawnCapture = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5), MoveType.Normal(true))
|
||||
val quiet = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R6))
|
||||
val hints = Map(quiet -> 9999, pawnCapture -> 500, rookCapture -> 100)
|
||||
|
||||
val sorted = MoveOrdering.sort(context, List(rookCapture, pawnCapture, quiet), None, ply = 0, rootHints = hints)
|
||||
sorted.head should equal(quiet)
|
||||
sorted(1) should equal(pawnCapture)
|
||||
sorted(2) should equal(rookCapture)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=36
|
||||
MINOR=38
|
||||
PATCH=0
|
||||
|
||||
Reference in New Issue
Block a user