Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8cbcdca3b | |||
| e4fee85134 | |||
| b4709b4a33 | |||
| 9f9140cb58 | |||
| fa10852bc9 | |||
| 44f376f032 |
@@ -987,3 +987,108 @@
|
|||||||
### Reverts
|
### Reverts
|
||||||
|
|
||||||
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
* 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]
|
eval_val = self.evals[idx]
|
||||||
features = fen_to_features(fen)
|
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
|
# Use evaluation as-is if normalized, otherwise apply sigmoid scaling
|
||||||
if self.is_normalized:
|
if self.is_normalized:
|
||||||
target = torch.tensor(eval_val, dtype=torch.float32)
|
target = torch.tensor(eval_val, dtype=torch.float32)
|
||||||
@@ -61,38 +66,59 @@ class NNUEDataset(Dataset):
|
|||||||
|
|
||||||
return features, target
|
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):
|
def fen_to_features(fen):
|
||||||
"""Convert FEN to 768-dimensional binary feature vector."""
|
"""Convert FEN to 98304-dim king-relative (HalfKP) 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)
|
|
||||||
|
|
||||||
|
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:
|
try:
|
||||||
board = chess.Board(fen)
|
board = chess.Board(fen)
|
||||||
|
# Perspective flip: present all positions as if White is to move
|
||||||
# 12 piece types × 64 squares = 768
|
if board.turn == chess.BLACK:
|
||||||
for square in chess.SQUARES:
|
board = board.mirror()
|
||||||
piece = board.piece_at(square)
|
wk = board.king(chess.WHITE)
|
||||||
if piece is not None:
|
bk = board.king(chess.BLACK)
|
||||||
piece_char = piece.symbol()
|
if wk is None or bk is None:
|
||||||
if piece_char in piece_to_idx:
|
return features
|
||||||
piece_idx = piece_to_idx[piece_char]
|
for sq in chess.SQUARES:
|
||||||
feature_idx = piece_idx * 64 + square
|
piece = board.piece_at(sq)
|
||||||
features[feature_idx] = 1.0
|
if piece is None:
|
||||||
except:
|
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
|
pass
|
||||||
|
|
||||||
return features
|
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):
|
class NNUE(nn.Module):
|
||||||
"""NNUE neural network with configurable hidden layers.
|
"""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
|
Layer attributes follow the naming l1, l2, ..., lN so export.py can
|
||||||
infer the architecture directly from the state_dict.
|
infer the architecture directly from the state_dict.
|
||||||
"""
|
"""
|
||||||
@@ -102,7 +128,7 @@ class NNUE(nn.Module):
|
|||||||
if hidden_sizes is None:
|
if hidden_sizes is None:
|
||||||
hidden_sizes = DEFAULT_HIDDEN_SIZES
|
hidden_sizes = DEFAULT_HIDDEN_SIZES
|
||||||
self.hidden_sizes = list(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)
|
num_hidden = len(self.hidden_sizes)
|
||||||
|
|
||||||
for i in range(num_hidden):
|
for i in range(num_hidden):
|
||||||
|
|||||||
Binary file not shown.
@@ -28,7 +28,7 @@ object NNUEBot:
|
|||||||
else
|
else
|
||||||
val scored = batchEvaluateRoot(rules, context, moves)
|
val scored = batchEvaluateRoot(rules, context, moves)
|
||||||
val bestMove = scored.maxBy(_._2)._1
|
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)] =
|
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)
|
nnue.copyAccumulator(parentPly, childPly)
|
||||||
|
|
||||||
override def pushAccumulator(childPly: Int, move: Move, parent: GameContext, child: GameContext): Unit =
|
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)
|
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 =
|
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)
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
package de.nowchess.bot.bots.nnue
|
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.game.GameContext
|
||||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
|
|
||||||
class NNUE(model: NbaiModel):
|
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 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.
|
// Column-major L1 weights: l1WeightsT(featureIdx * accSize + outputIdx)
|
||||||
// l1WeightsT(featureIdx * accSize + outputIdx) = l1Weights(outputIdx * featureSize + featureIdx)
|
|
||||||
private val l1WeightsT: Array[Float] =
|
private val l1WeightsT: Array[Float] =
|
||||||
val w = model.weights(0).weights
|
val w = model.weights(0).weights
|
||||||
val t = new Array[Float](featureSize * accSize)
|
val t = new Array[Float](featureSize * accSize)
|
||||||
@@ -23,7 +23,6 @@ class NNUE(model: NbaiModel):
|
|||||||
private val MAX_PLY = 128
|
private val MAX_PLY = 128
|
||||||
private val l1Stack: Array[Array[Float]] = Array.fill(MAX_PLY + 1)(new Array[Float](accSize))
|
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))
|
private val evalBuffers: Array[Array[Float]] = model.layers.init.map(l => new Array[Float](l.outputSize))
|
||||||
|
|
||||||
// ── Eval cache ───────────────────────────────────────────────────────────
|
// ── 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 squareNum(sq: Square): Int = sq.rank.ordinal * 8 + sq.file.ordinal
|
||||||
|
|
||||||
private def featureIndex(piece: Piece, sqNum: Int): Int =
|
// Mirror square vertically (rank 0 ↔ rank 7) for the perspective flip
|
||||||
val colorOffset = if piece.color == Color.White then 6 else 0
|
private def flipSqNum(sqNum: Int): Int = (7 - sqNum / 8) * 8 + sqNum % 8
|
||||||
(colorOffset + piece.pieceType.ordinal) * 64 + sqNum
|
|
||||||
|
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 =
|
private def addColumn(l1Pre: Array[Float], featureIdx: Int): Unit =
|
||||||
val offset = featureIdx * accSize
|
val offset = featureIdx * accSize
|
||||||
@@ -48,92 +67,96 @@ class NNUE(model: NbaiModel):
|
|||||||
val offset = featureIdx * accSize
|
val offset = featureIdx * accSize
|
||||||
for i <- 0 until accSize do l1Pre(i) -= l1WeightsT(offset + i)
|
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 ─────────────────────────────────────────────────────
|
// ── Accumulator init ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
def initAccumulator(board: Board): Unit =
|
def initAccumulator(board: Board): Unit =
|
||||||
|
val wkSq = wkSqOf(board)
|
||||||
|
val bkSq = bkSqOf(board)
|
||||||
System.arraycopy(model.weights(0).bias, 0, l1Stack(0), 0, accSize)
|
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) ───────────────────────────────
|
// ── 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)
|
System.arraycopy(l1Stack(childPly - 1), 0, l1Stack(childPly), 0, accSize)
|
||||||
val l1 = l1Stack(childPly)
|
if isKingMove(move, parentBoard) then recomputeAccumulatorInto(l1Stack(childPly), childBoard)
|
||||||
move.moveType match
|
else applyNonKingDelta(l1Stack(childPly), move, parentBoard)
|
||||||
case MoveType.Normal(_) => applyNormalDelta(l1, move, board)
|
|
||||||
case MoveType.EnPassant => applyEnPassantDelta(l1, move, board)
|
private def isKingMove(move: Move, board: Board): Boolean =
|
||||||
case MoveType.CastleKingside | MoveType.CastleQueenside => applyCastleDelta(l1, move, board)
|
move.moveType == MoveType.CastleKingside ||
|
||||||
case MoveType.Promotion(p) => applyPromotionDelta(l1, move, p, board)
|
move.moveType == MoveType.CastleQueenside ||
|
||||||
|
board.pieceAt(move.from).exists(_.pieceType == PieceType.King)
|
||||||
|
|
||||||
def copyAccumulator(parentPly: Int, childPly: Int): Unit =
|
def copyAccumulator(parentPly: Int, childPly: Int): Unit =
|
||||||
System.arraycopy(l1Stack(parentPly), 0, l1Stack(childPly), 0, accSize)
|
System.arraycopy(l1Stack(parentPly), 0, l1Stack(childPly), 0, accSize)
|
||||||
|
|
||||||
def recomputeAccumulator(ply: Int, board: Board): Unit =
|
def recomputeAccumulator(ply: Int, board: Board): Unit =
|
||||||
System.arraycopy(model.weights(0).bias, 0, l1Stack(ply), 0, accSize)
|
recomputeAccumulatorInto(l1Stack(ply), board)
|
||||||
for (sq, piece) <- board.pieces do addColumn(l1Stack(ply), featureIndex(piece, squareNum(sq)))
|
|
||||||
|
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 =
|
def validateAccumulator(ply: Int, board: Board): Boolean =
|
||||||
// Compute what L1 should be from scratch
|
val expected = new Array[Float](accSize)
|
||||||
val expectedL1 = new Array[Float](accSize)
|
val wkSq = wkSqOf(board)
|
||||||
System.arraycopy(model.weights(0).bias, 0, expectedL1, 0, accSize)
|
val bkSq = bkSqOf(board)
|
||||||
for (sq, piece) <- board.pieces do addColumn(expectedL1, featureIndex(piece, squareNum(sq)))
|
System.arraycopy(model.weights(0).bias, 0, expected, 0, accSize)
|
||||||
|
for (sq, piece) <- board.pieces do addPiece(expected, piece, squareNum(sq), wkSq, bkSq)
|
||||||
// Compare with actual L1
|
|
||||||
val actual = l1Stack(ply)
|
val actual = l1Stack(ply)
|
||||||
val maxError =
|
(0 until accSize).forall(i => math.abs(actual(i) - expected(i)) < 0.001f)
|
||||||
(0 until accSize).foldLeft(0f) { (currentMax, i) =>
|
|
||||||
val error = math.abs(actual(i) - expectedL1(i))
|
|
||||||
math.max(currentMax, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
maxError < 0.001f // Allow small floating-point errors
|
// ── Non-king incremental deltas ──────────────────────────────────────────
|
||||||
|
|
||||||
private def applyNormalDelta(l1: Array[Float], move: Move, board: Board): Unit =
|
private def applyNonKingDelta(l1: Array[Float], move: Move, board: Board): Unit =
|
||||||
// Extract source and destination square indices early
|
val wkSq = wkSqOf(board)
|
||||||
val fromNum = squareNum(move.from)
|
val bkSq = bkSqOf(board)
|
||||||
val toNum = squareNum(move.to)
|
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 =>
|
board.pieceAt(move.from).foreach { mover =>
|
||||||
subtractColumn(l1, featureIndex(mover, fromNum))
|
val fromNum = squareNum(move.from)
|
||||||
|
val toNum = squareNum(move.to)
|
||||||
// If there's a capture, subtract the captured piece
|
removePiece(l1, mover, fromNum, wkSq, bkSq)
|
||||||
board.pieceAt(move.to).foreach { cap =>
|
board.pieceAt(move.to).foreach(cap => removePiece(l1, cap, toNum, wkSq, bkSq))
|
||||||
subtractColumn(l1, featureIndex(cap, toNum))
|
addPiece(l1, mover, toNum, wkSq, bkSq)
|
||||||
}
|
|
||||||
|
|
||||||
// Add the piece to its new location
|
|
||||||
addColumn(l1, featureIndex(mover, toNum))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 =>
|
board.pieceAt(move.from).foreach { pawn =>
|
||||||
val capturedSq = Square(move.to.file, move.from.rank)
|
val capturedSq = Square(move.to.file, move.from.rank)
|
||||||
subtractColumn(l1, featureIndex(pawn, squareNum(move.from)))
|
removePiece(l1, pawn, squareNum(move.from), wkSq, bkSq)
|
||||||
board.pieceAt(capturedSq).foreach(cap => subtractColumn(l1, featureIndex(cap, squareNum(capturedSq))))
|
board.pieceAt(capturedSq).foreach(cap => removePiece(l1, cap, squareNum(capturedSq), wkSq, bkSq))
|
||||||
addColumn(l1, featureIndex(pawn, squareNum(move.to)))
|
addPiece(l1, pawn, squareNum(move.to), wkSq, bkSq)
|
||||||
}
|
}
|
||||||
|
|
||||||
private def applyCastleDelta(l1: Array[Float], move: Move, board: Board): Unit =
|
private def applyPromotionDelta(
|
||||||
board.pieceAt(move.from).foreach { king =>
|
l1: Array[Float],
|
||||||
val rank = move.from.rank
|
move: Move,
|
||||||
val kingside = move.moveType == MoveType.CastleKingside
|
promo: PromotionPiece,
|
||||||
val (rookFrom, rookTo) =
|
board: Board,
|
||||||
if kingside then (Square(File.H, rank), Square(File.F, rank))
|
wkSq: Int,
|
||||||
else (Square(File.A, rank), Square(File.D, rank))
|
bkSq: Int,
|
||||||
val rook = Piece(king.color, PieceType.Rook)
|
): Unit =
|
||||||
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 =
|
|
||||||
board.pieceAt(move.from).foreach { pawn =>
|
board.pieceAt(move.from).foreach { pawn =>
|
||||||
val toNum = squareNum(move.to)
|
val toNum = squareNum(move.to)
|
||||||
subtractColumn(l1, featureIndex(pawn, squareNum(move.from)))
|
removePiece(l1, pawn, squareNum(move.from), wkSq, bkSq)
|
||||||
board.pieceAt(move.to).foreach(cap => subtractColumn(l1, featureIndex(cap, toNum)))
|
board.pieceAt(move.to).foreach(cap => removePiece(l1, cap, toNum, wkSq, bkSq))
|
||||||
addColumn(l1, featureIndex(Piece(pawn.color, promotedType(promo)), toNum))
|
addPiece(l1, Piece(pawn.color, promotedType(promo)), toNum, wkSq, bkSq)
|
||||||
}
|
}
|
||||||
|
|
||||||
private def promotedType(promo: PromotionPiece): PieceType = promo match
|
private def promotedType(promo: PromotionPiece): PieceType = promo match
|
||||||
@@ -154,7 +177,6 @@ class NNUE(model: NbaiModel):
|
|||||||
score
|
score
|
||||||
|
|
||||||
def evaluateAtPlyWithValidation(ply: Int, turn: Color, hash: Long, board: Board): Int =
|
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
|
if validateAccum && ply > 0 && ply % 10 != 0 then
|
||||||
val isValid = validateAccumulator(ply, board)
|
val isValid = validateAccumulator(ply, board)
|
||||||
if !isValid then System.err.println(s"WARNING: NNUE accumulator diverged at ply $ply")
|
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)
|
private val legacyL1 = new Array[Float](accSize)
|
||||||
|
|
||||||
def evaluate(context: GameContext): Int =
|
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)
|
System.arraycopy(model.weights(0).bias, 0, legacyL1, 0, accSize)
|
||||||
for (sq, piece) <- context.board.pieces do addColumn(legacyL1, featureIndex(piece, squareNum(sq)))
|
for (sq, piece) <- pieces do
|
||||||
runL2toOutput(legacyL1, context.turn)
|
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 =
|
def benchmark(): Unit =
|
||||||
val context = GameContext.initial
|
val context = GameContext.initial
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ final class AlphaBetaSearch(
|
|||||||
private val nodeCount = AtomicInteger(0)
|
private val nodeCount = AtomicInteger(0)
|
||||||
private val ordering = MoveOrdering.OrderingContext()
|
private val ordering = MoveOrdering.OrderingContext()
|
||||||
|
|
||||||
|
def lastNodeCount: Int = nodeCount.get()
|
||||||
|
|
||||||
private final case class QuiescenceNode(
|
private final case class QuiescenceNode(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
ply: Int,
|
ply: Int,
|
||||||
@@ -47,6 +49,17 @@ final class AlphaBetaSearch(
|
|||||||
bestMove(context, maxDepth, Set.empty)
|
bestMove(context, maxDepth, Set.empty)
|
||||||
|
|
||||||
def bestMove(context: GameContext, maxDepth: Int, excludedRootMoves: Set[Move]): Option[Move] =
|
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()
|
tt.clear()
|
||||||
ordering.clear()
|
ordering.clear()
|
||||||
weights.initAccumulator(context)
|
weights.initAccumulator(context)
|
||||||
@@ -66,6 +79,7 @@ final class AlphaBetaSearch(
|
|||||||
ASPIRATION_DELTA,
|
ASPIRATION_DELTA,
|
||||||
rootHash,
|
rootHash,
|
||||||
excludedRootMoves,
|
excludedRootMoves,
|
||||||
|
hints,
|
||||||
)
|
)
|
||||||
(move.orElse(bestSoFar), score)
|
(move.orElse(bestSoFar), score)
|
||||||
}
|
}
|
||||||
@@ -78,6 +92,22 @@ final class AlphaBetaSearch(
|
|||||||
bestMoveWithTime(context, timeBudgetMs, Set.empty)
|
bestMoveWithTime(context, timeBudgetMs, Set.empty)
|
||||||
|
|
||||||
def bestMoveWithTime(context: GameContext, timeBudgetMs: Long, excludedRootMoves: Set[Move]): Option[Move] =
|
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()
|
tt.clear()
|
||||||
ordering.clear()
|
ordering.clear()
|
||||||
weights.initAccumulator(context)
|
weights.initAccumulator(context)
|
||||||
@@ -100,6 +130,7 @@ final class AlphaBetaSearch(
|
|||||||
ASPIRATION_DELTA,
|
ASPIRATION_DELTA,
|
||||||
rootHash,
|
rootHash,
|
||||||
excludedRootMoves,
|
excludedRootMoves,
|
||||||
|
hints,
|
||||||
)
|
)
|
||||||
loop(move.orElse(bestSoFar), score, depth + 1, depth)
|
loop(move.orElse(bestSoFar), score, depth + 1, depth)
|
||||||
|
|
||||||
@@ -124,14 +155,17 @@ final class AlphaBetaSearch(
|
|||||||
initialWindow: Int,
|
initialWindow: Int,
|
||||||
rootHash: Long,
|
rootHash: Long,
|
||||||
excludedRootMoves: Set[Move],
|
excludedRootMoves: Set[Move],
|
||||||
|
hints: Map[Move, Int],
|
||||||
): (Int, Option[Move]) =
|
): (Int, Option[Move]) =
|
||||||
val state = SearchState(rootHash, Map(rootHash -> 1))
|
val state = SearchState(rootHash, Map(rootHash -> 1))
|
||||||
|
|
||||||
@scala.annotation.tailrec
|
@scala.annotation.tailrec
|
||||||
def loop(currentAlpha: Int, currentBeta: Int, delta: Int, attempt: Int): (Int, Option[Move]) =
|
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
|
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)
|
if score > currentAlpha && score < currentBeta then (score, move)
|
||||||
else if score <= currentAlpha then
|
else if score <= currentAlpha then
|
||||||
loop(score - delta, currentBeta, math.min(delta * 2, ASPIRATION_DELTA_MAX), attempt + 1)
|
loop(score - delta, currentBeta, math.min(delta * 2, ASPIRATION_DELTA_MAX), attempt + 1)
|
||||||
@@ -156,12 +190,14 @@ final class AlphaBetaSearch(
|
|||||||
beta: Int,
|
beta: Int,
|
||||||
state: SearchState,
|
state: SearchState,
|
||||||
excludedRootMoves: Set[Move],
|
excludedRootMoves: Set[Move],
|
||||||
|
hints: Map[Move, Int],
|
||||||
): Option[Int] =
|
): Option[Int] =
|
||||||
val nullCtx = nullMoveContext(context)
|
val nullCtx = nullMoveContext(context)
|
||||||
val nullState = state.advance(ZobristHash.hash(nullCtx))
|
val nullState = state.advance(ZobristHash.hash(nullCtx))
|
||||||
val reductionDepth = math.max(0, depth - 1 - NULL_MOVE_R)
|
val reductionDepth = math.max(0, depth - 1 - NULL_MOVE_R)
|
||||||
weights.copyAccumulator(ply, ply + 1)
|
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
|
if -score >= beta then Some(beta) else None
|
||||||
|
|
||||||
/** Negamax alpha-beta search returning (score, best move). */
|
/** Negamax alpha-beta search returning (score, best move). */
|
||||||
@@ -172,8 +208,9 @@ final class AlphaBetaSearch(
|
|||||||
window: Window,
|
window: Window,
|
||||||
state: SearchState,
|
state: SearchState,
|
||||||
excludedRootMoves: Set[Move],
|
excludedRootMoves: Set[Move],
|
||||||
|
hints: Map[Move, Int],
|
||||||
): (Int, Option[Move]) =
|
): (Int, Option[Move]) =
|
||||||
val params = SearchParams(context, depth, ply, window, state, excludedRootMoves)
|
val params = SearchParams(context, depth, ply, window, state, excludedRootMoves, hints)
|
||||||
searchNode(params)
|
searchNode(params)
|
||||||
|
|
||||||
private def searchNode(params: SearchParams): (Int, Option[Move]) =
|
private def searchNode(params: SearchParams): (Int, Option[Move]) =
|
||||||
@@ -235,13 +272,14 @@ final class AlphaBetaSearch(
|
|||||||
params.window.beta,
|
params.window.beta,
|
||||||
params.state,
|
params.state,
|
||||||
params.excludedRootMoves,
|
params.excludedRootMoves,
|
||||||
|
params.rootHints,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.flatten
|
.flatten
|
||||||
|
|
||||||
nullResult.map((_, None)).getOrElse {
|
nullResult.map((_, None)).getOrElse {
|
||||||
val ttBest = tt.probe(params.state.hash).flatMap(_.bestMove)
|
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(
|
searchSequential(
|
||||||
params.context,
|
params.context,
|
||||||
params.depth,
|
params.depth,
|
||||||
@@ -250,6 +288,7 @@ final class AlphaBetaSearch(
|
|||||||
ordered,
|
ordered,
|
||||||
params.state,
|
params.state,
|
||||||
params.excludedRootMoves,
|
params.excludedRootMoves,
|
||||||
|
params.rootHints,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,6 +319,7 @@ final class AlphaBetaSearch(
|
|||||||
Window(-a - 1, -a),
|
Window(-a - 1, -a),
|
||||||
childState,
|
childState,
|
||||||
params.excludedRootMoves,
|
params.excludedRootMoves,
|
||||||
|
params.rootHints,
|
||||||
)
|
)
|
||||||
val s = -rs
|
val s = -rs
|
||||||
if s > a then
|
if s > a then
|
||||||
@@ -290,6 +330,7 @@ final class AlphaBetaSearch(
|
|||||||
Window(betaNeg, -a),
|
Window(betaNeg, -a),
|
||||||
childState,
|
childState,
|
||||||
params.excludedRootMoves,
|
params.excludedRootMoves,
|
||||||
|
params.rootHints,
|
||||||
)
|
)
|
||||||
-fs
|
-fs
|
||||||
else s
|
else s
|
||||||
@@ -301,6 +342,7 @@ final class AlphaBetaSearch(
|
|||||||
Window(betaNeg, -a),
|
Window(betaNeg, -a),
|
||||||
childState,
|
childState,
|
||||||
params.excludedRootMoves,
|
params.excludedRootMoves,
|
||||||
|
params.rootHints,
|
||||||
)
|
)
|
||||||
-rs
|
-rs
|
||||||
|
|
||||||
@@ -364,8 +406,9 @@ final class AlphaBetaSearch(
|
|||||||
ordered: List[Move],
|
ordered: List[Move],
|
||||||
state: SearchState,
|
state: SearchState,
|
||||||
excludedRootMoves: Set[Move],
|
excludedRootMoves: Set[Move],
|
||||||
|
rootHints: Map[Move, Int] = Map.empty,
|
||||||
): (Int, Option[Move]) =
|
): (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 (bestMove, bestScore, cutoff) = searchLoop(0, 0, LoopAcc(None, -INF, window.alpha), params, ordered)
|
||||||
val flag =
|
val flag =
|
||||||
if cutoff then TTFlag.Lower
|
if cutoff then TTFlag.Lower
|
||||||
|
|||||||
@@ -38,8 +38,10 @@ object MoveOrdering:
|
|||||||
ttBestMove: Option[Move],
|
ttBestMove: Option[Move],
|
||||||
ply: Int = 0,
|
ply: Int = 0,
|
||||||
ordering: OrderingContext = new OrderingContext(),
|
ordering: OrderingContext = new OrderingContext(),
|
||||||
|
rootHints: Map[Move, Int] = Map.empty,
|
||||||
): Int =
|
): Int =
|
||||||
if ttBestMove.exists(m => m.from == move.from && m.to == move.to) then Int.MaxValue
|
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
|
else
|
||||||
move.moveType match
|
move.moveType match
|
||||||
case MoveType.Promotion(PromotionPiece.Queen) =>
|
case MoveType.Promotion(PromotionPiece.Queen) =>
|
||||||
@@ -56,8 +58,9 @@ object MoveOrdering:
|
|||||||
ttBestMove: Option[Move],
|
ttBestMove: Option[Move],
|
||||||
ply: Int = 0,
|
ply: Int = 0,
|
||||||
ordering: OrderingContext = new OrderingContext(),
|
ordering: OrderingContext = new OrderingContext(),
|
||||||
|
rootHints: Map[Move, Int] = Map.empty,
|
||||||
): List[Move] =
|
): 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 =
|
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)
|
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,
|
window: Window,
|
||||||
state: SearchState,
|
state: SearchState,
|
||||||
excludedRootMoves: Set[Move],
|
excludedRootMoves: Set[Move],
|
||||||
|
rootHints: Map[Move, Int] = Map.empty,
|
||||||
)
|
)
|
||||||
|
|
||||||
final case class SearchState(hash: Long, repetitions: Map[Long, Int]):
|
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)
|
val search = AlphaBetaSearch(qRules, weights = ZeroEval)
|
||||||
search.bestMove(GameContext.initial, maxDepth = 1) should be(Some(rootMove))
|
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"):
|
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 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))
|
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 val altMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal())
|
||||||
|
|
||||||
private def vetoRules: RuleSet = new RuleSet:
|
private def vetoRules: RuleSet = new RuleSet:
|
||||||
private def fresh(ctx: GameContext): Boolean = ctx.moves.isEmpty
|
private def fresh(ctx: GameContext): Boolean = ctx.moves.isEmpty
|
||||||
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
|
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
|
||||||
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
|
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
|
||||||
def allLegalMoves(context: GameContext): List[Move] =
|
def allLegalMoves(context: GameContext): List[Move] =
|
||||||
if fresh(context) then List(mateMove, altMove) else Nil
|
if fresh(context) then List(mateMove, altMove) else Nil
|
||||||
def isCheck(context: GameContext): Boolean = false
|
def isCheck(context: GameContext): Boolean = false
|
||||||
def isCheckmate(context: GameContext): Boolean = context.moves.lastOption.contains(mateMove)
|
def isCheckmate(context: GameContext): Boolean = context.moves.lastOption.contains(mateMove)
|
||||||
def isStalemate(context: GameContext): Boolean = context.moves.lastOption.contains(altMove)
|
def isStalemate(context: GameContext): Boolean = context.moves.lastOption.contains(altMove)
|
||||||
def isInsufficientMaterial(context: GameContext): Boolean = false
|
def isInsufficientMaterial(context: GameContext): Boolean = false
|
||||||
def isFiftyMoveRule(context: GameContext): Boolean = false
|
def isFiftyMoveRule(context: GameContext): Boolean = false
|
||||||
def isThreefoldRepetition(context: GameContext): Boolean = false
|
def isThreefoldRepetition(context: GameContext): Boolean = false
|
||||||
def applyMove(context: GameContext)(move: Move): GameContext =
|
def applyMove(context: GameContext)(move: Move): GameContext =
|
||||||
context.copy(turn = context.turn.opposite, moves = context.moves :+ move)
|
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)
|
val castle = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)
|
||||||
|
|
||||||
MoveOrdering.score(context, castle, None) should be(0)
|
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
|
MAJOR=0
|
||||||
MINOR=36
|
MINOR=38
|
||||||
PATCH=0
|
PATCH=0
|
||||||
|
|||||||
Reference in New Issue
Block a user