diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6c74f61 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d94825..b9d2b35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,14 @@ name: CI on: + workflow_dispatch: push: branches: [main] pull_request: +permissions: + contents: read + jobs: test: name: Test @@ -38,6 +42,13 @@ jobs: - name: Smoke test the engine run: | printf 'boardsize 9\nname\nversion\nplay b D4\ngenmove w\nshowboard\nquit\n' | ./build/stonehue.exe | grep -c '^=' + - name: Smoke test search evidence + run: | + printf 'stonehue_iterations 0 +genmove b +stonehue_search_info +quit +' | ./build/stonehue.exe | grep -q 'transposition-entries 1' - name: Smoke test SGF export run: | printf 'boardsize 9\nplay b D4\nplay w F4\nplay b D6\nprintsgf\nquit\n' | ./build/stonehue.exe | grep -q 'FF\[4\]' diff --git a/CHANGELOG.md b/CHANGELOG.md index 31246a0..9bd195a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,15 @@ This project uses semantic versioning. ## Unreleased -No changes yet. +Search depth slice. + +Added: + +- State-safe MCTS transposition statistics. +- `stonehue_iterations` for exact search budgets. +- `stonehue_search_info` for search table evidence. +- Deterministic search, engine, and GTP tests. +- A GTP session that demonstrates the table contract. ## 0.2.0 - 2026-08-03 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..66fc640 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# Contributing + +Read `README.md` before changing the engine. +Keep public APIs stable unless tests require a correction. + +## Checks + +Install SBCL and FiveAM. +Run clean compilation. +Run the FiveAM suite. +Run the standalone build. + +```text +sbcl --load scripts/check-clean.lisp +sbcl --load scripts/run-tests.lisp +sbcl --load scripts/build.lisp +``` + +Keep generated files out of commits. +The repository ignores Lisp fasls and build output. + +## Changes + +Add deterministic tests for core behavior. +Update the README when commands or behavior change. +Update `ROADMAP.md` when roadmap work changes. +Keep examples runnable through GTP. + +Use active voice in public documentation. +Keep instructions short. +Describe limitations without performance claims. \ No newline at end of file diff --git a/README.md b/README.md index 353b7a5..e1ff892 100644 --- a/README.md +++ b/README.md @@ -1,231 +1,284 @@ # Stonehue -Stonehue is a Go engine for small boards. It applies the capture rules -and simple area scoring. It chooses moves with Monte Carlo tree search. -It answers the Go Text Protocol (GTP). +[![CI](https://github.com/DanielCuevas1208/stonehue/actions/workflows/ci.yml/badge.svg)](https://github.com/DanielCuevas1208/stonehue/actions/workflows/ci.yml) -Standard Go tools can play against Stonehue. This includes Sabaki and -GoGui. The engine runs on a board from 3 by 3 to 19 by 19. +Stonehue is a small-board Go engine. +It uses Monte Carlo tree search. +It speaks the Go Text Protocol (GTP). -Stonehue is written in Common Lisp. It runs on SBCL with ASDF. +Stonehue runs on SBCL with ASDF. +The test suite uses FiveAM. +The codebase uses Common Lisp. + +## Value + +Stonehue gives Go tools a compact engine for small boards. +It supports boards from 3 by 3 through 19 by 19 in GTP. + +Use it to: + +- play a local GTP game; +- inspect legal moves and captures; +- save and load SGF records; +- inspect deterministic search output; +- study a clear Common Lisp engine. + +The project makes no claim about competitive playing strength. +Self-play files are sample data. ## Features -- Capture rules with the simple ko rule -- Simple Chinese area scoring with komi -- Monte Carlo tree search with UCT -- Five strength levels from 1 to 5 -- Time controls for real games -- Opening pattern hints for the first moves -- Handicap points and handicap commands -- Deterministic search with a fixed random seed -- SGF import and export for game records -- A GTP server for standard Go tools +- capture rules and simple ko; +- Chinese-style area scoring with komi; +- Monte Carlo tree search with UCT; +- five strength levels; +- fixed-seed deterministic search; +- main time and byo-yomi budgets; +- opening hints and handicap commands; +- SGF main-line import and export; +- GTP version 2 commands; +- state-safe transposition statistics during each root search. -## Quick start +## Setup -Install SBCL. Then run this command from the project root: +Install SBCL. +Install FiveAM through Quicklisp or the operating system package. +Run commands from the repository root. -``` +Start a GTP session: + +```text sbcl --load bin/stonehue.lisp ``` -The engine starts a GTP session. Send a GTP command on each line. +Send one GTP command per line. Send `quit` to stop the session. -The test suite uses FiveAM. Install FiveAM in one of two ways: +Run the test suite: -- Install Quicklisp. Quicklisp installs FiveAM automatically. -- On Debian or Ubuntu, run `apt-get install cl-fiveam`. +```text +sbcl --load scripts/run-tests.lisp +``` -Then run this command: +Check compilation warnings: +```text +sbcl --load scripts/check-clean.lisp ``` -sbcl --load scripts/run-tests.lisp + +Build a standalone engine: + +```text +sbcl --load scripts/build.lisp ``` -## Play a game +The build writes `build/stonehue.exe`. +The output directory is ignored by Git. -Send this session to the engine: +## GTP session -``` +This short session uses standard commands. + +```text boardsize 9 clear_board komi 6.5 play b D4 play w F4 -play b D6 -genmove w +genmove b +showboard +quit ``` -The engine replies `= E4` or a similar vertex. It applies the move -to the board. Use `showboard` to see the position. +A small query returns this output: -See `examples/sample-session.txt` for a full transcript. +```text +name += Stonehue +version += 0.2.0 +quit += +``` -## Save and load games +See [examples/sample-session.txt](examples/sample-session.txt) for a complete transcript. -The engine saves a game as a Smart Game Format (SGF) record. -Send `printsgf` to see the record. Add a filename to write it: +## Search evidence -``` -printsgf game.sgf -``` +The search table lives for one root search. +Equivalent states share visit and win counts. +The state key includes these values: -Load a record with `loadsgf`. The engine replays the moves: +- board contents; +- side to play; +- move number; +- simple-ko predecessor hash; +- consecutive passes; +- capture counts. -``` -loadsgf game.sgf -``` +Tree edges remain local to their parent nodes. +This design keeps move analysis readable while sharing repeated-state evidence. -You can stop partway through the record. Add a move number: +The GTP extension `stonehue_search_info` reports the last search table. +The public `mcts-search` function returns the same information as a third value. +Run the deterministic evidence session: + +```text +sbcl --load bin/stonehue.lisp < examples/search-info-session.txt ``` -loadsgf game.sgf 24 + +The relevant output is: + +```text += pass += transposition-hits 0 +transposition-entries 1 ``` -Standard Go editors open SGF files. Use the engine as a study tool. +The zero-iteration session records only its root state. +It demonstrates the table contract without a performance claim. ## Engine controls -The strength level sets the search budget. - -| Command | Effect | -| --- | --- | -| `stonehue_strength N` | Sets the strength level from 1 to 5 | -| `stonehue_seed N` | Sets the random seed | -| `stonehue_opening` | Lists the opening hints | -| `stonehue_analyze` | Lists the search results for the last move | -| `loadsgf FILE [N]` | Loads an SGF record up to move N | -| `printsgf [FILE]` | Prints the game as SGF, or writes it to FILE | -| `time_settings M B S` | Sets main time, byo-yomi, and stones | -| `kgs-time_settings M B S P` | KGS form with the periods value ignored | - -The engine commands start with `stonehue_`. They extend the standard -protocol. The strength levels map to playout counts: - -| Level | Playouts | +| Command | Purpose | | --- | --- | +| `stonehue_strength N` | Select strength level 1 through 5 | +| `stonehue_iterations N` | Set the exact MCTS iteration budget | +| `stonehue_seed N` | Reset the deterministic random seed | +| `stonehue_opening` | List opening hints | +| `stonehue_analyze` | List root move statistics | +| `stonehue_search_info` | Report transposition hits and entries | +| `loadsgf FILE [N]` | Load an SGF main line | +| `printsgf [FILE]` | Print or save the current SGF record | +| `time_settings M B S` | Set main time, byo-yomi, and stones | +| `kgs-time_settings M B S P` | Accept KGS time settings | + +Strength levels use these default iteration budgets: + +| Level | Iterations | +| --- | ---: | | 1 | 120 | | 2 | 400 | -| 3 | 1000 | -| 4 | 3000 | -| 5 | 9000 | - -With `time_settings`, the engine uses a wall-clock budget per move. -The budget replaces the playout count. +| 3 | 1,000 | +| 4 | 3,000 | +| 5 | 9,000 | -## How it works +`stonehue_iterations` overrides the strength budget. +Time settings use a wall-clock budget for each move. -The engine has eleven modules. +## Architecture -| Module | Purpose | +| Module | Responsibility | | --- | --- | -| `board` | Board data and GTP coordinates | -| `rules` | Position state, captures, ko, legal moves | -| `scoring` | Area scoring and final score strings | -| `mcts` | Monte Carlo tree search | -| `opening` | Opening pattern hints and handicap points | -| `engine` | Engine state, strength, time controls, move history | -| `sgf` | SGF import and export | -| `gtp` | The GTP server | -| `random` | A deterministic random generator | -| `render` | ASCII board display | -| `packages` | Package and version | - -The `rules` module owns the game state. Each position holds the board -and a small history. The history covers captures and the ko rule. - -The `mcts` module searches the game tree. Each playout plays a short -random game. A short game keeps the result close to the material -balance. Captures then show clearly in the search. - -The `opening` module biases the first moves. It rates the standard -corner points. It also rates the center and the edges. - -The `engine` module ties the parts together. It tracks the position, -the komi, and the search budget. - -The `gtp` module reads commands and writes replies. Each command is a -function in a dispatch table. An unknown command gets an error reply. - -## Build a standalone engine - -Run this command: - -``` -sbcl --load scripts/build.lisp +| `board` | Board storage and GTP coordinates | +| `rules` | Positions, captures, ko, and legal moves | +| `scoring` | Area scoring and result strings | +| `mcts` | UCT search, playouts, and transposition stats | +| `opening` | Opening hints and handicap points | +| `engine` | Engine state, controls, history, and search evidence | +| `sgf` | SGF parsing, writing, and replay | +| `gtp` | GTP parsing, dispatch, and replies | +| `random` | Seeded random number generation | +| `render` | ASCII board output | +| `packages` | Package names and version data | + +The rules module owns mutable game state. +The engine records moves outside the rules module. +The search copies positions before simulations. +The transposition table owns shared search statistics. +The GTP layer converts conditions into protocol error replies. + +## SGF records + +Print the current game: + +```text +printsgf ``` -The command writes `build/stonehue.exe`. The file is a standalone -engine. It needs no Lisp image at runtime. +Save the current game: -## Run the tests +```text +printsgf game.sgf +``` -Run this command: +Load a record: -``` -sbcl --load scripts/run-tests.lisp +```text +loadsgf game.sgf ``` -The suite covers the rules, the scoring, the search, the protocol, and -the SGF records. It also replays the GTP fixtures in `examples/fixtures`. -The current suite runs 2642 checks and reports no failures. +Load a partial main line: -Check for clean compilation with this command: - -``` -sbcl --load scripts/check-clean.lisp +```text +loadsgf game.sgf 24 ``` -## Sample data +See [examples/sample-sgf-session.txt](examples/sample-sgf-session.txt). -The examples folder holds sample data. +## Tests and CI -- `examples/sample-session.txt` is a real GTP transcript. -- `examples/sample-sgf-session.txt` shows the save and load commands. -- `examples/sample-game.txt` is a full self-play game record. -- `examples/sample-game.sgf` is a self-play game as an SGF record. -- `examples/fixtures` holds GTP command files for the tests. +The FiveAM suite covers these areas: -The self-play records are sample data. They are not a claim about -strength. +- board coordinates; +- captures, suicide, and ko; +- area scoring; +- deterministic search; +- engine history and undo; +- transposition table state sharing; +- GTP replies and fixtures; +- SGF parsing, writing, and replay. -## Compatibility +The CI workflow runs clean compilation. +It runs the FiveAM suite. +It builds the standalone engine. +It runs GTP and SGF smoke sessions. -Stonehue follows the GTP version 2 specification. It implements the -standard commands. It adds the handicap commands and the KGS time -settings command. It adds `loadsgf` and `printsgf` for game records. -It adds `gogui-analyze_commands` for GoGui. +Run the full local checks: -Tested with these versions: - -- SBCL 2.2.9 and 2.6.7 -- FiveAM 1.4 (Quicklisp dist 2024-10-12) -- ASDF 3.3 (bundled with SBCL) +```text +sbcl --load scripts/check-clean.lisp +sbcl --load scripts/run-tests.lisp +sbcl --load scripts/build.lisp +``` ## Limitations -The engine is a first release. Play strength is low. The search uses -random playouts with a small budget. - -- The search values material only. Komi does not change the search. +- The search uses short random playouts. +- The search values material during playouts. +- Komi affects scoring but not playout value. - Scoring does not remove dead stones. -- There is no opening book beyond the pattern hints. -- The SGF parser reads the main line only. It ignores variations. -- There is no pondering or parallel search. - -The GTP interface accepts boards from 3 by 3 to 19 by 19. The engine -core supports boards up to 25 by 25. +- The rules implement simple ko, not superko. +- SGF replay follows the main line only. +- SGF variations and rich properties are not preserved. +- The transposition table resets for each root search. +- Search tree reuse is not available yet. +- Pondering and move interruption are not available. +- The GTP interface accepts boards from 3 by 3 through 19 by 19. +- The core accepts boards through 25 by 25. ## Roadmap -Release 0.2.0 added SGF import and export. -Release 0.3.0 will add a transposition table and pondering. -Release 0.4.0 will add pattern playouts for stronger play. +| Release | Status | Focus | +| --- | --- | --- | +| 0.2.0 | Complete | SGF records and replay | +| 0.3.0 | In progress | Search depth and reusable search work | +| 0.4.0 | Planned | Pattern-guided playouts | +| 0.5.0 | Planned | Richer SGF records | + +The 0.3.0 slice now includes state-safe transposition statistics. +The remaining work includes tree reuse, pondering, and `gogui-interrupt`. + +See [ROADMAP.md](ROADMAP.md) for release details. + +## Test status -See `ROADMAP.md` for the full plan. +CI checks are defined in `.github/workflows/ci.yml`. +The last merged release passed its configured CI checks. +Run the local commands above before proposing another release. ## License -Stonehue is released under the MIT license. See `LICENSE`. +Stonehue uses the MIT license. +See [LICENSE](LICENSE). \ No newline at end of file diff --git a/ROADMAP.md b/ROADMAP.md index 1db8dca..7562534 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,56 +1,55 @@ # Roadmap -This document plans the releases after 0.1.0. -Each release stands on its own. -A release is independent when the core works without the new features. +Each release keeps the engine usable without its next feature. +Status labels describe repository work, not playing strength. ## Status -- 0.2.0 - Records: complete -- 0.3.0 - Search depth: planned -- 0.4.0 - Pattern playouts: planned -- 0.5.0 - Richer records: planned +- 0.2.0 Records: complete +- 0.3.0 Search depth: in progress +- 0.4.0 Pattern playouts: planned +- 0.5.0 Richer records: planned -## 0.2.0 - Records (complete) +## 0.2.0 Records: complete -Goal: save and load games. +Goal: make games easy to save and study. -- Import and export the Smart Game Format (SGF) -- Add the GTP `loadsgf` and `printsgf` commands -- Record the move history in the engine -- Replay a game from a record -- Record handicap setup stones in SGF output +- Import and export SGF main lines. +- Add `loadsgf` and `printsgf`. +- Record engine moves. +- Replay records. +- Record handicap setup stones. -This release makes Stonehue useful as a study tool. +## 0.3.0 Search depth: in progress -## 0.3.0 - Search depth +Goal: use each search budget more effectively. -Goal: use the search time better. +Complete in this slice: -- Add a transposition table for repeated positions -- Add pondering during the opponent turn -- Reuse the search tree between moves -- Add `gogui-interrupt` for move cancellation +- Add a state-safe transposition table. +- Share visit statistics for equivalent states. +- Report table hits and entries through GTP. +- Add deterministic tests for table state sharing. -This release targets stronger play at the same budget. +Remaining work: -## 0.4.0 - Pattern playouts +- Reuse the search tree after a move. +- Add pondering during the opponent turn. +- Add `gogui-interrupt` for move cancellation. -Goal: improve the playout policy. +## 0.4.0 Pattern playouts: planned -- Add local pattern heuristics for random moves -- Add capture-and-extend sequences to the playouts -- Scale the playout length with the board size -- Tune the strength levels with self-play +Goal: improve random playout choices. -This release completes the first engine family. +- Add local pattern heuristics. +- Add capture-and-extend sequences. +- Scale playout length with board size. +- Tune strength levels with self-play. -## 0.5.0 - Richer records +## 0.5.0 Richer records: planned -Goal: cover more of the Smart Game Format. +Goal: cover more SGF study data. -- Parse SGF variations and side lines -- Keep player names and game comments in the record -- Round-trip stone-removal moves and setup additions - -This release widens the study and editing use cases. +- Parse SGF variations and side lines. +- Keep player names and comments. +- Round-trip setup additions and stone-removal moves. \ No newline at end of file diff --git a/examples/README.md b/examples/README.md index 8b9e7b9..34d961a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -26,6 +26,11 @@ A fixed random seed created it. Run this command to make a new record: sbcl --load scripts/save-game.lisp examples/sample-game.sgf ``` +## Search evidence + +`search-info-session.txt` runs a deterministic transposition table query through GTP. +It uses zero iterations for a deterministic table count. + ## Fixtures The fixtures folder holds GTP command files. The test suite replays diff --git a/examples/search-info-session.txt b/examples/search-info-session.txt new file mode 100644 index 0000000..87b106a --- /dev/null +++ b/examples/search-info-session.txt @@ -0,0 +1,7 @@ +# Stonehue search evidence session. +# Zero iterations keep the table count deterministic. +boardsize 9 +stonehue_iterations 0 +genmove b +stonehue_search_info +quit \ No newline at end of file diff --git a/src/engine.lisp b/src/engine.lisp index 5bbd930..8708b36 100644 --- a/src/engine.lisp +++ b/src/engine.lisp @@ -18,7 +18,8 @@ (undo-stack '()) (history '()) ; (color . move) pairs, newest first (setup '()) ; (color . index) setup stones from a handicap - (last-stats nil)) + (last-stats nil) + (last-search-info nil)) (defun engine-push-snapshot (engine) "Save the position, move history, and setup stones for an undo." @@ -135,7 +136,7 @@ Apply the chosen move and return it. Return :PASS when the engine passes." (unless (eql color (position-to-play pos)) (error "It is not ~a's turn" (color-name color))) (engine-push-snapshot engine) - (multiple-value-bind (move stats) + (multiple-value-bind (move stats search-info) (mcts-search pos :iterations (engine-iterations engine) :time-limit (engine-time-limit engine) @@ -145,7 +146,8 @@ Apply the chosen move and return it. Return :PASS when the engine passes." 'opening-hint-table) :opening-move-limit (engine-opening-move-limit engine) :max-branching (engine-max-branching engine)) - (setf (engine-last-stats engine) stats) + (setf (engine-last-stats engine) stats + (engine-last-search-info engine) search-info) (if (eq move :pass) (pass-move pos color) (try-play pos color move)) diff --git a/src/gtp.lisp b/src/gtp.lisp index 2f0bbcb..65f1bd0 100644 --- a/src/gtp.lisp +++ b/src/gtp.lisp @@ -264,6 +264,13 @@ Give the turn to White unless BLACK-TO-PLAY is set." (if level level 3))) nil) +(def-command "stonehue_iterations" (server) + (let ((iterations (parse-integer (first args) :junk-allowed t))) + (unless (and iterations (<= 0 iterations)) + (error "Unacceptable iteration count: ~a" (first args))) + (engine-set-iterations (gtp-server-engine server) iterations)) + nil) + (def-command "stonehue_seed" (server) (let* ((engine (gtp-server-engine server)) (seed (parse-integer (first args)))) @@ -293,10 +300,18 @@ Give the turn to White unless BLACK-TO-PLAY is set." wins rate))))) +(def-command "stonehue_search_info" (server) + (declare (ignore args)) + (let ((info (engine-last-search-info (gtp-server-engine server)))) + (format nil "transposition-hits ~d~%transposition-entries ~d" + (or (getf info :transposition-hits) 0) + (or (getf info :transposition-entries) 0)))) + (def-command "gogui-analyze_commands" (server) (declare (ignore server)) (format nil "gfx/Stonehue opening/stonehue_opening~%~ - plist/Stonehue analyze/stonehue_analyze")) + plist/Stonehue analyze/stonehue_analyze~%~ + plist/Stonehue search info/stonehue_search_info")) (defun error-message (condition) "Return a readable one-line message for CONDITION." diff --git a/src/mcts.lisp b/src/mcts.lisp index d2353d4..55c52af 100644 --- a/src/mcts.lisp +++ b/src/mcts.lisp @@ -69,16 +69,71 @@ The board is not modified." (return-from find-capture i))) nil)) -(defstruct (mcts-node (:constructor %make-mcts-node (move to-play))) +(defstruct (mcts-stats (:constructor %make-mcts-stats ())) + "Visit statistics shared by nodes that reach the same search state." + (plays 0 :type fixnum) + (wins 0 :type fixnum)) + +(defstruct (mcts-table (:constructor make-mcts-table ())) + "Statistics for equivalent states reached during one root search." + (entries (make-hash-table :test #'equalp)) + (hits 0 :type fixnum)) + +(defun position-state-key (position) + "Return a collision-safe key for the state that affects legal play. +The previous hash represents simple ko. Capture counts affect playout value." + (list (position-size position) + (copy-seq (position-board position)) + (position-to-play position) + (position-move-number position) + (position-prev-hash position) + (position-consecutive-passes position) + (position-captures-black position) + (position-captures-white position))) + +(defun table-stats (table key) + "Return the stats for KEY and whether the table already contained KEY." + (multiple-value-bind (stats found-p) + (gethash key (mcts-table-entries table)) + (if found-p + (incf (mcts-table-hits table)) + (setf stats (%make-mcts-stats) + (gethash key (mcts-table-entries table)) stats)) + (values stats found-p))) + +(defstruct (mcts-node (:constructor %make-mcts-node + (move to-play stats state-key))) move ; NIL at the root, otherwise an index or :PASS to-play ; the color to move at this node - (plays 0 :type fixnum) - (wins 0 :type fixnum) + stats ; shared visit statistics for this position + state-key ; stable key used by the transposition table (children '()) ; child nodes in expansion order (untried '()) ; candidate moves not yet expanded (hint-table nil) ; opening prior weights, or NIL parent) +(defun mcts-node-plays (node) + "Return the shared visit count for NODE." + (mcts-stats-plays (mcts-node-stats node))) + +(defun (setf mcts-node-plays) (value node) + (setf (mcts-stats-plays (mcts-node-stats node)) value)) + +(defun mcts-node-wins (node) + "Return the shared win count for NODE." + (mcts-stats-wins (mcts-node-stats node))) + +(defun (setf mcts-node-wins) (value node) + (setf (mcts-stats-wins (mcts-node-stats node)) value)) + +(defun make-search-node (move position table) + "Create a node and attach the stats record for POSITION." + (let ((key (position-state-key position))) + (multiple-value-bind (stats found-p) (table-stats table key) + (values (%make-mcts-node move (position-to-play position) + stats key) + (not found-p))))) + (defun win-for-p (color margin) "Return T when the player COLOR wins a game ending with MARGIN." (if (eql color +black+) @@ -163,37 +218,41 @@ KOMI is reserved for future use and is not part of the playout value." (try-play pos (position-to-play pos) move)))) (playout-margin pos))) -(defun expand-node (node pos rng opening-fn opening-move-limit max-branching) - "Expand one untried move of NODE and return the new child node. -POS is the position at NODE. The child's position is left in POS." +(defun expand-node (node pos rng opening-fn opening-move-limit max-branching + table) + "Expand one untried move and share stats for repeated positions." (let* ((move (pop (mcts-node-untried node))) (opening-prior (and opening-fn (< (position-move-number pos) opening-move-limit) (gethash move (mcts-node-hint-table node)))) (captures (and (integerp move) (capture-count pos (position-to-play pos) move))) - (child (%make-mcts-node move nil))) + (child nil) + (fresh-p nil)) (if (eq move :pass) (pass-move pos (position-to-play pos)) (try-play pos (position-to-play pos) move)) - (setf (mcts-node-to-play child) (position-to-play pos)) - (let ((virtual-plays 8) - (virtual-wins 4)) - (cond (opening-prior - ;; An opening hint is a strong prior on a good move. The - ;; count is sized so the hint survives the playout noise of - ;; a small search budget, yet fades as real evidence arrives. - (setf virtual-plays 32 - virtual-wins (max 0 (round (* 32 (- 1.0d0 opening-prior)))))) - ((and captures (plusp captures)) - ;; A move that captures stones is likely good for the mover. - ;; The child stores its value from the opponent's perspective, - ;; so a strong move gets a LOW child win rate. - (let ((quality (min 0.95d0 (+ 0.55d0 (* captures 0.15d0))))) - (setf virtual-plays 8 - virtual-wins (max 1 (round (* 8 (- 1.0d0 quality)))))))) - (setf (mcts-node-plays child) virtual-plays - (mcts-node-wins child) virtual-wins)) + (multiple-value-setq (child fresh-p) (make-search-node move pos table)) + (when fresh-p + (let ((virtual-plays 8) + (virtual-wins 4)) + (cond (opening-prior + ;; An opening hint is a strong prior on a good move. The + ;; count is sized so the hint survives the playout noise of + ;; a small search budget, yet fades as real evidence arrives. + (setf virtual-plays 32 + virtual-wins + (max 0 (round (* 32 (- 1.0d0 opening-prior)))))) + ((and captures (plusp captures)) + ;; A move that captures stones is likely good for the mover. + ;; The child stores its value from the opponent's perspective, + ;; so a strong move gets a LOW child win rate. + (let ((quality (min 0.95d0 (+ 0.55d0 (* captures 0.15d0))))) + (setf virtual-plays 8 + virtual-wins + (max 1 (round (* 8 (- 1.0d0 quality)))))))) + (setf (mcts-node-plays child) virtual-plays + (mcts-node-wins child) virtual-wins))) (setf (mcts-node-parent child) node) (push child (mcts-node-children node)) (setf (mcts-node-untried child) @@ -204,14 +263,15 @@ POS is the position at NODE. The child's position is left in POS." (funcall opening-fn pos))) child)) -(defun one-iteration (root position rng komi opening-fn opening-move-limit max-branching) +(defun one-iteration (root position rng komi opening-fn opening-move-limit + max-branching table) "Run one MCTS playout from ROOT at POSITION." (let ((node root) (pos (position-copy position))) (loop (cond ((mcts-node-untried node) (setf node (expand-node node pos rng opening-fn - opening-move-limit max-branching)) + opening-move-limit max-branching table)) (return)) ((mcts-node-children node) (let ((child (select-child node))) @@ -239,9 +299,14 @@ POS is the position at NODE. The child's position is left in POS." (opening-move-limit 4) (max-branching nil)) "Search POSITION with Monte Carlo tree search. -Return (values best-move stats) where STATS lists the root children -as (move plays wins win-rate)." - (let* ((root (%make-mcts-node nil (position-to-play position)))) +Return best move, root stats, and transposition information. +STATS lists root children as (move plays wins win-rate)." + (let* ((table (make-mcts-table)) + (root-key (position-state-key position)) + (root-stats (%make-mcts-stats)) + (root (%make-mcts-node nil (position-to-play position) + root-stats root-key))) + (setf (gethash root-key (mcts-table-entries table)) root-stats) (setf (mcts-node-untried root) (cons :pass (legal-moves position :max-branching max-branching :rng rng))) (setf (mcts-node-hint-table root) @@ -254,8 +319,12 @@ as (move plays wins win-rate)." (loop for i from 1 to (max 0 iterations) while (or (null deadline) (< (get-internal-real-time) deadline)) do (one-iteration root position rng komi opening-fn - opening-move-limit max-branching))) - (values (select-best-move root) (mcts-analyze root)))) + opening-move-limit max-branching table))) + (values (select-best-move root) + (mcts-analyze root) + (list :transposition-hits (mcts-table-hits table) + :transposition-entries + (hash-table-count (mcts-table-entries table)))))) (defun mcts-analyze (root) "Return a sorted list of (move plays wins win-rate) for the root children." diff --git a/src/packages.lisp b/src/packages.lisp index 6f34ce3..7528209 100644 --- a/src/packages.lisp +++ b/src/packages.lisp @@ -78,6 +78,7 @@ #:engine-time-limit #:engine-use-opening #:engine-last-stats + #:engine-last-search-info #:engine-history #:engine-setup #:engine-move-list diff --git a/tests/engine-tests.lisp b/tests/engine-tests.lisp index 2c4f58f..fa4c1b1 100644 --- a/tests/engine-tests.lisp +++ b/tests/engine-tests.lisp @@ -86,3 +86,10 @@ (engine-play engine +white+ (vertex->index 9 "F4")) (engine-genmove engine +black+)))) (is (equal (play-once) (play-once))))) + +(test engine-stores-search-info + (let ((engine (make-engine :size 9 :iterations 0))) + (is (eql :pass (engine-genmove engine +black+))) + (let ((info (engine-last-search-info engine))) + (is (= 0 (getf info :transposition-hits))) + (is (= 1 (getf info :transposition-entries)))))) diff --git a/tests/gtp-tests.lisp b/tests/gtp-tests.lisp index 994edb4..dcace13 100644 --- a/tests/gtp-tests.lisp +++ b/tests/gtp-tests.lisp @@ -274,3 +274,15 @@ (is (plusp (length replies))) (dolist (reply replies) (is (equal #\= (char reply 0)))))) + +(test gtp-search-info + (let ((server (make-gtp-server :engine (make-engine :size 9 :iterations 0)))) + (is (string= "= pass" (process-line server "genmove b"))) + (let ((response (process-line server "stonehue_search_info"))) + (is (search "transposition-hits 0" response)) + (is (search "transposition-entries 1" response))))) +(test gtp-iterations-setting + (let ((server (make-gtp-server))) + (is (string= "= " (process-line server "stonehue_iterations 0"))) + (is (string= "? Unacceptable iteration count: nope" + (process-line server "stonehue_iterations nope"))))) diff --git a/tests/mcts-tests.lisp b/tests/mcts-tests.lisp index 53c80c6..aca0559 100644 --- a/tests/mcts-tests.lisp +++ b/tests/mcts-tests.lisp @@ -87,3 +87,23 @@ (is (plusp plays)) (is (<= 0 rate 1)) (is (<= 0 wins plays))))))) + +(test mcts-transposition-table-shares-state-stats + (let* ((table (stonehue::make-mcts-table)) + (pos (make-position 9)) + (key (stonehue::position-state-key pos))) + (multiple-value-bind (first fresh-p) + (stonehue::table-stats table key) + (multiple-value-bind (second repeated-p) + (stonehue::table-stats table (stonehue::position-state-key pos)) + (is-true fresh-p) + (is-false repeated-p) + (is (eq first second)) + (is (= 1 (stonehue::mcts-table-hits table))))))) + +(test mcts-search-reports-table-evidence + (multiple-value-bind (move stats info) + (mcts-search (make-position 9) :iterations 0 :rng (make-rng 5)) + (declare (ignore move stats)) + (is (= 0 (getf info :transposition-hits))) + (is (= 1 (getf info :transposition-entries)))))