diff --git a/.github/workflows/arena-nightly.yml b/.github/workflows/arena-nightly.yml
deleted file mode 100644
index 79acee9..0000000
--- a/.github/workflows/arena-nightly.yml
+++ /dev/null
@@ -1,26 +0,0 @@
-name: Arena nightly
-
-on:
- schedule:
- - cron: "0 6 * * *"
- workflow_dispatch:
-
-jobs:
- arena-200:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-go@v5
- with:
- go-version: "1.22"
- - name: 200-game strength gate
- run: |
- go run ./cmd/gofer -arena -games 200 -size 9 -playouts 400 \
- -black-playouts 600 -white-playouts 200 \
- -black-eval heuristic -white-eval heuristic -seed 42 \
- -arena-enhanced baseline \
- -json arena-nightly.json
- - uses: actions/upload-artifact@v4
- with:
- name: arena-nightly-json
- path: arena-nightly.json
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 28c6a2c..030a421 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,53 +5,65 @@ on:
pull_request:
jobs:
- test:
+ go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22"
+ - name: gofmt
+ run: test -z "$(gofmt -l cmd internal)" || (gofmt -l cmd internal; exit 1)
+ - name: vet
+ run: go vet ./...
+ - name: install GNU Go (arena integration test)
+ run: sudo apt-get install -y gnugo
+ - name: test
+ run: PATH=$PATH:/usr/games go test -count=1 ./...
+ - name: race
+ run: go test -race -short -count=1 ./...
+ - name: pure-Go build
+ run: CGO_ENABLED=0 go build -o bin/gofer ./cmd/gofer
+
+ python:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
- python-version: "3.11"
- - name: Test
- run: go test ./... -short -count=1
- - name: Default build (no onnx tag, CGO off)
- run: CGO_ENABLED=0 go build -o bin/gofer ./cmd/gofer
- - name: Sidecar unit tests
- run: go test ./cmd/gofer/ -count=1 -run 'Sidecar|FeatureGolden|Batched'
- - name: Bench smoke
- run: go test -bench=BenchmarkLegalMoves -benchtime=100ms ./cmd/gofer/
- - name: Bench regression
- run: go run ./cmd/bench -baseline .tectonix/reports/bench-regression.json -check
- - name: Export ONNX fixture (policy+value)
+ python-version: "3.12"
+ - name: install
run: |
- pip install pytest
+ pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install -r training/requirements.txt
- python training/export_onnx.py --out models/gofer-9x9-bootstrap.onnx | grep -q 'outputs=policy+value'
- - name: Training pytest
- run: pytest training/ -q
- - name: ONNX integration
+ - name: pytest
+ run: python -m pytest -q training
+
+ end-to-end:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version: "1.22"
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - name: install
run: |
- python training/inference_server.py --model models/gofer-9x9-bootstrap.onnx --port 8080 &
- for i in $(seq 1 30); do
- curl -sf http://127.0.0.1:8080/health >/dev/null && break
- sleep 1
- done
- curl -sf http://127.0.0.1:8080/health
- GOFER_ONNX_URL=http://127.0.0.1:8080 go test -tags=onnx_integration ./cmd/gofer/ -count=1 -run TestSidecarLive
- - name: Arena smoke
- run: go run ./cmd/gofer -arena -games 20 -size 9 -playouts 30 -black-eval heuristic -white-eval uniform -seed 0 -arena-enhanced none
- - name: Arena ONNX smoke (sidecar)
+ sudo apt-get install -y gnugo
+ scripts/install-ort.sh
+ pip install torch --index-url https://download.pytorch.org/whl/cpu
+ pip install -r training/requirements.txt
+ - name: build with ONNX Runtime
+ run: make build
+ - name: one tiny training generation (self-play -> train -> export -> arena)
run: |
- python training/inference_server.py --model models/gofer-9x9-bootstrap.onnx --port 8081 &
- for i in $(seq 1 30); do
- curl -sf http://127.0.0.1:8081/health >/dev/null && break
- sleep 1
- done
- curl -sf http://127.0.0.1:8081/health
- go run ./cmd/gofer -arena -games 20 -size 9 -playouts 30 \
- -black-eval heuristic -white-eval onnx \
- -eval-backend sidecar -onnx-url http://127.0.0.1:8081 \
- -seed 0 -arena-enhanced none
+ python -m training.loop --run /tmp/e2e --gofer bin/gofer --gens 1 \
+ --set games_per_gen=12 --set full_visits=24 --set fast_visits=8 \
+ --set eval_games=4 --set eval_visits=8 --set anchor_games=2 \
+ --set 'anchors=["gnugo,level=1"]' --set window_min=500
+ test -f /tmp/e2e/models/gen-0001.onnx
+ cat /tmp/e2e/eval/ratings.json
+ - name: GTP smoke
+ run: printf 'boardsize 9\nclear_board\ngenmove b\nquit\n' | bin/gofer gtp -model /tmp/e2e/models/gen-0001.onnx -visits 16
diff --git a/.gitignore b/.gitignore
index f1e207d..2b7ed37 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,16 +2,14 @@ default.pgo
bin/
*.exe
.tectonix/session-baseline.json
-training/checkpoints/
-training/data/**/*.jsonl
**/__pycache__/
# Local training data / experiment artifacts (huge, never commit)
training/experiments/
-# Rotating local model artifacts (bootstrap reference model stays tracked)
+# Rotating local model artifacts (the released model stays tracked)
models/*.onnx
-!models/gofer-9x9-bootstrap.onnx
+!models/gofer-9x9.onnx
# Secrets / local AWS (never commit)
.tectonix/*.pem
@@ -21,5 +19,8 @@ models/*.onnx
# Local reports / profiles (not release artifacts)
.tectonix/reports/*
-!.tectonix/reports/bench-regression.json
-train-v3.log
+
+# v3 layout
+third_party/
+runs/
+.venv/
diff --git a/.tectonix/reports/bench-regression.json b/.tectonix/reports/bench-regression.json
deleted file mode 100644
index d8795ba..0000000
--- a/.tectonix/reports/bench-regression.json
+++ /dev/null
@@ -1,119 +0,0 @@
-{
- "timestamp": "2026-07-12T00:30:00Z",
- "results": [
- {
- "name": "BenchmarkApplyStone",
- "ns_per_op": 3.521,
- "bytes_per_op": 0,
- "allocs_per_op": 0
- },
- {
- "name": "BenchmarkUndo",
- "ns_per_op": 23.42,
- "bytes_per_op": 0,
- "allocs_per_op": 0
- },
- {
- "name": "BenchmarkClone",
- "ns_per_op": 129.5,
- "bytes_per_op": 384,
- "allocs_per_op": 1
- },
- {
- "name": "BenchmarkHashUpdate",
- "ns_per_op": 3.442,
- "bytes_per_op": 0,
- "allocs_per_op": 0
- },
- {
- "name": "BenchmarkCloneVsUndo/Clone",
- "ns_per_op": 127.6,
- "bytes_per_op": 384,
- "allocs_per_op": 1
- },
- {
- "name": "BenchmarkCloneVsUndo/Undo",
- "ns_per_op": 21.08,
- "bytes_per_op": 0,
- "allocs_per_op": 0
- },
- {
- "name": "BenchmarkSearchWorkers1",
- "ns_per_op": 64867,
- "bytes_per_op": 68848,
- "allocs_per_op": 428
- },
- {
- "name": "BenchmarkSearchWorkers8",
- "ns_per_op": 91503,
- "bytes_per_op": 69313,
- "allocs_per_op": 445
- },
- {
- "name": "BenchmarkSearchParallel",
- "ns_per_op": 53449,
- "bytes_per_op": 40289,
- "allocs_per_op": 137
- },
- {
- "name": "BenchmarkBestMove",
- "ns_per_op": 52775,
- "bytes_per_op": 40289,
- "allocs_per_op": 137
- },
- {
- "name": "BenchmarkLegalMoves",
- "ns_per_op": 155678,
- "bytes_per_op": 18016,
- "allocs_per_op": 7
- },
- {
- "name": "BenchmarkMakeMove",
- "ns_per_op": 3681,
- "bytes_per_op": 10096,
- "allocs_per_op": 9
- },
- {
- "name": "BenchmarkPlay",
- "ns_per_op": 3686,
- "bytes_per_op": 10096,
- "allocs_per_op": 9
- },
- {
- "name": "BenchmarkCaptureHeavy",
- "ns_per_op": 336481,
- "bytes_per_op": 18016,
- "allocs_per_op": 7
- },
- {
- "name": "BenchmarkScore",
- "ns_per_op": 38069,
- "bytes_per_op": 23528,
- "allocs_per_op": 1032
- },
- {
- "name": "BenchmarkSidecarBackendEvalBatch",
- "ns_per_op": 625589,
- "bytes_per_op": 136446,
- "allocs_per_op": 3568
- },
- {
- "name": "BenchmarkEvalBatch",
- "ns_per_op": 5540,
- "bytes_per_op": 12672,
- "allocs_per_op": 33
- },
- {
- "name": "BenchmarkBatchedEvaluator",
- "ns_per_op": 2186097,
- "bytes_per_op": 1168,
- "allocs_per_op": 15
- },
- {
- "name": "BenchmarkSGFReplay",
- "ns_per_op": 5281,
- "bytes_per_op": 10016,
- "allocs_per_op": 42
- }
- ]
-}
diff --git a/.tectonix/rules.toml b/.tectonix/rules.toml
index 6e53ef8..95f6614 100644
--- a/.tectonix/rules.toml
+++ b/.tectonix/rules.toml
@@ -2,24 +2,29 @@
max_cycles = 0
max_cc = 25
max_fn_lines = 100
-no_god_files = false
+no_god_files = true
[[layers]]
-name = "core"
-paths = ["cmd/gofer/*"]
+name = "rules"
+paths = ["internal/game/*", "internal/sgf/*"]
order = 0
[[layers]]
-name = "training"
-paths = ["training/*"]
+name = "engine"
+paths = ["internal/nn/*", "internal/mcts/*"]
order = 1
[[layers]]
-name = "ops"
-paths = ["scripts/*"]
+name = "apps"
+paths = ["internal/selfplay/*", "internal/arena/*", "internal/gtp/*", "cmd/gofer/*"]
order = 2
+[[layers]]
+name = "training"
+paths = ["training/*"]
+order = 3
+
[[boundaries]]
-from = "scripts/*"
-to = "cmd/gofer/*"
-reason = "Shell ops must not import Go engine internals directly"
+from = "internal/game/*"
+to = "internal/nn/*"
+reason = "Rules must not depend on the network"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f076251..d547367 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,45 @@
All notable changes to Gofer are documented here. Format based on [Keep a Changelog](https://keepachangelog.com/).
+## [3.0.0] - unreleased
+
+A rewrite of the engine core and the training pipeline. See
+[docs/v2-audit.md](docs/v2-audit.md) for the defects that motivated it.
+
+### Fixed
+
+- Search backed values up with the wrong sign, so it chose the opponent's best move
+- Fresh searches did no work: the root lost its `Expanded` flag when the node arena reallocated
+- Transposition table returned other positions' values (no key check; pass collided with the parent)
+- Forced root playouts made policy targets near-uniform
+- Network inference ran on a single core regardless of machine size
+- Superko/Tromp-Taylor state was shared with search playouts; simple ko was set on any single-stone capture
+- Self-play and arena games were truncated at 83 moves and scored unfinished
+- ONNX export traced BatchNorm in training mode when the wrapper module was not in `eval()`
+
+### Changed
+
+- Engine split into packages: `internal/{game,sgf,nn,mcts,selfplay,arena,gtp}`; `cmd/gofer` is a
+ subcommand CLI (`gtp`, `play`, `selfplay`, `arena`, `bench`) replacing the v2 flag set
+- Board with incremental chains and exact liberties: ~0.7 µs per move including legal-move generation;
+ hash-based positional superko
+- PUCT search with virtual-loss leaf batching, Dirichlet noise, tree reuse, exact terminal scoring,
+ and a conservative pass rule so games are played to a settled finish
+- Batched evaluator with multiple ONNX Runtime sessions (CPU) or CUDA/TensorRT, random symmetry,
+ position cache
+- Feature schema 3: liberty planes, edge plane, pass flags, komi from the side to move's view
+- Network: residual trunk with global-pooling blocks and fully convolutional heads
+- Training: `.npz` shards, growing replay window, 8-fold symmetry augmentation, AdamW with a
+ per-generation cosine schedule, resumable `python -m training.loop`
+- Evaluation: arena with paired openings, GNU Go (or any GTP engine) as anchors with dead-stone
+ adjudication, Wilson intervals, Elo, SPRT, and a Bradley-Terry ladder across generations
+
+### Removed
+
+- HTTP inference sidecar, heuristic evaluator, JSONL samples, SGF-to-JSONL converter, Lightsail/AWS
+ scripts, the arena-gated v2 loop (`train-loop-v3.sh`), and the v2 bootstrap model (incompatible
+ features). v2 design docs are kept in `docs/archive/`.
+
## [2.7.1] - 2026-07-07
### Added
diff --git a/Makefile b/Makefile
index 771ac6a..015c86c 100644
--- a/Makefile
+++ b/Makefile
@@ -1,63 +1,45 @@
-.PHONY: test bench race lint profile memprofile pgo-build pgo-profile build build-onnx bench-check reproduce-9x9-baseline reproduce-9x9-onnx-gate sidecar train-bootstrap export-onnx
+PYTHON ?= .venv/bin/python
+MODEL ?= models/gofer-9x9.onnx
+RUN ?= runs/9x9
-test:
- go test ./...
+.PHONY: build build-nocgo test test-go test-py race lint bench gtp play train ort
-bench:
- go run ./cmd/bench
-
-bench-check:
- go run ./cmd/bench -baseline .tectonix/reports/bench-regression.json -check
-
-race:
- go test -race ./...
-
-lint:
- go vet ./...
-
-profile:
- go test -cpuprofile=.tectonix/reports/legalmoves-cpu.prof -bench=BenchmarkLegalMoves -benchtime=3s ./cmd/gofer/
-
-memprofile:
- go test -memprofile=.tectonix/reports/legalmoves-mem.prof -bench=BenchmarkLegalMoves -benchtime=3s ./cmd/gofer/
+# Engine with ONNX Runtime (needs CGO and scripts/install-ort.sh).
+build:
+ CGO_ENABLED=1 go build -tags onnx -o bin/gofer ./cmd/gofer
-pgo-profile:
- go test -cpuprofile=default.pgo -bench=BenchmarkLegalMoves -benchtime=10s ./cmd/gofer/
+# Pure-Go build: rules, search, GTP and arena work; network inference does not.
+build-nocgo:
+ CGO_ENABLED=0 go build -o bin/gofer ./cmd/gofer
-pgo-build:
- @test -f default.pgo || (echo "run: make pgo-profile" && exit 1)
- go build -pgo=default.pgo -o bin/gofer ./cmd/gofer
+test: test-go test-py
-build-onnx:
- CGO_ENABLED=1 go build -tags=onnx -o bin/gofer ./cmd/gofer
+test-go:
+ go test -short ./...
-build:
- go build -o bin/gofer ./cmd/gofer
+test-py:
+ $(PYTHON) -m pytest -q training
-play:
- go run ./cmd/gofer -play -size 9
+race:
+ go test -race -short ./...
-analyze:
- go run ./cmd/gofer -analyze -size 9 -playouts 200
+lint:
+ @test -z "$$(gofmt -l cmd internal)" || (gofmt -l cmd internal; exit 1)
+ go vet ./...
+ CGO_ENABLED=1 go vet -tags onnx ./...
-sidecar:
- python training/inference_server.py --model models/gofer-9x9-bootstrap.onnx --port 8080
+bench:
+ go test -run '^$$' -bench . -benchmem ./internal/game ./internal/mcts
+ bin/gofer bench -model $(MODEL)
-export-onnx:
- python training/export_onnx.py --out models/gofer-9x9-bootstrap.onnx
+gtp: build
+ bin/gofer gtp -model $(MODEL)
-train-bootstrap:
- go run ./cmd/gofer -selfplay -games 100 -size 9 -playouts 100 -eval heuristic -o training/data/samples.jsonl -seed 42
- python training/train_bootstrap.py --data training/data/samples.jsonl --epochs 20
- python training/export_onnx.py --checkpoint training/checkpoints/best.pt --out models/gofer-9x9-bootstrap.onnx
+play: build
+ bin/gofer play -model $(MODEL)
-reproduce-9x9-baseline:
- go test ./... -count=1
- go run ./cmd/bench -baseline .tectonix/reports/bench-regression.json -check
- go run ./cmd/gofer -arena -games 200 -size 9 -playouts 400 -black-playouts 600 -white-playouts 200 -black-eval heuristic -white-eval heuristic -seed 42 -arena-enhanced baseline -json .tectonix/reports/arena-9x9-baseline.json
+train: build
+ $(PYTHON) -m training.loop --run $(RUN) --gofer bin/gofer
-reproduce-9x9-onnx-gate:
- go test ./... -count=1
- go run ./cmd/bench -baseline .tectonix/reports/bench-regression.json -check
- @test -f models/gofer-9x9-bootstrap.onnx || (echo "run: make export-onnx" && exit 1)
- go run ./cmd/gofer -arena -games 200 -size 9 -playouts 400 -black-eval heuristic -white-eval onnx -seed 42 -arena-enhanced none -json .tectonix/reports/arena-9x9-onnx-v25.json
+ort:
+ scripts/install-ort.sh
diff --git a/README.md b/README.md
index 986c3d1..63b23b0 100644
--- a/README.md
+++ b/README.md
@@ -1,204 +1,102 @@
-# Gofer v2.7.0
+# Gofer
-A Go engine with Chinese rules, MCTS search, GTP, and self-play — inspired by [Wu et al. 2020](https://arxiv.org/abs/1902.10565). Not KataGo.
+An AlphaZero-style Go engine written in Go, with a PyTorch training loop that
+learns 9×9 Go from zero by self-play. Techniques follow
+[Wu 2020, *Accelerating Self-Play Learning in Go*](docs/wu-2020-accelerating-self-play.pdf)
+(KataGo) at hobby scale: playout-cap randomization, global-pooling residual
+network, ownership auxiliary target, Dirichlet root noise.
-See [CHANGELOG.md](CHANGELOG.md) for release notes.
+- **Engine (Go):** incremental bitset board with exact liberties and superko,
+ PUCT search with virtual-loss batching, batched multi-core/GPU inference
+ through ONNX Runtime, GTP server with live analysis for Sabaki and Lizzie.
+- **Training (Python):** self-play shards → augmented training → verified ONNX
+ export → evaluation against the previous generation and GNU Go → Elo ladder.
+ Resumable, one command.
-## Quick start
-
-```bash
-make build # -> bin/gofer
-make test
-make bench-check # same regression gate as CI
-```
-
-### Play in the terminal
-
-```bash
-bin/gofer -play -size 9 -color b
-# commands: play D4 | genmove | analyze | board | score | undo | quit
-# save game on quit: bin/gofer -play -o game.sgf
-```
-
-### Analyze a position
-
-```bash
-bin/gofer -analyze -size 9 -playouts 400
-bin/gofer -analyze -size 9 -think-time 3s -top 8
-bin/gofer -analyze -size 9 -moves D4,Q16,pass
-```
-
-### GTP (Sabaki, Lizzie, KaGo tools)
-
-Engine command:
-
-```text
-bin/gofer -gtp -size 9 -think-time 5s -eval heuristic
-```
-
-Save game on quit:
-
-```text
-bin/gofer -gtp -size 9 -o game.sgf
-```
-
-Or fixed playouts:
-
-```text
-bin/gofer -gtp -gtp-playouts 800 -eval heuristic
-```
-
-Sabaki demo (9×9, think-time, SGF on quit):
-
-1. Build: `make build`
-2. Sabaki → **Engines → Manage engines → Add**
-3. Command: `bin/gofer -gtp -size 9 -think-time 5s -eval heuristic -o demo.sgf`
-4. New game 9×9, play; on quit the engine writes `demo.sgf`
-
-Plot arena gating curve: `./scripts/plot-gating.sh .tectonix/reports/arena-9x9-baseline.json`
-
-Nightly 200-game arena workflow: [`.github/workflows/arena-nightly.yml`](.github/workflows/arena-nightly.yml) (also `make reproduce-9x9-baseline`).
-
-### Watch engine vs engine
-
-```bash
-bin/gofer -watch -size 9 -playouts 50
-```
+## Results
-### ONNX inference (v2.5+)
+Trained from random play on one 4-vCPU cloud VM (no GPU, no human games):
+17 generations, 10,200 self-play games, **3.6 hours**.
-Default path is **in-process ORT** (no sidecar). Build with ONNX support:
+| Final network (400 visits/move) vs | Games | Score | Elo difference (95% CI) |
+|------------------------------------|------:|------:|------------------------:|
+| GNU Go 3.8, level 10 (default) | 100 | **66–34** | +115 [+44, +187] |
+| GNU Go 3.8, level 1 | 100 | **78–22** | +220 [+138, +301] |
+| Gofer v2 engine + its model (400 playouts) — generation 9 at 100 visits | 20 | **20–0** | ≥ +287 |
-```bash
-make build-onnx # CGO + -tags=onnx -> bin/gofer
-```
-
-Use `-eval-backend` to choose the transport:
-
-| Flag / env | Values | Default |
-|------------|--------|---------|
-| `-eval-backend` | `inprocess`, `sidecar` | `inprocess` |
-| `EVAL_BACKEND` | same (train loop) | `inprocess` |
-| `-model` | champion ONNX path | `models/gofer-9x9-bootstrap.onnx` |
-| `-model-2` | challenger ONNX (`onnx2` eval) | — |
+Games alternate colors over paired random openings; GNU Go judges dead
+stones at the end. The v2 engine lost 0–20 to GNU Go level 1.
-**Sidecar fallback** (HTTP, still supported):
+
-```bash
-make sidecar # python training/inference_server.py on :8080
-bin/gofer -gtp -size 9 -eval onnx -eval-backend sidecar -onnx-url http://127.0.0.1:8080
-```
+Per-generation numbers: [docs/results/summary.md](docs/results/summary.md).
-**Parity harness** — proves Go ORT matches Python `inference_server.Session` bit-exact (ORT 1.26.0):
+## Quick start
```bash
-# quick (500 positions)
-bash scripts/parity-onnx.sh
+scripts/setup.sh # ONNX Runtime, GNU Go, .venv, bin/gofer (add "gpu" on NVIDIA machines)
+make test
-# full samples file
-GOFER_PARITY_LIMIT=1722 bash scripts/parity-onnx.sh
+bin/gofer play -model models/gofer-9x9.onnx # play in the terminal
+bin/gofer gtp -model models/gofer-9x9.onnx -visits 800 # GTP engine for Sabaki / Lizzie / GoGui
```
-Requires `onnxruntime==1.26.0` on the Python side (`training/requirements-ort-parity.txt` or pip). Linux amd64 also needs `ONNXRUNTIME_SHARED_LIBRARY_PATH` (script downloads ORT 1.26.0 if missing). Go test: `CGO_ENABLED=1 go test -tags=onnx ./cmd/gofer/ -run TestONNXParityPythonRef`.
+In Sabaki: *Engines → Manage engines → Add*, path `bin/gofer`, arguments
+`gtp -model models/gofer-9x9.onnx`. Live analysis (`lz-analyze`) works out of
+the box.
-See [ADR 0004](docs/decisions/0004-in-process-onnx-inference.md).
-
-### Arena (gating)
-
-Equal-config ONNX strength check (200 games, same playouts):
+## Train your own
```bash
-make sidecar
-make reproduce-9x9-onnx-gate
+.venv/bin/python -m training.loop --run runs/9x9 --gofer bin/gofer --hours 8
+.venv/bin/python -m training.report runs/9x9 --out runs/9x9/report
```
-Legacy asymmetric heuristic gate (600 vs 200 playouts):
+Stop it any time and rerun the same command to resume. See
+[docs/training.md](docs/training.md) for the loop, settings, throughput, and
+how to use a GPU.
-```bash
-bin/gofer -arena -games 200 -size 9 -playouts 400 \
- -black-playouts 600 -white-playouts 200 \
- -black-eval heuristic -white-eval heuristic -seed 42 \
- -arena-enhanced baseline \
- -json .tectonix/reports/arena-report.json
-```
+## Commands
-### Self-play
+| Command | What it does |
+|---------|--------------|
+| `gofer gtp` | GTP v2 server (`genmove`, `undo`, `final_score`, `lz-analyze`, ...) |
+| `gofer play` | Play against the engine in the terminal (`hint` shows its suggestion) |
+| `gofer selfplay` | Generate training shards (`.npz`) and SGFs |
+| `gofer arena` | Match between two players with Elo, confidence interval and optional SPRT; players are `nn:model.onnx`, `rollout`, `uniform`, `gnugo,level=N` or `gtp:` |
+| `gofer bench` | Search and inference throughput |
```bash
-bin/gofer -selfplay -games 5 -size 9 -playouts 200 -selfplay-eval mix -o samples.jsonl
-bin/gofer -selfplay -games 3 -size 9 -sgf-dir games/
+bin/gofer arena -a nn:models/gofer-9x9.onnx,visits=400 -b gnugo,level=10 -games 100 -sgf games/
```
-### ML training loop v3
-
-Persistent replay buffer, resume training, monotonic arena promote:
-
-```bash
-SEED_FROM_CYCLE2=1 WEEK_DAYS=14 bash scripts/train-loop-v3.sh
-# Lightsail: bash scripts/aws-run-arena.sh IP start-v3
-```
+## Layout
-See [docs/decisions/0003-iterative-training-loop.md](docs/decisions/0003-iterative-training-loop.md).
+| Path | |
+|------|--|
+| `internal/game` | rules, board, scoring |
+| `internal/mcts` | search |
+| `internal/nn` | features, batched evaluator, ONNX Runtime backend |
+| `internal/selfplay`, `internal/arena`, `internal/gtp`, `internal/sgf` | applications |
+| `cmd/gofer` | CLI |
+| `training/` | model, data, trainer, export, loop, Elo, report (+ tests) |
+| `docs/` | [architecture](docs/architecture.md), [training](docs/training.md), [v2 audit](docs/v2-audit.md) |
-### SGF replay
+## Development
```bash
-bin/gofer -sgf cmd/gofer/testdata/simple.sgf
+make lint test race # gofmt, vet (with and without ONNX), Go + Python tests, race detector
+make build-nocgo # pure-Go binary: rules, search, GTP, arena (no network inference)
```
-## Defaults
-
-| Board | Default playouts (when `-playouts 0`) |
-|-------|---------------------------------------|
-| 9×9 | 400 |
-| 13×13 | 800 |
-| 19×19 | 1600 |
-
-`-think-time` overrides playout count for that move (GTP `time_left`, `-play`, `-analyze`, `-gtp`).
-
-## Evaluators
-
-- **`heuristic`** (default) — stones, liberties, territory estimate, move priors for PUCT
-- **`uniform`** — random-ish MCTS baseline
-- **`batched`** / **`mock-batch`** — batched mock inference queue
-- **`onnx`** — ONNX Runtime via HTTP sidecar (`-onnx-url`, `-batch-size`, `-eval-timeout`)
-
-## Profile-guided build (optional)
-
-```bash
-make pgo-profile # generates default.pgo (gitignored)
-make pgo-build # bin/gofer with -pgo=
-make bench-check # compare before/after
-```
-
-`make pgo-profile` profiles `BenchmarkLegalMoves` only; self-play would be a better macro workload if PGO gains matter.
-
-## Project layout
-
-| Path | Role |
-|------|------|
-| `cmd/gofer/` | Engine binary (rules, MCTS, GTP, CLI) |
-| `cmd/bench/` | Benchmark regression runner |
-| `training/` | PyTorch bootstrap trainer + ONNX sidecar |
-| `models/` | Exported ONNX weights |
-| `docs/` | Blueprint, traceability, scorecard |
-
-## Docs
-
-- [`docs/implementation-blueprint.md`](docs/implementation-blueprint.md) — milestones
-- [`docs/research-traceability.md`](docs/research-traceability.md) — paper ↔ code
-- [`docs/optimization-scorecard.md`](docs/optimization-scorecard.md) — benches & quality gate
-
-## Included
-
-- Chinese + Tromp-Taylor rules, SGF import/export
-- PUCT MCTS, transposition table, parallel playouts
-- GTP subset, terminal play/analyze/watch, self-play samples
-- Arena gating, Wilson CI, training sample export
-- ONNX sidecar inference (`-eval onnx`), bootstrap 9×9 net
+The board is differential-tested against a naive reference on random games
+from 5×5 to 19×19; the search has regression tests for every defect found in
+the [v2 audit](docs/v2-audit.md); CI runs a full self-play → train → export →
+arena generation against GNU Go.
-## Not yet
+## Version 3
-- KataGo-level strength or full analysis API
-- In-process ONNX (CGO); sidecar is v2.5 default
-- Full time controls (byo-yomi); `time_left` uses remaining time as next-move budget
+v3 rewrote the engine core and the training pipeline after an audit found
+that v2's search backed up values with the wrong sign, skipped the search on
+fresh trees, and trained the policy toward uniform targets, among other
+defects. The details, with measurements, are in [docs/v2-audit.md](docs/v2-audit.md).
diff --git a/cmd/bench/main.go b/cmd/bench/main.go
deleted file mode 100644
index 027b318..0000000
--- a/cmd/bench/main.go
+++ /dev/null
@@ -1,183 +0,0 @@
-// Command bench runs package benchmarks and optional JSON regression output.
-package main
-
-import (
- "encoding/json"
- "flag"
- "fmt"
- "os"
- "os/exec"
- "regexp"
- "strconv"
- "strings"
- "time"
-)
-
-type benchResult struct {
- Name string `json:"name"`
- NsPerOp float64 `json:"ns_per_op"`
- Bytes int64 `json:"bytes_per_op"`
- Allocs int64 `json:"allocs_per_op"`
-}
-
-type report struct {
- Timestamp string `json:"timestamp"`
- Results []benchResult `json:"results"`
-}
-
-const (
- defaultRegressionThreshold = 1.10 // 10% slower allowed vs baseline
- microBenchThreshold = 1.25 // looser for sub-microsecond benches (Windows noise)
- microBenchNs = 1000
- benchSampleRuns = 3 // max-of-N dampens noise; pinned CI runner would be tighter
-)
-
-func regressionLimit(baselineNs float64) float64 {
- if baselineNs < microBenchNs {
- return microBenchThreshold
- }
- return defaultRegressionThreshold
-}
-
-func main() {
- jsonOut := flag.String("json", "", "write regression JSON to path")
- baselinePath := flag.String("baseline", "", "baseline JSON for -check")
- check := flag.Bool("check", false, "fail if >10% regression vs -baseline")
- flag.Parse()
-
- results, err := runBenchesMax(benchSampleRuns)
- if err != nil {
- os.Exit(1)
- }
-
- if *jsonOut != "" {
- rep := report{
- Timestamp: time.Now().UTC().Format(time.RFC3339),
- Results: results,
- }
- data, err := json.MarshalIndent(rep, "", " ")
- if err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- if err := os.WriteFile(*jsonOut, data, 0644); err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- }
-
- if *check {
- if *baselinePath == "" {
- fmt.Fprintln(os.Stderr, "-check requires -baseline")
- os.Exit(1)
- }
- if err := checkRegression(*baselinePath, results); err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- }
-}
-
-func runBenchesMax(runs int) ([]benchResult, error) {
- if runs < 1 {
- runs = 1
- }
- var merged []benchResult
- for i := 0; i < runs; i++ {
- out, err := exec.Command("go", "test", "-bench=.", "-benchmem", "./...").CombinedOutput()
- fmt.Print(string(out))
- if err != nil {
- if exit, ok := err.(*exec.ExitError); ok {
- return nil, fmt.Errorf("bench run %d failed: exit %d", i+1, exit.ExitCode())
- }
- return nil, err
- }
- cur := parseBenchOutput(string(out))
- if i == 0 {
- merged = cur
- continue
- }
- merged = mergeMaxResults(merged, cur)
- }
- return merged, nil
-}
-
-func mergeMaxResults(a, b []benchResult) []benchResult {
- bm := make(map[string]benchResult, len(b))
- for _, r := range b {
- bm[r.Name] = r
- }
- out := make([]benchResult, len(a))
- for i, r := range a {
- out[i] = r
- if o, ok := bm[r.Name]; ok && o.NsPerOp > r.NsPerOp {
- out[i] = o
- }
- }
- for _, o := range b {
- found := false
- for _, r := range a {
- if r.Name == o.Name {
- found = true
- break
- }
- }
- if !found {
- out = append(out, o)
- }
- }
- return out
-}
-
-func checkRegression(baselinePath string, current []benchResult) error {
- data, err := os.ReadFile(baselinePath)
- if err != nil {
- return fmt.Errorf("read baseline: %w", err)
- }
- var base report
- if err := json.Unmarshal(data, &base); err != nil {
- return fmt.Errorf("parse baseline: %w", err)
- }
- baseMap := make(map[string]benchResult, len(base.Results))
- for _, r := range base.Results {
- baseMap[r.Name] = r
- }
- var regressions []string
- for _, cur := range current {
- b, ok := baseMap[cur.Name]
- if !ok {
- continue
- }
- if b.NsPerOp > 0 && cur.NsPerOp > b.NsPerOp*regressionLimit(b.NsPerOp) {
- regressions = append(regressions,
- fmt.Sprintf("%s: %.0f ns/op > %.0f baseline (%.1f%%)",
- cur.Name, cur.NsPerOp, b.NsPerOp, (cur.NsPerOp/b.NsPerOp-1)*100))
- }
- }
- if len(regressions) > 0 {
- return fmt.Errorf("bench regression:\n %s", strings.Join(regressions, "\n "))
- }
- return nil
-}
-
-var benchLine = regexp.MustCompile(`^(Benchmark\S+)-\d+\s+\d+\s+(\d+(?:\.\d+)?)\s+ns/op\s+(\d+)\s+B/op\s+(\d+)\s+allocs/op`)
-
-func parseBenchOutput(s string) []benchResult {
- var results []benchResult
- for _, line := range strings.Split(s, "\n") {
- m := benchLine.FindStringSubmatch(strings.TrimSpace(line))
- if m == nil {
- continue
- }
- ns, _ := strconv.ParseFloat(m[2], 64)
- b, _ := strconv.ParseInt(m[3], 10, 64)
- a, _ := strconv.ParseInt(m[4], 10, 64)
- results = append(results, benchResult{
- Name: m[1],
- NsPerOp: ns,
- Bytes: b,
- Allocs: a,
- })
- }
- return results
-}
diff --git a/cmd/gofer/arena.go b/cmd/gofer/arena.go
deleted file mode 100644
index 9af95bb..0000000
--- a/cmd/gofer/arena.go
+++ /dev/null
@@ -1,86 +0,0 @@
-package main
-
-import "math"
-
-// Node is an index-based MCTS tree node.
-type Node struct {
- Parent int
- Move Move
- Children []int
- Visits uint32
- ValueSum float64
- Prior float64
- Expanded bool
-}
-
-// Arena stores nodes in a contiguous slice; doubles capacity when full.
-type Arena struct {
- nodes []Node
-}
-
-// NewArena creates an empty arena.
-func NewArena() *Arena {
- return &Arena{nodes: make([]Node, 0, 64)}
-}
-
-// Root allocates the root node and returns its index.
-func (a *Arena) Root() int {
- if len(a.nodes) == 0 {
- a.nodes = append(a.nodes, Node{Parent: -1})
- }
- return 0
-}
-
-// Get returns node at index.
-func (a *Arena) Get(i int) *Node {
- return &a.nodes[i]
-}
-
-// AddChild appends a child node and returns its index.
-func (a *Arena) AddChild(parent int, m Move, prior float64) int {
- idx := len(a.nodes)
- a.nodes = append(a.nodes, Node{
- Parent: parent,
- Move: m,
- Prior: prior,
- })
- a.nodes[parent].Children = append(a.nodes[parent].Children, idx)
- return idx
-}
-
-// Mean returns average value for node visits.
-func (n *Node) Mean() float64 {
- if n.Visits == 0 {
- return 0
- }
- return n.ValueSum / float64(n.Visits)
-}
-
-func puctScore(c *Node, parentVisits float64, isRoot bool, cfg SearchConfig) float64 {
- q := c.Mean()
- if c.Visits == 0 {
- q = -cfg.FPU
- }
- u := cfg.CPUCT * c.Prior * math.Sqrt(parentVisits) / (1 + float64(c.Visits))
- if isRoot && cfg.RootTemperature != 1 && c.Visits > 0 {
- q /= cfg.RootTemperature
- }
- return q + u
-}
-
-func (a *Arena) bestRootMove(root int) Move {
- n := a.Get(root)
- if len(n.Children) == 0 {
- return PassMove
- }
- best := n.Children[0]
- maxV := uint32(0)
- for _, cidx := range n.Children {
- c := a.Get(cidx)
- if c.Visits > maxV {
- maxV = c.Visits
- best = cidx
- }
- }
- return a.Get(best).Move
-}
diff --git a/cmd/gofer/arena_bias_test.go b/cmd/gofer/arena_bias_test.go
deleted file mode 100644
index a39071d..0000000
--- a/cmd/gofer/arena_bias_test.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package main
-
-import "testing"
-
-// TestArenaIdenticalNetsNoSystematicRoleBias verifies swap-colors balances role
-// wins for equal strength. Stone-color wins may skew with komi; arena gating uses
-// challenger vs baseline (role), not stone color.
-func TestArenaIdenticalNetsNoSystematicRoleBias(t *testing.T) {
- if testing.Short() {
- t.Skip("short")
- }
- old := minGamesBeforePromote
- minGamesBeforePromote = 201
- t.Cleanup(func() { minGamesBeforePromote = old })
-
- const games = 200
- res := RunMatch(MatchConfig{
- Games: games, Size: 9, Komi: 6.5, Playouts: 50,
- BlackEval: "heuristic", WhiteEval: "heuristic2",
- Seed: 99, SwapColors: true, OpeningMoves: 8, OpeningTemp: 1.0, Parallel: 4,
- })
- decisive := res.WinsChallenger + res.WinsBaseline
- t.Logf("komi=6.5 challenger=%d baseline=%d draws=%d stone_black=%d stone_white=%d",
- res.WinsChallenger, res.WinsBaseline, res.Draws, res.WinsBlack, res.WinsWhite)
- if decisive == 0 {
- t.Fatal("no decisive games")
- }
- assertWinsNearExpected(t, res.WinsChallenger, decisive, 0.5, 3.0)
-}
diff --git a/cmd/gofer/bench_test.go b/cmd/gofer/bench_test.go
deleted file mode 100644
index ad228cc..0000000
--- a/cmd/gofer/bench_test.go
+++ /dev/null
@@ -1,233 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "testing"
- "time"
-)
-
-func BenchmarkApplyStone(b *testing.B) {
- br := NewBoard(19, 7.5)
- b.ReportAllocs()
- for i := 0; i < b.N; i++ {
- idx := i % (19 * 19)
- br.setStone(idx, Black)
- br.setStone(idx, Empty)
- }
-}
-
-func BenchmarkUndo(b *testing.B) {
- br := NewBoard(19, 7.5)
- b.ReportAllocs()
- for i := 0; i < b.N; i++ {
- p := At(i%19, (i/19)%19)
- idx := p.Idx(19)
- br.StartPlay(StoneMove(p), nil, idx, Empty)
- br.SetStoneIndex(idx, Black)
- br.FinishTurn(-1)
- br.Undo()
- }
-}
-
-func BenchmarkClone(b *testing.B) {
- br := NewBoard(19, 7.5)
- for i := 0; i < 20; i++ {
- br.setStone(i, Black)
- }
- b.ReportAllocs()
- for i := 0; i < b.N; i++ {
- _ = br.Clone()
- }
-}
-
-func BenchmarkHashUpdate(b *testing.B) {
- br := NewBoard(19, 7.5)
- b.ReportAllocs()
- for i := 0; i < b.N; i++ {
- idx := i % (19 * 19)
- br.setStone(idx, Black)
- _ = br.Hash()
- br.setStone(idx, Empty)
- }
-}
-
-func BenchmarkCloneVsUndo(b *testing.B) {
- b.Run("Clone", func(b *testing.B) {
- br := NewBoard(19, 7.5)
- for i := 0; i < 40; i++ {
- br.setStone(i, Black)
- }
- b.ReportAllocs()
- for i := 0; i < b.N; i++ {
- c := br.Clone()
- idx := (i + 40) % (19 * 19)
- c.setStone(idx, White)
- _ = c
- }
- })
- b.Run("Undo", func(b *testing.B) {
- br := NewBoard(19, 7.5)
- for i := 0; i < 40; i++ {
- br.setStone(i, Black)
- }
- b.ReportAllocs()
- for i := 0; i < b.N; i++ {
- idx := (i + 40) % (19 * 19)
- prev := br.stones[idx]
- br.StartPlay(StoneMove(At(idx%19, idx/19)), nil, idx, prev)
- br.SetStoneIndex(idx, White)
- br.FinishTurn(-1)
- br.Undo()
- }
- })
-}
-
-func benchmarkSearchWithWorkers(b *testing.B, workers, playouts int) {
- r := Chinese()
- board := NewBoard(9, 6.5)
- cfg := DefaultConfig()
- cfg.Playouts = playouts
- cfg.Workers = workers
- eng := NewEngine(r, Uniform{}, cfg)
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- eng.ResetArena()
- eng.BestMove(board)
- }
-}
-
-func BenchmarkSearchWorkers1(b *testing.B) { benchmarkSearchWithWorkers(b, 1, 200) }
-func BenchmarkSearchWorkers8(b *testing.B) { benchmarkSearchWithWorkers(b, 8, 200) }
-
-func BenchmarkSearchParallel(b *testing.B) {
- benchmarkSearchWithWorkers(b, 4, 50)
-}
-
-func BenchmarkBestMove(b *testing.B) { BenchmarkSearchParallel(b) }
-
-func BenchmarkLegalMoves(b *testing.B) {
- br := NewBoard(19, 7.5)
- r := Chinese()
- b.ReportAllocs()
- for i := 0; i < b.N; i++ {
- _ = r.LegalMoves(br)
- }
-}
-
-func BenchmarkMakeMove(b *testing.B) {
- br := NewBoard(19, 7.5)
- r := Chinese()
- b.ReportAllocs()
- for i := 0; i < b.N; i++ {
- p := At(i%19, (i/19)%19)
- if !r.Play(br, StoneMove(p)) {
- br = NewBoard(19, 7.5)
- continue
- }
- br.Undo()
- }
-}
-
-func BenchmarkPlay(b *testing.B) { BenchmarkMakeMove(b) }
-
-func BenchmarkCaptureHeavy(b *testing.B) {
- br := NewBoard(19, 7.5)
- r := Chinese()
- for y := 3; y < 16; y += 3 {
- for x := 3; x < 16; x += 3 {
- _ = r.Play(br, StoneMove(At(x, y)))
- }
- }
- b.ReportAllocs()
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- _ = r.LegalMoves(br)
- }
-}
-
-func BenchmarkScore(b *testing.B) {
- br := NewBoard(19, 7.5)
- r := Chinese()
- for i := 0; i < 30; i++ {
- _ = r.Play(br, StoneMove(At(i%19, (i*2)%19)))
- }
- b.ReportAllocs()
- for i := 0; i < b.N; i++ {
- _, _ = r.Score(br)
- }
-}
-
-func BenchmarkSidecarBackendEvalBatch(b *testing.B) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- policy := make([]float32, 82)
- for j := range policy {
- policy[j] = 1.0 / 82
- }
- _ = json.NewEncoder(w).Encode(map[string]any{
- "results": []map[string]any{{"value": 0.0, "policy": policy}},
- })
- }))
- defer srv.Close()
- backend := SidecarBackend{URL: srv.URL, Client: srv.Client()}
- boards := make([]*Board, 32)
- for i := range boards {
- boards[i] = NewBoard(9, 6.5)
- }
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- _ = backend.EvalBatch(boards[:8])
- }
-}
-
-func BenchmarkEvalBatch(b *testing.B) {
- boards := make([]*Board, 32)
- for i := range boards {
- boards[i] = NewBoard(9, 6.5)
- }
- inf := Inference{Latency: 0}
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- _ = inf.EvalBatch(boards)
- }
-}
-
-func BenchmarkBatchedEvaluator(b *testing.B) {
- ev := NewBatchedEvaluator(Inference{}, Heuristic{}, 8, 2*time.Millisecond)
- defer ev.Close()
- board := NewBoard(9, 6.5)
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- _ = ev.Evaluate(board)
- }
-}
-
-func BenchmarkSGFReplay(b *testing.B) {
- path := filepath.Join("testdata", "open.sgf")
- data, err := os.ReadFile(path)
- if err != nil {
- b.Fatal(err)
- }
- g, err := ParseSGF(string(data))
- if err != nil {
- b.Fatal(err)
- }
- moves, err := g.MainLine()
- if err != nil {
- b.Fatal(err)
- }
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- r := Chinese()
- brd := NewBoard(g.Size, g.Komi)
- _ = g.Setup(brd)
- for _, m := range moves {
- if !r.Play(brd, sgfMoveToPlay(m)) {
- b.Fatal("illegal replay")
- }
- }
- }
-}
diff --git a/cmd/gofer/board.go b/cmd/gofer/board.go
deleted file mode 100644
index e671927..0000000
--- a/cmd/gofer/board.go
+++ /dev/null
@@ -1,312 +0,0 @@
-package main
-
-import (
- "fmt"
- "strconv"
- "strings"
-)
-
-// Board holds Go grid state.
-type Board struct {
- size int
- komi float64
- stones []Stone
- player Color
- ko int // linear index prohibited by simple ko, or -1
- hash uint64
- undo []undoSnap
-}
-
-type undoSnap struct {
- move Move
- player Color
- ko int
- hash uint64
- captured []int
- stoneIdx int
- prevStone Stone
-}
-
-// NewBoard creates an empty board of the given size and komi.
-func NewBoard(size int, komi float64) *Board {
- if size < 2 || size > 19 {
- panic("board size out of range")
- }
- b := &Board{
- size: size,
- komi: komi,
- stones: make([]Stone, size*size),
- player: Black,
- ko: -1,
- }
- b.Rehash()
- return b
-}
-
-func (b *Board) Size() int { return b.size }
-func (b *Board) Komi() float64 { return b.komi }
-func (b *Board) Player() Color { return b.player }
-
-// SetPlayer sets side to move (SGF setup PL property).
-func (b *Board) SetPlayer(c Color) { b.player = c }
-
-func (b *Board) Ko() int { return b.ko }
-func (b *Board) Hash() uint64 { return b.hash }
-
-func (b *Board) StoneAt(p Point) Stone {
- idx := p.Idx(b.size)
- if idx < 0 {
- return Empty
- }
- return b.stones[idx]
-}
-
-func (b *Board) AtIndex(idx int) Stone {
- if idx < 0 || idx >= len(b.stones) {
- return Empty
- }
- return b.stones[idx]
-}
-
-// StartPlay records undo before rules apply a move.
-func (b *Board) StartPlay(m Move, captured []int, stoneIdx int, prev Stone) {
- b.undo = append(b.undo, undoSnap{
- move: m,
- player: b.player,
- ko: b.ko,
- hash: b.hash,
- captured: append([]int(nil), captured...),
- stoneIdx: stoneIdx,
- prevStone: prev,
- })
-}
-
-// SetStoneIndex places color at linear index (rules use after legality check).
-func (b *Board) SetStoneIndex(idx int, c Stone) {
- b.setStone(idx, c)
-}
-
-func (b *Board) setStone(idx int, c Stone) {
- old := b.stones[idx]
- if old == c {
- return
- }
- if old != Empty {
- b.hash ^= zobristTable[idx][old]
- }
- b.stones[idx] = c
- if c != Empty {
- b.hash ^= zobristTable[idx][c]
- }
-}
-
-// FinishTurn switches player and sets ko point.
-func (b *Board) FinishTurn(newKo int) {
- b.ko = newKo
- if b.player == Black {
- b.player = White
- } else {
- b.player = Black
- }
-}
-
-// MoveNum returns completed moves on the board.
-func (b *Board) MoveNum() int { return len(b.undo) }
-
-// historyMoves returns up to n most recent moves, oldest first (for feature history planes).
-func (b *Board) historyMoves(n int) []undoSnap {
- if n <= 0 || len(b.undo) == 0 {
- return nil
- }
- start := len(b.undo) - n
- if start < 0 {
- start = 0
- }
- out := make([]undoSnap, len(b.undo)-start)
- copy(out, b.undo[start:])
- return out
-}
-
-func (b *Board) CanUndo() bool { return len(b.undo) > 0 }
-
-func (b *Board) Undo() bool {
- if len(b.undo) == 0 {
- return false
- }
- s := b.undo[len(b.undo)-1]
- b.undo = b.undo[:len(b.undo)-1]
- b.player = s.player
- b.ko = s.ko
- b.hash = s.hash
- if s.move.Pass {
- return true
- }
- b.stones[s.stoneIdx] = s.prevStone
- for _, idx := range s.captured {
- b.stones[idx] = s.player.Opposite()
- }
- return true
-}
-
-// Clone returns a deep copy (benchmarks).
-func (b *Board) Clone() *Board {
- c := *b
- c.stones = append([]Stone(nil), b.stones...)
- c.undo = append([]undoSnap(nil), b.undo...)
- for i := range c.undo {
- c.undo[i].captured = append([]int(nil), b.undo[i].captured...)
- }
- return &c
-}
-
-// Rehash recomputes Zobrist from stones.
-func (b *Board) Rehash() {
- var h uint64
- for i, s := range b.stones {
- if s != Empty {
- h ^= zobristTable[i][s]
- }
- }
- b.hash = h
-}
-
-// forEachNeighbor visits in-bounds adjacent indices without allocating.
-func (b *Board) forEachNeighbor(idx int, fn func(int)) {
- size := b.size
- x, y := idx%size, idx/size
- if x > 0 {
- fn(idx - 1)
- }
- if x+1 < size {
- fn(idx + 1)
- }
- if y > 0 {
- fn(idx - size)
- }
- if y+1 < size {
- fn(idx + size)
- }
-}
-
-// Neighbors returns in-bounds adjacent linear indices.
-func (b *Board) Neighbors(idx int) []int {
- size := b.size
- x, y := idx%size, idx/size
- var out []int
- if x > 0 {
- out = append(out, idx-1)
- }
- if x+1 < size {
- out = append(out, idx+1)
- }
- if y > 0 {
- out = append(out, idx-size)
- }
- if y+1 < size {
- out = append(out, idx+size)
- }
- return out
-}
-
-// Snapshot captures board state.
-type Snapshot struct {
- Stones []Stone
- Player Color
- Komi float64
- Ko int
- Hash uint64
-}
-
-func (b *Board) Snapshot() Snapshot {
- return Snapshot{
- Stones: append([]Stone(nil), b.stones...),
- Player: b.player,
- Komi: b.komi,
- Ko: b.ko,
- Hash: b.hash,
- }
-}
-
-func (b *Board) Restore(s Snapshot) {
- copy(b.stones, s.Stones)
- b.player = s.Player
- b.komi = s.Komi
- b.ko = s.Ko
- b.hash = s.Hash
-}
-
-// cloneTrial returns a board copy without undo history (legality trials only).
-func (b *Board) cloneTrial() *Board {
- c := *b
- c.stones = append([]Stone(nil), b.stones...)
- c.undo = nil
- return &c
-}
-
-// trialSnap captures state for repeated trial restores without re-cloning.
-type trialSnap struct {
- stones []Stone
- player Color
- ko int
-}
-
-func (b *Board) captureTrialSnap() trialSnap {
- return trialSnap{
- stones: append([]Stone(nil), b.stones...),
- player: b.player,
- ko: b.ko,
- }
-}
-
-func (b *Board) restoreTrialSnap(s trialSnap) {
- copy(b.stones, s.stones)
- b.player = s.player
- b.ko = s.ko
-}
-
-func formatGTPBoard(b *Board, size int) string {
- var sb strings.Builder
- sb.WriteString(" ")
- for x := 0; x < size; x++ {
- col := rune('A' + x)
- if col >= 'I' {
- col++
- }
- sb.WriteRune(col)
- sb.WriteByte(' ')
- }
- sb.WriteByte('\n')
- for y := 0; y < size; y++ {
- row := size - y
- if row < 10 {
- sb.WriteByte(' ')
- }
- sb.WriteString(strconv.Itoa(row))
- sb.WriteByte(' ')
- for x := 0; x < size; x++ {
- switch b.StoneAt(At(x, y)) {
- case Black:
- sb.WriteByte('X')
- case White:
- sb.WriteByte('O')
- default:
- sb.WriteByte('.')
- }
- sb.WriteByte(' ')
- }
- sb.WriteByte('\n')
- }
- return strings.TrimRight(sb.String(), "\n")
-}
-
-func formatGTPScore(b *Board, r Ruleset) string {
- bl, wl := r.Score(b)
- diff := bl - wl
- if diff > 0 {
- return fmt.Sprintf("B+%.1f", diff)
- }
- if diff < 0 {
- return fmt.Sprintf("W+%.1f", -diff)
- }
- return "0"
-}
diff --git a/cmd/gofer/chinese_rules.go b/cmd/gofer/chinese_rules.go
deleted file mode 100644
index 4cd4db0..0000000
--- a/cmd/gofer/chinese_rules.go
+++ /dev/null
@@ -1,119 +0,0 @@
-package main
-
-// chineseRules implements Chinese rules: area scoring, suicide if capturing, simple ko.
-type chineseRules struct{}
-
-// LegalMoves returns all legal moves including pass.
-func (r *chineseRules) LegalMoves(b *Board) []Move {
- size := b.Size()
- n := size * size
- ko := b.Ko()
- var scratch legalityScratch
- scratch.prepare(b)
- moves := make([]Move, 0, n+1)
- for idx := 0; idx < n; idx++ {
- if idx == ko || b.AtIndex(idx) != Empty {
- continue
- }
- scratch.trial.restoreTrialSnap(scratch.snap)
- scratch.mark.ensure(n)
- if r.wouldBeLegalTrialScratch(scratch.trial, idx, &scratch) {
- moves = append(moves, StoneMove(PointFromIdx(size, idx)))
- }
- }
- moves = append(moves, PassMove)
- return moves
-}
-
-// Play applies m for the current player. Returns false if illegal.
-func (r *chineseRules) Play(b *Board, m Move) bool {
- player := b.Player()
- if m.Pass {
- b.StartPlay(m, nil, -1, Empty)
- b.FinishTurn(-1)
- return true
- }
- idx := m.Point.Idx(b.Size())
- if idx < 0 || b.AtIndex(idx) != Empty || idx == b.Ko() {
- return false
- }
- if !r.wouldBeLegal(b, idx) {
- return false
- }
- prev := b.AtIndex(idx)
- trial := b.Clone()
- trial.SetStoneIndex(idx, player)
- captured := removeDeadGroups(trial, player.Opposite())
- if libertyCount(trial, idx, player) == 0 {
- return false
- }
- b.StartPlay(m, captured, idx, prev)
- b.SetStoneIndex(idx, player)
- for _, cidx := range captured {
- b.SetStoneIndex(cidx, Empty)
- }
- newKo := -1
- if len(captured) == 1 {
- newKo = captured[0]
- }
- b.FinishTurn(newKo)
- return true
-}
-
-// Score returns area scores; komi added to white.
-// Seki neutral; no dead-stone removal pass.
-// Ceiling: tournament Chinese may differ.
-// Upgrade: two-pass dead-stone scoring.
-func (r *chineseRules) Score(b *Board) (black, white float64) {
- size := b.Size()
- n := size * size
- for i := 0; i < n; i++ {
- switch b.AtIndex(i) {
- case Black:
- black++
- case White:
- white++
- }
- }
- seen := make([]bool, n)
- for i := 0; i < n; i++ {
- if seen[i] || b.AtIndex(i) != Empty {
- continue
- }
- t, tb, tw := floodEmpty(b, i, seen)
- if tb && !tw {
- black += float64(t)
- } else if tw && !tb {
- white += float64(t)
- }
- }
- white += b.Komi()
- return black, white
-}
-
-func (r *chineseRules) wouldBeLegal(b *Board, idx int) bool {
- if idx == b.Ko() {
- return false
- }
- trial := b.Clone()
- return r.wouldBeLegalTrial(trial, idx)
-}
-
-func (r *chineseRules) wouldBeLegalTrial(trial *Board, idx int) bool {
- var scratch legalityScratch
- n := trial.Size() * trial.Size()
- scratch.trial = trial
- if cap(scratch.groupBuf) < n {
- scratch.groupBuf = make([]int, 0, n)
- scratch.stackBuf = make([]int, 0, n)
- }
- scratch.mark.ensure(n)
- return r.wouldBeLegalTrialScratch(trial, idx, &scratch)
-}
-
-func (r *chineseRules) wouldBeLegalTrialScratch(trial *Board, idx int, scratch *legalityScratch) bool {
- player := trial.Player()
- trial.SetStoneIndex(idx, player)
- removeDeadGroupsMark(trial, player.Opposite(), &scratch.mark, &scratch.groupBuf, &scratch.stackBuf)
- return libertyCountMark(trial, idx, player, &scratch.mark, &scratch.groupBuf, &scratch.stackBuf) > 0
-}
diff --git a/cmd/gofer/chinese_scoring_test.go b/cmd/gofer/chinese_scoring_test.go
deleted file mode 100644
index 276697d..0000000
--- a/cmd/gofer/chinese_scoring_test.go
+++ /dev/null
@@ -1,204 +0,0 @@
-package main
-
-import (
- "testing"
-)
-
-// chineseAreaDecomposition mirrors chineseRules.Score territory logic without komi.
-// Returns black area, white area, neutral (seki), and unassigned (dame touching no stones).
-func chineseAreaDecomposition(b *Board) (black, white, neutral, unassigned int) {
- n := b.Size() * b.Size()
- for i := 0; i < n; i++ {
- switch b.AtIndex(i) {
- case Black:
- black++
- case White:
- white++
- }
- }
- seen := make([]bool, n)
- for i := 0; i < n; i++ {
- if seen[i] || b.AtIndex(i) != Empty {
- continue
- }
- t, tb, tw := floodEmpty(b, i, seen)
- switch {
- case tb && !tw:
- black += t
- case tw && !tb:
- white += t
- case tb && tw:
- neutral += t
- default:
- unassigned += t
- }
- }
- return black, white, neutral, unassigned
-}
-
-func setStone(t *testing.T, b *Board, x, y int, c Stone) {
- t.Helper()
- if x < 0 || y < 0 || x >= b.Size() || y >= b.Size() {
- t.Fatalf("coord (%d,%d) out of range for %dx%d", x, y, b.Size(), b.Size())
- }
- b.SetStoneIndex(y*b.Size()+x, c)
-}
-
-func playCoord(t *testing.T, r Ruleset, b *Board, x, y int) {
- t.Helper()
- m := StoneMove(At(x, y))
- if !r.Play(b, m) {
- t.Fatalf("illegal play at (%d,%d) player=%v", x, y, b.Player())
- }
-}
-
-func passMove(t *testing.T, r Ruleset, b *Board) {
- t.Helper()
- if !r.Play(b, PassMove) {
- t.Fatal("pass illegal")
- }
-}
-
-// (a) Empty board: no stones, no territory assigned — only komi affects final white score.
-func TestChineseAreaEmptyBoardNoTerritory(t *testing.T) {
- r := Chinese()
- b := NewBoard(9, 0)
- bl, wl, neutral, unassigned := chineseAreaDecomposition(b)
- if bl != 0 || wl != 0 || neutral != 0 || unassigned != 81 {
- t.Fatalf("empty 9x9 want 0,0,0,81 unassigned got %d %d %d %d", bl, wl, neutral, unassigned)
- }
- scBl, scWl := r.Score(b)
- if scBl != 0 || scWl != 0 {
- t.Fatalf("Score with komi=0 want 0,0 got %v %v", scBl, scWl)
- }
- b2 := NewBoard(9, 6.5)
- _, scWl2 := r.Score(b2)
- if scWl2 != 6.5 {
- t.Fatalf("empty board white score should be komi only, got %v", scWl2)
- }
-}
-
-// (b) Mirror-symmetric stones: equal area for both colors before komi.
-func TestChineseAreaSymmetricTerritories(t *testing.T) {
- r := Chinese()
- b := NewBoard(9, 0)
- for _, c := range [][2]int{{0, 0}, {1, 0}, {0, 1}} {
- setStone(t, b, c[0], c[1], Black)
- }
- for _, c := range [][2]int{{8, 8}, {7, 8}, {8, 7}} {
- setStone(t, b, c[0], c[1], White)
- }
- bl, wl, _, _ := chineseAreaDecomposition(b)
- if bl != wl {
- t.Fatalf("mirror stones want equal area got black=%d white=%d", bl, wl)
- }
- if bl < 3 {
- t.Fatalf("expected at least 3 stones per side, got black=%d", bl)
- }
- _ = r
-}
-
-// (c) Surrounded on-board stone: current scorer counts it for owner (no Benson removal).
-func TestChineseAreaDeadStoneCountsAsTerritory(t *testing.T) {
- r := Chinese()
- b := NewBoard(9, 0)
- setStone(t, b, 4, 4, White)
- for _, c := range [][2]int{{3, 4}, {5, 4}, {4, 3}, {4, 5}, {0, 0}} {
- setStone(t, b, c[0], c[1], Black)
- }
- bl, wl, _, _ := chineseAreaDecomposition(b)
- if wl < 1 {
- t.Fatalf("white dead stone still on board counts as white area in current impl, got white=%d", wl)
- }
- t.Logf("ceiling: dead on-board stone counts for owner (black=%d white=%d); Benson pass not implemented", bl, wl)
- _ = r
-}
-
-// (d) Conservation: stones + attributed territory + neutral + unassigned == board size.
-func TestChineseAreaConservationInvariant(t *testing.T) {
- r := Chinese()
- cases := []struct {
- name string
- play func(t *testing.T, r Ruleset, b *Board)
- }{
- {"empty", func(t *testing.T, r Ruleset, b *Board) {}},
- {"single_black", func(t *testing.T, r Ruleset, b *Board) { playCoord(t, r, b, 4, 4) }},
- {"double_pass", func(t *testing.T, r Ruleset, b *Board) {
- playCoord(t, r, b, 2, 2)
- playCoord(t, r, b, 6, 6)
- passMove(t, r, b)
- passMove(t, r, b)
- }},
- {"mirror_corners", func(t *testing.T, r Ruleset, b *Board) {
- for _, c := range [][2]int{{0, 0}, {1, 0}, {0, 1}} {
- setStone(t, b, c[0], c[1], Black)
- }
- for _, c := range [][2]int{{8, 8}, {7, 8}, {8, 7}} {
- setStone(t, b, c[0], c[1], White)
- }
- }},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- b := NewBoard(9, 6.5)
- tc.play(t, r, b)
- bl, wl, neutral, unassigned := chineseAreaDecomposition(b)
- sum := bl + wl + neutral + unassigned
- if sum != 81 {
- t.Fatalf("conservation failed: black=%d white=%d neutral=%d unassigned=%d sum=%d want 81",
- bl, wl, neutral, unassigned, sum)
- }
- })
- }
-}
-
-// (e) Alternation: after N moves both colors had equal turns (black may lead by one).
-func TestChineseMoveParityEqualTurns(t *testing.T) {
- r := Chinese()
- b := NewBoard(9, 6.5)
- const moves = 20
- for i := 0; i < moves; i++ {
- x, y := i%9, (i*2)%9
- if b.AtIndex(y*9+x) == Empty {
- playCoord(t, r, b, x, y)
- } else {
- passMove(t, r, b)
- }
- }
- n := b.MoveNum()
- blackPlays := (n + 1) / 2
- whitePlays := n / 2
- if blackPlays-whitePlays > 1 {
- t.Fatalf("turn parity: black=%d white=%d after %d moves", blackPlays, whitePlays, n)
- }
-}
-
-// (f) Indexing symmetry: mirror position swaps area counts.
-func TestChineseAreaIndexingSymmetry(t *testing.T) {
- mirror := func(x, y, size int) (int, int) { return size - 1 - x, size - 1 - y }
-
- buildCorner := func(color Stone) *Board {
- b := NewBoard(9, 0)
- c := func(x, y int) { b.SetStoneIndex(y*9+x, color) }
- if color == Black {
- c(0, 0)
- c(1, 0)
- c(0, 1)
- } else {
- mx, my := mirror(0, 0, 9)
- c(mx, my)
- mx, my = mirror(1, 0, 9)
- c(mx, my)
- mx, my = mirror(0, 1, 9)
- c(mx, my)
- }
- return b
- }
-
- blB, wlB, _, _ := chineseAreaDecomposition(buildCorner(Black))
- blW, wlW, _, _ := chineseAreaDecomposition(buildCorner(White))
- if blB != wlW || wlB != blW {
- t.Fatalf("mirror corners should swap area counts: black-corner bl=%d wl=%d white-corner bl=%d wl=%d",
- blB, wlB, blW, wlW)
- }
-}
diff --git a/cmd/gofer/cli.go b/cmd/gofer/cli.go
deleted file mode 100644
index 7b39803..0000000
--- a/cmd/gofer/cli.go
+++ /dev/null
@@ -1,402 +0,0 @@
-package main
-
-import (
- "bufio"
- "fmt"
- "net/http"
- "os"
- "strconv"
- "strings"
- "time"
-)
-
-func evalBackendInprocess() bool {
- return strings.EqualFold(evalConfig.Backend, "inprocess")
-}
-
-func parseEvaluator(name string) Evaluator {
- switch {
- case strings.EqualFold(name, "uniform"):
- return Uniform{}
- case strings.EqualFold(name, "batched"), strings.EqualFold(name, "mock-batch"):
- return NewBatchedEvaluatorWithTimeout(
- Inference{MockValue: 0, Latency: 500 * time.Microsecond},
- Heuristic{},
- evalConfig.BatchSize,
- evalConfig.MaxWait,
- evalConfig.EvalTimeout,
- )
- case strings.EqualFold(name, "onnx"), strings.EqualFold(name, "onnx-batch"):
- return newONNXEvaluator(evalConfig.BatchSize)
- case strings.EqualFold(name, "onnx2"):
- return newONNXEvaluatorSecondary(evalConfig.BatchSize)
- case strings.EqualFold(name, "heuristic2"):
- return Heuristic{}
- default:
- return Heuristic{}
- }
-}
-
-// newONNXEvaluator builds a batched ONNX evaluator (sidecar or in-process).
-func newONNXEvaluator(minBatch int) Evaluator {
- return newONNXEvaluatorModel(evalConfig.ModelPath, minBatch)
-}
-
-// newONNXEvaluatorSecondary builds the second ONNX evaluator (onnx2 / challenger model).
-func newONNXEvaluatorSecondary(minBatch int) Evaluator {
- model := evalConfig.ModelPath2
- if model == "" {
- model = evalConfig.ModelPath
- }
- return newONNXEvaluatorModel(model, minBatch)
-}
-
-// newONNXEvaluatorURL builds a batched sidecar evaluator against an explicit URL.
-func newONNXEvaluatorURL(url string, minBatch int) Evaluator {
- if evalBackendInprocess() {
- model := evalConfig.ModelPath
- if url == evalConfig.ONNXURL2 {
- model = evalConfig.ModelPath2
- if model == "" {
- model = evalConfig.ModelPath
- }
- }
- return newONNXEvaluatorModel(model, minBatch)
- }
- if url == "" {
- url = "http://127.0.0.1:8080"
- }
- if minBatch < 1 {
- minBatch = evalConfig.BatchSize
- }
- return NewBatchedEvaluatorWithTimeout(
- SidecarBackend{
- URL: url,
- Fallback: Heuristic{},
- Client: &http.Client{Timeout: evalConfig.EvalTimeout},
- },
- Heuristic{},
- minBatch,
- evalConfig.MaxWait,
- evalConfig.EvalTimeout,
- )
-}
-
-func newONNXEvaluatorModel(modelPath string, minBatch int) Evaluator {
- if minBatch < 1 {
- minBatch = evalConfig.BatchSize
- }
- if evalBackendInprocess() {
- backend, err := newORTBackend(modelPath, Heuristic{})
- if err != nil {
- fmt.Fprintf(os.Stderr, "in-process ONNX: %v\n", err)
- os.Exit(1)
- }
- return NewBatchedEvaluatorWithTimeout(
- backend,
- Heuristic{},
- minBatch,
- evalConfig.MaxWait,
- evalConfig.EvalTimeout,
- )
- }
- return newONNXEvaluatorURL(evalConfig.ONNXURL, minBatch)
-}
-
-func newSearchEngine(r Ruleset, playouts int, think time.Duration, evalName string) *Engine {
- return newSearchEngineSeed(r, playouts, think, evalName, DefaultConfig().Seed)
-}
-
-func newSearchEngineSeed(r Ruleset, playouts int, think time.Duration, evalName string, seed int64) *Engine {
- cfg := DefaultConfig()
- cfg.Playouts = playouts
- cfg.ThinkTime = think
- cfg.Seed = seed
- return NewEngine(r, parseEvaluator(evalName), cfg)
-}
-
-func defaultPlayoutsForSize(size int) int {
- switch {
- case size <= 9:
- return 400
- case size <= 13:
- return 800
- default:
- return 1600
- }
-}
-
-func printAnalyzeTable(a Analysis, size int, header string) {
- if header != "" {
- fmt.Println(header)
- }
- fmt.Println(" move visits share winrate")
- for _, c := range a.Candidates {
- fmt.Printf(" %-8s %7d %6.1f%% %7.3f\n",
- moveToGTPVertex(c.Move, size), c.Visits, c.Share*100, c.WinRate)
- }
- if len(a.PV) > 0 {
- parts := make([]string, len(a.PV))
- for i, m := range a.PV {
- parts[i] = moveToGTPVertex(m, size)
- }
- fmt.Printf("\nPV: %s\n", strings.Join(parts, " "))
- }
- fmt.Printf("\nBest: %s\n", moveToGTPVertex(a.Best, size))
-}
-
-func runAnalyze(size int, komi float64, playouts int, think time.Duration, topN int, evalName string, setup []string) {
- r := Chinese()
- b := NewBoard(size, komi)
- if err := applySetupMoves(r, b, setup); err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- if playouts <= 0 && think <= 0 {
- playouts = defaultPlayoutsForSize(size)
- }
- eng := newSearchEngine(r, playouts, think, evalName)
- a := eng.Analyze(b, topN)
- printBoard(b, size)
- header := fmt.Sprintf("\n%s to move | %d playouts | root value %.3f\n",
- colorName(b.Player()), a.Playouts, a.RootValue)
- printAnalyzeTable(a, size, header)
-}
-
-type playSession struct {
- r Ruleset
- b *Board
- log *GameLog
- eng *Engine
- human Color
- size int
-}
-
-func newPlaySession(size int, komi float64, playouts int, think time.Duration, evalName, humanColor string) *playSession {
- r := Chinese()
- human := Black
- if strings.EqualFold(humanColor, "w") || strings.EqualFold(humanColor, "white") {
- human = White
- }
- return &playSession{
- r: r,
- b: NewBoard(size, komi),
- log: NewGameLog(size, komi),
- eng: newSearchEngine(r, playouts, think, evalName),
- human: human,
- size: size,
- }
-}
-
-func (ps *playSession) printTurnHint() {
- n := len(ps.r.LegalMoves(ps.b))
- if ps.b.Player() == ps.human {
- fmt.Printf("%d legal moves | your turn — play \n", n)
- } else {
- fmt.Printf("%d legal moves | engine turn — genmove\n", n)
- }
-}
-
-func (ps *playSession) cmdQuit(sgfOut string) bool {
- if sgfOut != "" {
- if err := ps.log.WriteSGF(sgfOut); err != nil {
- fmt.Fprintln(os.Stderr, err)
- } else {
- fmt.Printf("saved %s\n", sgfOut)
- }
- }
- return true
-}
-
-func (ps *playSession) cmdUndo() {
- if !ps.b.CanUndo() {
- fmt.Println("nothing to undo")
- return
- }
- ps.b.Undo()
- if len(ps.log.Moves) > 0 {
- ps.log.Moves = ps.log.Moves[:len(ps.log.Moves)-1]
- }
- ps.eng.ResetArena()
- printBoard(ps.b, ps.size)
- ps.printTurnHint()
-}
-
-func (ps *playSession) cmdAnalyze(parts []string) {
- n := 5
- if len(parts) >= 2 {
- if v, err := strconv.Atoi(parts[1]); err == nil {
- n = v
- }
- }
- a := ps.eng.Analyze(ps.b, n)
- header := fmt.Sprintf("%d playouts | root %.3f", a.Playouts, a.RootValue)
- printAnalyzeTable(a, ps.size, header)
-}
-
-func (ps *playSession) cmdGenmove() {
- if ps.b.Player() == ps.human {
- fmt.Println("your turn — use play ")
- return
- }
- m := ps.eng.BestMove(ps.b)
- color := ps.b.Player()
- if !ps.r.Play(ps.b, m) {
- m = PassMove
- ps.r.Play(ps.b, m)
- }
- ps.log.Record(color, m)
- ps.eng.AdvanceTree(m)
- fmt.Printf("engine %s\n", moveToGTPVertex(m, ps.size))
- printBoard(ps.b, ps.size)
- if onlyPass(ps.r.LegalMoves(ps.b)) {
- fmt.Println("game over (only pass left)")
- fmt.Println(formatGTPScore(ps.b, ps.r))
- } else {
- ps.printTurnHint()
- }
-}
-
-func (ps *playSession) cmdPlay(parts []string) {
- if len(parts) < 2 {
- fmt.Println("usage: play (e.g. D4 or pass)")
- return
- }
- if ps.b.Player() != ps.human {
- fmt.Println("engine turn — use genmove")
- return
- }
- m, err := parseGTPVertex(parts[1], ps.size)
- if err != nil {
- fmt.Println(err.Error())
- return
- }
- if !ps.r.Play(ps.b, m) {
- fmt.Println("illegal move")
- return
- }
- ps.log.Record(ps.human, m)
- ps.eng.AdvanceTree(m)
- fmt.Printf("played %s\n", moveToGTPVertex(m, ps.size))
- printBoard(ps.b, ps.size)
- ps.printTurnHint()
-}
-
-func (ps *playSession) handleCommand(parts []string, sgfOut string) bool {
- switch strings.ToLower(parts[0]) {
- case "quit", "exit", "q":
- return ps.cmdQuit(sgfOut)
- case "board", "show":
- printBoard(ps.b, ps.size)
- case "score":
- fmt.Println(formatGTPScore(ps.b, ps.r))
- case "undo":
- ps.cmdUndo()
- case "analyze":
- ps.cmdAnalyze(parts)
- case "genmove":
- ps.cmdGenmove()
- case "play":
- ps.cmdPlay(parts)
- default:
- fmt.Println("unknown command — board | play | genmove | analyze | score | undo | quit")
- }
- return false
-}
-
-func runPlay(size int, komi float64, playouts int, think time.Duration, evalName, humanColor string, sgfOut string) {
- ps := newPlaySession(size, komi, playouts, think, evalName, humanColor)
- fmt.Printf("Gofer %dx%d komi=%.1f — you are %s\n", size, size, komi, colorName(ps.human))
- fmt.Println("Commands: board | play | genmove | analyze | score | undo | quit")
- printBoard(ps.b, size)
- ps.printTurnHint()
-
- in := bufio.NewScanner(os.Stdin)
- for {
- fmt.Print("> ")
- if !in.Scan() {
- break
- }
- line := strings.TrimSpace(in.Text())
- if line == "" {
- continue
- }
- if ps.handleCommand(strings.Fields(line), sgfOut) {
- return
- }
- }
-}
-
-func watchUntilEnd(r Ruleset, b *Board, eng *Engine, size int, onMove func(Color, Move)) int {
- passes := 0
- for moveNum := 0; moveNum < size*size+2; moveNum++ {
- if onlyPass(r.LegalMoves(b)) {
- break
- }
- color := b.Player()
- m := eng.BestMove(b)
- if !r.Play(b, m) {
- m = PassMove
- r.Play(b, m)
- }
- if onMove != nil {
- onMove(color, m)
- }
- eng.AdvanceTree(m)
- if m.Pass {
- passes++
- } else {
- passes = 0
- }
- if passes >= 2 {
- break
- }
- }
- return passes
-}
-
-func runWatch(size int, komi float64, playouts int, think time.Duration, evalName string) {
- if playouts <= 0 && think <= 0 {
- playouts = defaultPlayoutsForSize(size)
- }
- r := Chinese()
- b := NewBoard(size, komi)
- eng := newSearchEngine(r, playouts, think, evalName)
- fmt.Printf("Gofer watch %dx%d komi=%.1f\n", size, size, komi)
- moveNum := 0
- watchUntilEnd(r, b, eng, size, func(color Color, m Move) {
- moveNum++
- fmt.Printf("\n%d. %s %s\n", moveNum, colorName(color), moveToGTPVertex(m, size))
- printBoard(b, size)
- })
- fmt.Println("\n" + formatGTPScore(b, r))
-}
-
-func applySetupMoves(r Ruleset, b *Board, moves []string) error {
- for i, raw := range moves {
- m, err := parseGTPVertex(raw, b.Size())
- if err != nil {
- return fmt.Errorf("setup move %d: %w", i, err)
- }
- if !r.Play(b, m) {
- return fmt.Errorf("illegal setup move %d: %s", i, raw)
- }
- }
- return nil
-}
-
-func printBoard(b *Board, size int) {
- fmt.Println(formatGTPBoard(b, size))
- fmt.Printf("%s to move\n", colorName(b.Player()))
-}
-
-func colorName(c Color) string {
- switch c {
- case Black:
- return "Black"
- case White:
- return "White"
- default:
- return "?"
- }
-}
diff --git a/cmd/gofer/cmdline.go b/cmd/gofer/cmdline.go
deleted file mode 100644
index 48b38db..0000000
--- a/cmd/gofer/cmdline.go
+++ /dev/null
@@ -1,339 +0,0 @@
-package main
-
-import (
- "bufio"
- "encoding/json"
- "flag"
- "fmt"
- "os"
- "strings"
- "time"
-)
-
-type cliFlags struct {
- gtp, play, analyze, selfplay, watch, arena, convertSGF, verifyJSONL bool
- convertMaxRows int
- convertEpsilon float64
- games int
- size int
- komi float64
- playouts, gtpPlayouts int
- think time.Duration
- topN int
- eval, humanColor, out, sgfDir string
- blackEval, whiteEval, arenaJSON string
- blackPlayouts, whitePlayouts int
- selfplayFullOnly bool
- selfplayEval string
- selfplayONNXFraction float64
- selfplayParallel int
- selfplayTempMoves int
- selfplayFastPlayouts int
- selfplayFullPlayouts int
- selfplayCapRandomizeP float64
- selfplayFixedPlayouts bool
- seed int64
- sgfPath, setup string
- modelPath, onnxURL, onnxURL2 string
- evalBackend, modelPath2 string
- batchSize int
- evalTimeout time.Duration
- arenaEnhanced string
- arenaParallel int
- arenaOpeningMoves int
- arenaTemp float64
-}
-
-func parseCLIFlags() cliFlags {
- var f cliFlags
- flag.BoolVar(&f.gtp, "gtp", false, "run GTP protocol on stdin/stdout")
- flag.BoolVar(&f.play, "play", false, "interactive terminal game")
- flag.BoolVar(&f.analyze, "analyze", false, "analyze current position")
- flag.BoolVar(&f.selfplay, "selfplay", false, "run self-play and emit training samples")
- flag.BoolVar(&f.watch, "watch", false, "engine vs engine demo (mutually exclusive with other modes)")
- flag.BoolVar(&f.arena, "arena", false, "run arena match (baseline vs challenger)")
- flag.BoolVar(&f.convertSGF, "convert-sgf", false, "convert SGF archives to supervised JSONL (pass input dirs as args)")
- flag.BoolVar(&f.verifyJSONL, "verify-jsonl", false, "verify converted JSONL sample schema (-o path)")
- flag.IntVar(&f.convertMaxRows, "convert-max-rows", 0, "max training rows for -convert-sgf (0 = unlimited)")
- flag.Float64Var(&f.convertEpsilon, "convert-epsilon", defaultPolicyEpsilon, "label-smoothing epsilon for -convert-sgf policy targets")
- flag.IntVar(&f.games, "games", 1, "number of games (with -selfplay or -arena)")
- flag.IntVar(&f.size, "size", 9, "board size")
- flag.Float64Var(&f.komi, "komi", 6.5, "komi")
- flag.IntVar(&f.playouts, "playouts", 0, "MCTS playouts per move (0 = size default)")
- flag.IntVar(&f.gtpPlayouts, "gtp-playouts", 0, "MCTS playouts per GTP move (0 = size default)")
- flag.DurationVar(&f.think, "think-time", 0, "search time budget per move (overrides -playouts when set)")
- flag.IntVar(&f.topN, "top", 5, "top moves to show (with -analyze)")
- flag.StringVar(&f.eval, "eval", "heuristic", "evaluator: uniform, heuristic, batched, onnx")
- flag.StringVar(&f.humanColor, "color", "b", "your color in -play: b or w")
- flag.StringVar(&f.out, "o", "", "output path (self-play JSON, game SGF, or GTP SGF on quit)")
- flag.StringVar(&f.sgfDir, "sgf-dir", "", "write self-play games as SGF files to directory")
- flag.StringVar(&f.sgfPath, "sgf", "", "replay SGF file and print score")
- flag.StringVar(&f.setup, "moves", "", "comma-separated setup moves for -analyze (e.g. D4,Q16)")
- flag.StringVar(&f.blackEval, "black-eval", "heuristic", "baseline evaluator for -arena")
- flag.StringVar(&f.whiteEval, "white-eval", "uniform", "challenger evaluator for -arena")
- flag.StringVar(&f.arenaJSON, "json", "", "write arena match JSON report to path")
- flag.IntVar(&f.blackPlayouts, "black-playouts", 0, "baseline playouts for -arena (0 = -playouts)")
- flag.IntVar(&f.whitePlayouts, "white-playouts", 0, "challenger playouts for -arena (0 = -playouts)")
- flag.BoolVar(&f.selfplayFullOnly, "full-only", true, "export only full-search self-play positions")
- flag.StringVar(&f.selfplayEval, "selfplay-eval", "mix", "self-play evaluator: heuristic, onnx, mix")
- flag.Float64Var(&f.selfplayONNXFraction, "selfplay-onnx-fraction", 0.7, "fraction of mix-mode games using ONNX (odd games use ONNX)")
- flag.IntVar(&f.selfplayParallel, "selfplay-parallel", 8, "concurrent self-play games (feeds real batches to the ONNX sidecar)")
- flag.IntVar(&f.selfplayTempMoves, "selfplay-temp-moves", 16, "opening plies sampled from the visit distribution for game diversity (0 = always argmax)")
- flag.IntVar(&f.selfplayFastPlayouts, "selfplay-fast-playouts", 0, "self-play fast search cap per move (0 = full/4)")
- flag.IntVar(&f.selfplayFullPlayouts, "selfplay-full-playouts", 0, "self-play full search cap per move (0 = -playouts)")
- flag.Float64Var(&f.selfplayCapRandomizeP, "selfplay-cap-randomize-p", 0.20, "fraction of self-play moves that use the full cap (0 = fixed full cap every move)")
- flag.BoolVar(&f.selfplayFixedPlayouts, "selfplay-fixed-playouts", false, "disable cap randomization: every move uses -playouts (sets fast=full and cap-randomize-p=0)")
- flag.Int64Var(&f.seed, "seed", 1, "RNG seed for -arena and -selfplay")
- flag.StringVar(&f.modelPath, "model", "models/gofer-9x9-bootstrap.onnx", "ONNX model path (sidecar or in-process primary)")
- flag.StringVar(&f.modelPath2, "model-2", "models/gofer-9x9-candidate.onnx", "second ONNX model for -white-eval onnx2 / champion-vs-challenger")
- flag.StringVar(&f.evalBackend, "eval-backend", "inprocess", "ONNX inference transport: inprocess (ORT in Go binary, requires -tags=onnx build) or sidecar (HTTP)")
- flag.StringVar(&f.onnxURL, "onnx-url", "http://127.0.0.1:8080", "ONNX inference sidecar base URL")
- flag.StringVar(&f.onnxURL2, "onnx-url-2", "http://127.0.0.1:8081", "second ONNX sidecar base URL (eval name onnx2, for champion-vs-challenger)")
- flag.IntVar(&f.batchSize, "batch-size", 8, "batched evaluator minimum batch size")
- flag.DurationVar(&f.evalTimeout, "eval-timeout", 500*time.Millisecond, "batched/onnx eval timeout before heuristic fallback")
- flag.StringVar(&f.arenaEnhanced, "arena-enhanced", "none", "arena forced root playouts: none, baseline, both")
- flag.IntVar(&f.arenaParallel, "arena-parallel", 8, "concurrent arena games (shared evaluators feed real batches to the sidecars)")
- flag.IntVar(&f.arenaOpeningMoves, "arena-opening-moves", 8, "opening plies sampled from the visit distribution so match games differ (0 = deterministic, identical games)")
- flag.Float64Var(&f.arenaTemp, "arena-temp", 1.0, "sampling temperature for arena opening plies")
- flag.Parse()
- SetEvalConfig(EvalConfig{
- ModelPath: f.modelPath,
- ModelPath2: f.modelPath2,
- ONNXURL: f.onnxURL,
- ONNXURL2: f.onnxURL2,
- Backend: f.evalBackend,
- BatchSize: f.batchSize,
- EvalTimeout: f.evalTimeout,
- MaxWait: 2 * time.Millisecond,
- })
- return f
-}
-
-func runMode(f cliFlags) bool {
- n := 0
- for _, on := range []bool{f.gtp, f.play, f.analyze, f.selfplay, f.watch, f.arena, f.convertSGF, f.verifyJSONL} {
- if on {
- n++
- }
- }
- if n > 1 {
- fmt.Fprintln(os.Stderr, "only one mode flag allowed (-gtp, -play, -analyze, -selfplay, -watch, -arena, -convert-sgf, -verify-jsonl)")
- os.Exit(1)
- }
- switch {
- case f.gtp:
- runGTP(f.gtpPlayouts, f.size, f.think, f.eval, f.out)
- case f.play:
- p := f.playouts
- if p <= 0 {
- p = defaultPlayoutsForSize(f.size)
- }
- runPlay(f.size, f.komi, p, f.think, f.eval, f.humanColor, f.out)
- case f.analyze:
- p := f.playouts
- if p <= 0 && f.think <= 0 {
- p = defaultPlayoutsForSize(f.size)
- }
- runAnalyze(f.size, f.komi, p, f.think, f.topN, f.eval, parseSetupMoves(f.setup))
- case f.selfplay:
- runSelfplayCLI(f)
- case f.watch:
- p := f.playouts
- if p <= 0 && f.think <= 0 {
- p = defaultPlayoutsForSize(f.size)
- }
- runWatch(f.size, f.komi, p, f.think, f.eval)
- case f.arena:
- runArenaCLI(f)
- case f.convertSGF:
- if f.out == "" {
- fmt.Fprintln(os.Stderr, "-convert-sgf requires -o output.jsonl")
- os.Exit(1)
- }
- runSGFConvertCLI(f, flag.Args())
- case f.verifyJSONL:
- runVerifyJSONLCLI(f.out, 5)
- default:
- return false
- }
- return true
-}
-
-func parseSetupMoves(setup string) []string {
- if setup == "" {
- return nil
- }
- var moves []string
- for _, m := range strings.Split(setup, ",") {
- if s := strings.TrimSpace(m); s != "" {
- moves = append(moves, s)
- }
- }
- return moves
-}
-
-func printUsage(size int, komi float64) {
- b := NewBoard(size, komi)
- r := Chinese()
- fmt.Printf("gofer %dx%d komi=%.1f, %d legal moves\n", size, size, komi, len(r.LegalMoves(b)))
- fmt.Println("\nUsage:")
- fmt.Println(" gofer -play interactive game")
- fmt.Println(" gofer -analyze MCTS analysis")
- fmt.Println(" gofer -gtp GTP engine (Sabaki/Lizzie)")
- fmt.Println(" gofer -watch engine vs engine demo")
- fmt.Println(" gofer -selfplay generate training games")
- fmt.Println(" gofer -arena baseline vs challenger match")
- fmt.Println(" gofer -convert-sgf SGF dirs → supervised JSONL (-o required)")
- fmt.Println(" gofer -sgf game.sgf replay SGF")
-}
-
-func runArenaCLI(f cliFlags) {
- cfg := MatchConfig{
- Games: f.games,
- Size: f.size,
- Komi: f.komi,
- Playouts: f.playouts,
- BlackPlayouts: f.blackPlayouts,
- WhitePlayouts: f.whitePlayouts,
- ThinkTime: f.think,
- BlackEval: f.blackEval,
- WhiteEval: f.whiteEval,
- Seed: f.seed,
- ArenaEnhanced: f.arenaEnhanced,
- Parallel: f.arenaParallel,
- OpeningMoves: f.arenaOpeningMoves,
- OpeningTemp: f.arenaTemp,
- }
- result := RunMatch(cfg)
- data, err := json.MarshalIndent(result, "", " ")
- if err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- if f.arenaJSON != "" {
- if err := os.WriteFile(f.arenaJSON, data, 0644); err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- }
- fmt.Printf("arena %d games: baseline(%s)=%d challenger(%s)=%d draws=%d black=%d white=%d win_rate_challenger=%.3f CI=[%.3f,%.3f] promoted=%v hash=%s\n",
- result.Games, result.BaselineEval, result.WinsBaseline, result.ChallengerEval, result.WinsChallenger,
- result.Draws, result.WinsBlack, result.WinsWhite,
- result.WinRateChallenger, result.WilsonCILow, result.WilsonCIHigh, result.Promoted, result.ConfigHash)
- if f.arenaJSON == "" {
- fmt.Println(string(data))
- }
-}
-
-func runSGFReplay(path string) {
- n, bl, wl, err := ReplaySGFFile(path)
- if err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- fmt.Printf("replayed %d moves: black=%.1f white=%.1f\n", n, bl, wl)
-}
-
-func runGTP(playouts, defaultSize int, think time.Duration, evalMode, sgfOut string) {
- if playouts <= 0 {
- playouts = defaultPlayoutsForSize(defaultSize)
- }
- s := NewSession(SessionConfig{
- Playouts: playouts,
- ThinkTime: think,
- Eval: evalMode,
- BoardSize: defaultSize,
- })
- in := bufio.NewScanner(os.Stdin)
- for in.Scan() {
- line := in.Text()
- if strings.TrimSpace(line) == "quit" {
- if sgfOut != "" {
- if err := s.log.WriteSGF(sgfOut); err != nil {
- fmt.Fprintln(os.Stderr, err)
- } else {
- fmt.Fprintf(os.Stderr, "saved %s\n", sgfOut)
- }
- }
- fmt.Print("= \n\n")
- break
- }
- out := s.Handle(line)
- if strings.HasPrefix(out, "?") {
- fmt.Printf("? %s\n\n", out)
- } else {
- fmt.Printf("= %s\n\n", out)
- }
- }
-}
-
-func runSelfplayCLI(f cliFlags) {
- playouts := f.playouts
- if playouts <= 0 {
- playouts = defaultPlayoutsForSize(f.size)
- }
- cfg := DefaultSelfplayConfig()
- cfg.Games = f.games
- cfg.BoardSize = f.size
- cfg.Komi = f.komi
- cfg.Playouts = playouts
- cfg.FullOnlyExport = f.selfplayFullOnly
- cfg.EvalMode = f.selfplayEval
- cfg.ONNXFraction = f.selfplayONNXFraction
- cfg.Seed = f.seed
- cfg.Parallel = f.selfplayParallel
- cfg.TemperatureMoves = f.selfplayTempMoves
- if f.selfplayFixedPlayouts {
- cfg.FastPlayouts = playouts
- cfg.FullPlayouts = playouts
- cfg.CapRandomizeP = 0
- } else {
- cfg.FastPlayouts = f.selfplayFastPlayouts
- cfg.FullPlayouts = f.selfplayFullPlayouts
- cfg.CapRandomizeP = f.selfplayCapRandomizeP
- }
- samples, logs := RunSelfplayWithLogs(cfg)
- if f.out != "" {
- writeSelfplayJSON(f.out, samples)
- } else if f.sgfDir == "" {
- data, _ := MarshalSampleExport(samples)
- fmt.Println(string(data))
- }
- if f.sgfDir != "" {
- writeSelfplaySGFs(f.sgfDir, logs)
- }
-}
-
-func writeSelfplayJSON(path string, samples []Sample) {
- if isJSONLPath(path) {
- if err := WriteSampleJSONL(path, samples); err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- return
- }
- data, err := MarshalSampleExport(samples)
- if err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- if err := os.WriteFile(path, data, 0644); err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
-}
-
-func writeSelfplaySGFs(dir string, logs []*GameLog) {
- if err := os.MkdirAll(dir, 0755); err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- for i, log := range logs {
- path := fmt.Sprintf("%s/game-%03d.sgf", dir, i+1)
- if err := log.WriteSGF(path); err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- }
- fmt.Printf("wrote %d SGF files to %s\n", len(logs), dir)
-}
diff --git a/cmd/gofer/color.go b/cmd/gofer/color.go
deleted file mode 100644
index 4d46b73..0000000
--- a/cmd/gofer/color.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package main
-
-import (
- "fmt"
- "strings"
-)
-
-// Color is stone color or empty.
-type Color uint8
-
-const (
- Empty Color = iota
- Black
- White
-)
-
-// Stone is an alias for Color on the grid.
-type Stone = Color
-
-func (c Color) Opposite() Color {
- switch c {
- case Black:
- return White
- case White:
- return Black
- default:
- return Empty
- }
-}
-
-func parseGTPColor(s string) (Color, error) {
- switch strings.ToUpper(s) {
- case "B", "BLACK":
- return Black, nil
- case "W", "WHITE":
- return White, nil
- default:
- return Empty, fmt.Errorf("invalid color")
- }
-}
diff --git a/cmd/gofer/commands.go b/cmd/gofer/commands.go
new file mode 100644
index 0000000..2968d3f
--- /dev/null
+++ b/cmd/gofer/commands.go
@@ -0,0 +1,312 @@
+package main
+
+import (
+ "bufio"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "os"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/DevomB/gofer/internal/arena"
+ "github.com/DevomB/gofer/internal/game"
+ "github.com/DevomB/gofer/internal/gtp"
+ "github.com/DevomB/gofer/internal/mcts"
+ "github.com/DevomB/gofer/internal/nn"
+ "github.com/DevomB/gofer/internal/selfplay"
+)
+
+func cmdGTP(args []string) error {
+ fs := flag.NewFlagSet("gtp", flag.ExitOnError)
+ var nf netFlags
+ var sf searchFlags
+ nf.register(fs)
+ sf.register(fs, 800)
+ size := fs.Int("size", 9, "board size the model was trained for")
+ resign := fs.Float64("resign", -0.95, "resign below this root value (0 disables)")
+ verbose := fs.Bool("v", false, "log search info to stderr")
+ fs.Parse(args)
+ eval, closeEval, err := nf.evaluator(*size, 1)
+ if err != nil {
+ return err
+ }
+ defer closeEval()
+ e := gtp.Engine{Name: "Gofer", Version: version, Eval: eval, Search: sf.config(1), Visits: sf.visits, Superko: true, ResignBelow: *resign}
+ if *verbose {
+ e.Log = os.Stderr
+ }
+ return gtp.NewEngine(e).Serve(os.Stdin, os.Stdout)
+}
+
+func cmdPlay(args []string) error {
+ fs := flag.NewFlagSet("play", flag.ExitOnError)
+ var nf netFlags
+ var sf searchFlags
+ nf.register(fs)
+ sf.register(fs, 400)
+ size := fs.Int("size", 9, "board size")
+ komi := fs.Float64("komi", 7.5, "komi")
+ color := fs.String("color", "b", "your color: b or w")
+ fs.Parse(args)
+ eval, closeEval, err := nf.evaluator(*size, 1)
+ if err != nil {
+ return err
+ }
+ defer closeEval()
+ human := game.Black
+ if strings.HasPrefix(strings.ToLower(*color), "w") {
+ human = game.White
+ }
+ b := game.NewBoard(game.Options{Size: *size, Komi: *komi, Superko: true})
+ s := mcts.New(sf.config(time.Now().UnixNano()), eval)
+ s.Reset(b)
+ in := bufio.NewScanner(os.Stdin)
+ fmt.Println("Moves: D4, pass, resign, hint, quit")
+ for !b.GameOver() {
+ fmt.Printf("\n%s\n", b)
+ var m game.Point
+ if b.ToPlay() == human {
+ fmt.Printf("%s to move> ", b.ToPlay())
+ if !in.Scan() {
+ return nil
+ }
+ cmd := strings.TrimSpace(in.Text())
+ switch strings.ToLower(cmd) {
+ case "quit", "resign":
+ fmt.Println("You resigned.")
+ return nil
+ case "hint":
+ s.Run(sf.visits)
+ fmt.Printf("hint: %s (win %.0f%%)\n", b.FormatPoint(s.BestMove()), 50*(1+s.RootValue()))
+ continue
+ }
+ p, err := game.ParsePoint(cmd, *size)
+ if err != nil || !b.IsLegal(p) {
+ fmt.Println("illegal move")
+ continue
+ }
+ m = p
+ } else {
+ if err := s.Run(sf.visits); err != nil {
+ return err
+ }
+ m = s.BestMove()
+ fmt.Printf("Gofer plays %s (win %.0f%%, pv %s)\n", b.FormatPoint(m), 50*(1+s.RootValue()), pvString(s.PV(6), *size))
+ }
+ b.Play(m)
+ s.Advance(m, b)
+ }
+ fmt.Printf("\n%s\nGame over: %s\n", b, scoreString(b.Margin()))
+ return nil
+}
+
+func pvString(pv []game.Point, size int) string {
+ parts := make([]string, len(pv))
+ for i, p := range pv {
+ parts[i] = game.FormatPoint(p, size)
+ }
+ return strings.Join(parts, " ")
+}
+
+func scoreString(m float64) string {
+ switch {
+ case m > 0:
+ return fmt.Sprintf("B+%g", m)
+ case m < 0:
+ return fmt.Sprintf("W+%g", -m)
+ }
+ return "draw"
+}
+
+func cmdSelfplay(args []string) error {
+ fs := flag.NewFlagSet("selfplay", flag.ExitOnError)
+ var nf netFlags
+ var sf searchFlags
+ nf.register(fs)
+ sf.register(fs, 0)
+ var c selfplay.Config
+ fs.IntVar(&c.Size, "size", 9, "board size")
+ fs.Float64Var(&c.Komi, "komi", 7.5, "komi")
+ fs.IntVar(&c.Games, "games", 100, "games to play")
+ fs.IntVar(&c.Parallel, "parallel", 0, "concurrent games (0: 8 per inference worker)")
+ fs.IntVar(&c.FullVisits, "full-visits", 200, "visits for recorded (full) searches")
+ fs.IntVar(&c.FastVisits, "fast-visits", 40, "visits for cheap searches (0: every move is full)")
+ fs.Float64Var(&c.FullProb, "full-prob", 0.25, "probability a move gets a full search")
+ fs.IntVar(&c.TempMoves, "temp-moves", 10, "opening plies sampled from visit counts")
+ fs.Float64Var(&c.ResignThreshold, "resign", -0.9, "resign below this value (0 disables)")
+ fs.Float64Var(&c.NoResignProb, "no-resign-prob", 0.1, "fraction of games played out to measure false resignations")
+ fs.Int64Var(&c.Seed, "seed", 1, "random seed")
+ fs.StringVar(&c.OutDir, "out", "selfplay", "output directory for .npz shards")
+ fs.StringVar(&c.Prefix, "prefix", "shard", "shard file prefix")
+ fs.IntVar(&c.ShardGames, "shard-games", 250, "games per shard")
+ fs.StringVar(&c.SGFDir, "sgf", "", "also write SGF records here")
+ statsPath := fs.String("stats", "", "write run statistics as JSON")
+ fs.Parse(args)
+ c.Superko = true
+ c.Search = sf.config(c.Seed)
+ eval, closeEval, err := nf.evaluator(c.Size, c.Seed)
+ if err != nil {
+ return err
+ }
+ defer closeEval()
+ if c.Parallel == 0 {
+ w := nf.workers
+ if w == 0 {
+ w = runtime.NumCPU()
+ }
+ c.Parallel = 8 * w
+ }
+ c.Log = func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }
+ st, err := selfplay.Run(c, eval)
+ if err != nil {
+ return err
+ }
+ out := map[string]any{
+ "games": st.Games, "moves": st.Moves, "rows": st.Rows, "black_wins": st.BlackWins,
+ "resigned": st.Resigned, "false_resigns": st.FalseResigns, "no_resign_triggered": st.NoResignWithTrigger,
+ "seconds": st.Elapsed.Seconds(), "games_per_second": float64(st.Games) / st.Elapsed.Seconds(),
+ "shards": st.Shards,
+ }
+ if net, ok := eval.(*nn.Net); ok {
+ out["nn_positions"] = net.Stats.Positions.Load()
+ out["nn_batches"] = net.Stats.Batches.Load()
+ out["nn_cache_hits"] = net.Stats.CacheHits.Load()
+ out["nn_seconds"] = float64(net.Stats.NetNanos.Load()) / 1e9
+ }
+ fmt.Fprintf(os.Stderr, "selfplay: %d games, %d rows in %.0fs (%.2f games/s), black won %.1f%%, resigned %d, false resigns %d/%d\n",
+ st.Games, st.Rows, st.Elapsed.Seconds(), out["games_per_second"], 100*float64(st.BlackWins)/float64(st.Games),
+ st.Resigned, st.FalseResigns, st.NoResignWithTrigger)
+ if *statsPath != "" {
+ data, _ := json.MarshalIndent(out, "", " ")
+ return os.WriteFile(*statsPath, data, 0o644)
+ }
+ return nil
+}
+
+func cmdArena(args []string) error {
+ fs := flag.NewFlagSet("arena", flag.ExitOnError)
+ var nf netFlags
+ var sf searchFlags
+ nf.register(fs)
+ sf.register(fs, 200)
+ a := fs.String("a", "", "player A spec (nn:PATH | rollout | uniform | gnugo,level=N | gtp:CMD), options ,visits=N")
+ bspec := fs.String("b", "", "player B spec")
+ var c arena.Config
+ fs.IntVar(&c.Size, "size", 9, "board size")
+ fs.Float64Var(&c.Komi, "komi", 7.5, "komi")
+ fs.IntVar(&c.Games, "games", 100, "games (even: each opening is played with both colors)")
+ fs.IntVar(&c.Parallel, "parallel", 0, "concurrent games (0: 2 per CPU)")
+ fs.IntVar(&c.OpeningMoves, "opening", 2, "random opening plies")
+ fs.Int64Var(&c.Seed, "seed", 1, "random seed")
+ fs.StringVar(&c.SGFDir, "sgf", "", "write game records here")
+ sprt := fs.String("sprt", "", "stop early via SPRT: elo0,elo1 (e.g. 0,35)")
+ jsonPath := fs.String("json", "", "write the result as JSON")
+ fs.Parse(args)
+ if *a == "" || *bspec == "" {
+ return fmt.Errorf("arena needs -a and -b")
+ }
+ pa, err := parsePlayerSpec(*a, sf.visits)
+ if err != nil {
+ return err
+ }
+ pb, err := parsePlayerSpec(*bspec, sf.visits)
+ if err != nil {
+ return err
+ }
+ if *sprt != "" {
+ var e0, e1 float64
+ if _, err := fmt.Sscanf(*sprt, "%g,%g", &e0, &e1); err != nil {
+ return fmt.Errorf("bad -sprt %q", *sprt)
+ }
+ c.SPRT = &arena.SPRT{Elo0: e0, Elo1: e1, Alpha: 0.05, Beta: 0.05}
+ }
+ if c.Parallel == 0 {
+ c.Parallel = 2 * runtime.NumCPU()
+ }
+ c.Superko = true
+ c.Log = func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }
+ pool := &nets{m: map[string]*nn.Net{}, f: &nf, size: c.Size}
+ defer pool.close()
+ var seed int64 = c.Seed * 7919
+ res, err := arena.Run(c, pa.factory(pool, &sf, &seed), pb.factory(pool, &sf, &seed))
+ if err != nil {
+ return err
+ }
+ fmt.Printf("%s vs %s: %d-%d-%d score %.3f [%.3f, %.3f] elo %+.0f [%+.0f, %+.0f] black wins %.0f%% %s\n",
+ res.NameA, res.NameB, res.Wins, res.Losses, res.Draws, res.Score, res.ScoreLow, res.ScoreHigh,
+ res.Elo, res.EloLow, res.EloHigh, 100*res.BlackWinRate, res.SPRTDecision)
+ if *jsonPath != "" {
+ data, _ := json.MarshalIndent(res, "", " ")
+ return os.WriteFile(*jsonPath, data, 0o644)
+ }
+ return nil
+}
+
+func cmdBench(args []string) error {
+ fs := flag.NewFlagSet("bench", flag.ExitOnError)
+ var nf netFlags
+ nf.register(fs)
+ size := fs.Int("size", 9, "board size")
+ games := fs.Int("games", 64, "concurrent self-play-like searches")
+ visits := fs.Int("visits", 100, "visits per move")
+ dur := fs.Duration("time", 10*time.Second, "benchmark duration")
+ fs.Parse(args)
+ eval, closeEval, err := nf.evaluator(*size, 1)
+ if err != nil {
+ return err
+ }
+ defer closeEval()
+ var playouts, moves int64
+ done := make(chan struct{})
+ results := make(chan [2]int64, *games)
+ for g := 0; g < *games; g++ {
+ go func(g int) {
+ var p, m int64
+ cfg := mcts.DefaultConfig()
+ cfg.Seed = int64(g)
+ s := mcts.New(cfg, eval)
+ b := game.NewBoard(game.Options{Size: *size, Komi: 7.5, Superko: true})
+ s.Reset(b)
+ for {
+ select {
+ case <-done:
+ results <- [2]int64{p, m}
+ return
+ default:
+ }
+ if b.GameOver() || b.MoveNum() > 2*b.NumPoints() {
+ b = game.NewBoard(game.Options{Size: *size, Komi: 7.5, Superko: true})
+ s.Reset(b)
+ }
+ if err := s.Run(*visits); err != nil {
+ results <- [2]int64{p, m}
+ return
+ }
+ mv := s.SampleMove(1)
+ b.Play(mv)
+ s.Advance(mv, b)
+ p += int64(*visits)
+ m++
+ }
+ }(g)
+ }
+ time.Sleep(*dur)
+ close(done)
+ for g := 0; g < *games; g++ {
+ r := <-results
+ playouts += r[0]
+ moves += r[1]
+ }
+ sec := dur.Seconds()
+ fmt.Printf("bench: %d searches, %.0f playouts/s, %.1f moves/s", *games, float64(playouts)/sec, float64(moves)/sec)
+ if net, ok := eval.(*nn.Net); ok {
+ pos := net.Stats.Positions.Load()
+ fmt.Printf(", nn %.0f positions/s, mean batch %.1f, cache hits %.0f%%",
+ float64(pos)/sec, float64(pos)/float64(max(net.Stats.Batches.Load(), 1)),
+ 100*float64(net.Stats.CacheHits.Load())/float64(max(pos+net.Stats.CacheHits.Load(), 1)))
+ }
+ fmt.Println()
+ return nil
+}
diff --git a/cmd/gofer/engine.go b/cmd/gofer/engine.go
new file mode 100644
index 0000000..26f11e7
--- /dev/null
+++ b/cmd/gofer/engine.go
@@ -0,0 +1,229 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "runtime"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/DevomB/gofer/internal/arena"
+ "github.com/DevomB/gofer/internal/mcts"
+ "github.com/DevomB/gofer/internal/nn"
+)
+
+// netFlags are the shared flags that configure a network evaluator.
+type netFlags struct {
+ model string
+ device string
+ workers int
+ threads int
+ maxBatch int
+ wait time.Duration
+ cache int
+ noSym bool
+}
+
+func (f *netFlags) register(fs *flag.FlagSet) {
+ fs.StringVar(&f.model, "model", "", "ONNX model exported by training/ (empty: network-free uniform evaluator)")
+ fs.StringVar(&f.device, "device", "cpu", "inference device: cpu, cuda, tensorrt")
+ fs.IntVar(&f.workers, "workers", 0, "concurrent inference sessions (0: CPU cores, or 2 on GPU)")
+ fs.IntVar(&f.threads, "threads", 1, "intra-op threads per CPU session")
+ fs.IntVar(&f.maxBatch, "batch", 0, "max inference batch (0: 32 on CPU, 512 on GPU)")
+ fs.DurationVar(&f.wait, "batch-wait", 0, "max time to fill a batch (0: 200µs on CPU, 2ms on GPU)")
+ fs.IntVar(&f.cache, "cache", 200_000, "position cache entries")
+ fs.BoolVar(&f.noSym, "no-symmetry", false, "disable random board symmetry at inference")
+}
+
+// evaluator builds the evaluator described by the flags.
+func (f *netFlags) evaluator(size int, seed int64) (nn.Evaluator, func(), error) {
+ if f.model == "" {
+ return nn.Uniform{}, func() {}, nil
+ }
+ return openNet(f.model, size, seed, f)
+}
+
+func openNet(path string, size int, seed int64, f *netFlags) (*nn.Net, func(), error) {
+ gpu := f.device != "cpu"
+ cfg := nn.NetConfig{
+ Size: size,
+ Workers: f.workers,
+ MaxBatch: f.maxBatch,
+ Wait: f.wait,
+ RandomSymmetry: !f.noSym,
+ CacheEntries: f.cache,
+ Seed: seed,
+ }
+ if cfg.Workers == 0 {
+ cfg.Workers = runtime.NumCPU()
+ if gpu {
+ cfg.Workers = 2
+ }
+ }
+ if cfg.MaxBatch == 0 {
+ cfg.MaxBatch = 32
+ if gpu {
+ cfg.MaxBatch = 512
+ }
+ }
+ if cfg.Wait == 0 {
+ cfg.Wait = 200 * time.Microsecond
+ if gpu {
+ cfg.Wait = 2 * time.Millisecond
+ }
+ }
+ opts := nn.ORTOptions{Device: f.device, CPUThreads: f.threads}
+ net, err := nn.NewNet(cfg, func() (nn.Backend, error) { return nn.NewORTBackend(path, size, opts) })
+ if err != nil {
+ return nil, nil, err
+ }
+ return net, func() { net.Close() }, nil
+}
+
+// searchFlags configure MCTS.
+type searchFlags struct {
+ cpuct float64
+ fpu float64
+ batch int
+ visits int
+}
+
+func (f *searchFlags) register(fs *flag.FlagSet, visits int) {
+ d := mcts.DefaultConfig()
+ fs.Float64Var(&f.cpuct, "cpuct", d.CPuct, "PUCT exploration constant")
+ fs.Float64Var(&f.fpu, "fpu", d.FPUReduction, "first-play-urgency reduction")
+ fs.IntVar(&f.batch, "leaf-batch", d.Batch, "leaves evaluated together per search step")
+ fs.IntVar(&f.visits, "visits", visits, "playouts per move")
+}
+
+func (f *searchFlags) config(seed int64) mcts.Config {
+ c := mcts.DefaultConfig()
+ c.CPuct, c.FPUReduction, c.Batch, c.Seed = f.cpuct, f.fpu, f.batch, seed
+ return c
+}
+
+// playerSpec builds arena players from strings such as
+//
+// nn:models/gen-0004.onnx,visits=200
+// rollout,visits=400,playouts=2
+// uniform,visits=50
+// gnugo,level=5
+// gtp:/path/to/engine --mode gtp
+type playerSpec struct {
+ kind string
+ arg string
+ opts map[string]string
+ visits int
+}
+
+func parsePlayerSpec(s string, defVisits int) (playerSpec, error) {
+ ps := playerSpec{opts: map[string]string{}, visits: defVisits}
+ if rest, ok := strings.CutPrefix(s, "gtp:"); ok {
+ ps.kind, ps.arg = "gtp", rest
+ return ps, nil
+ }
+ parts := strings.Split(s, ",")
+ head := parts[0]
+ ps.kind = head
+ if k, a, ok := strings.Cut(head, ":"); ok {
+ ps.kind, ps.arg = k, a
+ }
+ for _, kv := range parts[1:] {
+ k, v, ok := strings.Cut(kv, "=")
+ if !ok {
+ return ps, fmt.Errorf("bad option %q in %q", kv, s)
+ }
+ ps.opts[k] = v
+ }
+ if v, ok := ps.opts["visits"]; ok {
+ n, err := strconv.Atoi(v)
+ if err != nil {
+ return ps, err
+ }
+ ps.visits = n
+ }
+ switch ps.kind {
+ case "nn", "rollout", "uniform", "gnugo", "gtp":
+ default:
+ return ps, fmt.Errorf("unknown player kind %q", ps.kind)
+ }
+ return ps, nil
+}
+
+func (ps playerSpec) label() string {
+ switch ps.kind {
+ case "nn":
+ name := ps.arg[strings.LastIndex(ps.arg, "/")+1:]
+ return fmt.Sprintf("%s@%d", strings.TrimSuffix(name, ".onnx"), ps.visits)
+ case "gnugo":
+ return "gnugo-l" + ps.optOr("level", "10")
+ case "gtp":
+ return strings.Fields(ps.arg)[0]
+ }
+ return fmt.Sprintf("%s@%d", ps.kind, ps.visits)
+}
+
+func (ps playerSpec) optOr(k, def string) string {
+ if v, ok := ps.opts[k]; ok {
+ return v
+ }
+ return def
+}
+
+// nets shares one evaluator per model path across all arena games.
+type nets struct {
+ mu sync.Mutex
+ m map[string]*nn.Net
+ f *netFlags
+ size int
+}
+
+func (n *nets) get(path string) (*nn.Net, error) {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ if net, ok := n.m[path]; ok {
+ return net, nil
+ }
+ net, _, err := openNet(path, n.size, int64(len(n.m)+1), n.f)
+ if err != nil {
+ return nil, err
+ }
+ n.m[path] = net
+ return net, nil
+}
+
+func (n *nets) close() {
+ for _, net := range n.m {
+ net.Close()
+ }
+}
+
+func (ps playerSpec) factory(pool *nets, sf *searchFlags, seed *int64) func() (arena.Player, error) {
+ return func() (arena.Player, error) {
+ cfg := sf.config(0)
+ pool.mu.Lock()
+ *seed++
+ cfg.Seed = *seed
+ pool.mu.Unlock()
+ switch ps.kind {
+ case "nn":
+ net, err := pool.get(ps.arg)
+ if err != nil {
+ return nil, err
+ }
+ return &arena.MCTSPlayer{Label: ps.label(), Eval: net, Config: cfg, Visits: ps.visits}, nil
+ case "rollout":
+ k, _ := strconv.Atoi(ps.optOr("playouts", "1"))
+ return &arena.MCTSPlayer{Label: ps.label(), Eval: nn.NewRollout(k, cfg.Seed), Config: cfg, Visits: ps.visits}, nil
+ case "uniform":
+ return &arena.MCTSPlayer{Label: ps.label(), Eval: nn.Uniform{}, Config: cfg, Visits: ps.visits}, nil
+ case "gnugo":
+ bin := ps.optOr("bin", "gnugo")
+ return arena.NewGTPPlayer(ps.label(), []string{bin, "--mode", "gtp", "--level", ps.optOr("level", "10"), "--chinese-rules"}), nil
+ default:
+ return arena.NewGTPPlayer(ps.label(), strings.Fields(ps.arg)), nil
+ }
+ }
+}
diff --git a/cmd/gofer/eval_config.go b/cmd/gofer/eval_config.go
deleted file mode 100644
index 4a33ff1..0000000
--- a/cmd/gofer/eval_config.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package main
-
-import "time"
-
-// EvalConfig holds CLI options for evaluator construction.
-type EvalConfig struct {
- ModelPath string
- ModelPath2 string // second model for eval name "onnx2" (champion-vs-challenger)
- ONNXURL string
- ONNXURL2 string // second sidecar (eval name "onnx2") for champion-vs-challenger arenas
- Backend string // inprocess (default) or sidecar
- BatchSize int
- EvalTimeout time.Duration
- MaxWait time.Duration
-}
-
-var evalConfig = EvalConfig{
- Backend: "inprocess",
- BatchSize: 8,
- EvalTimeout: 500 * time.Millisecond,
- MaxWait: 2 * time.Millisecond,
-}
-
-// SetEvalConfig updates package-level evaluator options (called from flag parse).
-func SetEvalConfig(c EvalConfig) {
- if c.BatchSize < 1 {
- c.BatchSize = 8
- }
- if c.EvalTimeout <= 0 {
- c.EvalTimeout = 8 * time.Millisecond
- }
- if c.MaxWait <= 0 {
- c.MaxWait = 2 * time.Millisecond
- }
- if c.Backend == "" {
- c.Backend = "inprocess"
- }
- evalConfig = c
-}
diff --git a/cmd/gofer/evaluator.go b/cmd/gofer/evaluator.go
deleted file mode 100644
index 1047462..0000000
--- a/cmd/gofer/evaluator.go
+++ /dev/null
@@ -1,253 +0,0 @@
-package main
-
-import (
- "math"
- "math/rand"
-)
-
-const (
- // dirichletAlpha ~ 10/avg-legal-moves; 9x9 opens with ~70 moves. The AZ 19x19
- // value (0.03) is far too peaky here and kills root exploration on 9x9.
- dirichletAlpha = 0.15
- dirichletBlend = 0.25
- maxRolloutMoves = 150
-)
-
-// Result holds leaf evaluation from an Evaluator.
-type Result struct {
- Value float64 // from current player perspective, in [-1,1]
- Policy []float32 // optional move priors indexed by point (len size*size+1 for pass)
- HasValue bool // true when Value is a real estimate (false => caller may roll out)
-}
-
-// Evaluator scores positions and optional policy priors.
-type Evaluator interface {
- Evaluate(b *Board) Result
-}
-
-// Uniform returns equal priors and zero value.
-type Uniform struct{}
-
-func (Uniform) Evaluate(b *Board) Result {
- n := b.Size()*b.Size() + 1
- p := make([]float32, n)
- for i := range p {
- p[i] = 1
- }
- return Result{Value: 0, Policy: p}
-}
-
-// Heuristic combines material, liberties, territory, and move priors.
-type Heuristic struct{}
-
-func (Heuristic) Evaluate(b *Board) Result {
- size := b.Size()
- n := size * size
- blS, wlS := 0, 0
- for i := 0; i < n; i++ {
- switch b.AtIndex(i) {
- case Black:
- blS++
- case White:
- wlS++
- }
- }
- blL, wlL := groupLibertyTotals(b, Black), groupLibertyTotals(b, White)
- blT, wlT := estimateTerritory(b)
- score := float64(blS-wlS) + 0.15*float64(blL-wlL) + float64(blT-wlT)
- v := score / float64(max(n, 1))
- if b.Player() == White {
- v = -v
- }
- v = clamp(v, -1, 1)
- return Result{Value: v, Policy: heuristicPolicy(b), HasValue: true}
-}
-
-func groupLibertyTotals(b *Board, color Color) int {
- n := b.Size() * b.Size()
- seen := make([]bool, n)
- total := 0
- for i := 0; i < n; i++ {
- if seen[i] || b.AtIndex(i) != color {
- continue
- }
- for _, idx := range collectGroup(b, i, color) {
- seen[idx] = true
- }
- total += libertyCount(b, i, color)
- }
- return total
-}
-
-func estimateTerritory(b *Board) (black, white int) {
- n := b.Size() * b.Size()
- seen := make([]bool, n)
- for i := 0; i < n; i++ {
- if b.AtIndex(i) != Empty || seen[i] {
- continue
- }
- t, tb, tw := floodEmpty(b, i, seen)
- if tb && !tw {
- black += t
- }
- if tw && !tb {
- white += t
- }
- }
- return black, white
-}
-
-func heuristicPolicy(b *Board) []float32 {
- size := b.Size()
- n := size*size + 1
- p := make([]float32, n)
- player := b.Player()
- opp := player.Opposite()
- for y := 0; y < size; y++ {
- for x := 0; x < size; x++ {
- pt := At(x, y)
- if b.StoneAt(pt) != Empty {
- continue
- }
- p[pt.Idx(size)] = policyPriorAt(b, pt, player, opp)
- }
- }
- p[size*size] = 0.02
- return p
-}
-
-func policyPriorAt(b *Board, pt Point, player, opp Color) float32 {
- score := float32(0.05)
- for _, nb := range b.Neighbors(pt.Idx(b.Size())) {
- switch b.AtIndex(nb) {
- case player:
- score += 1
- case opp:
- score -= 0.4
- }
- }
- if score < 0.01 {
- return 0.01
- }
- return score
-}
-
-func clamp(v, lo, hi float64) float64 {
- if v < lo {
- return lo
- }
- if v > hi {
- return hi
- }
- return v
-}
-
-// Mock returns fixed value/policy for tests.
-type Mock struct {
- Value float64
- Policy []float32
-}
-
-func (m Mock) Evaluate(b *Board) Result {
- return Result{Value: m.Value, Policy: m.Policy, HasValue: true}
-}
-
-func uniformPriors(n int) []float64 {
- if n == 0 {
- return nil
- }
- p := 1.0 / float64(n)
- out := make([]float64, n)
- for i := range out {
- out[i] = p
- }
- return out
-}
-
-func uniformPolicy32(n int) []float32 {
- out := make([]float32, n)
- if n == 0 {
- return out
- }
- for i := range out {
- out[i] = 1 / float32(n)
- }
- return out
-}
-
-func policyPriors(b *Board, moves []Move, policy []float32) []float64 {
- size := b.Size()
- sum := float64(0)
- raw := make([]float64, len(moves))
- for i, m := range moves {
- idx := size * size
- if !m.Pass {
- idx = m.Point.Idx(size)
- }
- if idx >= 0 && idx < len(policy) {
- raw[i] = float64(policy[idx])
- } else {
- raw[i] = 1
- }
- sum += raw[i]
- }
- if sum == 0 {
- return uniformPriors(len(moves))
- }
- for i := range raw {
- raw[i] /= sum
- }
- return raw
-}
-
-// blendDirichlet mixes symmetric Dirichlet(alpha) noise into root priors (AZ SE-4.1).
-func blendDirichlet(priors []float64, rng *rand.Rand) []float64 {
- noise := sampleDirichlet(len(priors), dirichletAlpha, rng)
- out := make([]float64, len(priors))
- for i := range out {
- out[i] = (1-dirichletBlend)*priors[i] + dirichletBlend*noise[i]
- }
- return out
-}
-
-// sampleDirichlet draws a symmetric Dirichlet(alpha) vector via normalized Gamma samples.
-func sampleDirichlet(n int, alpha float64, rng *rand.Rand) []float64 {
- out := make([]float64, n)
- sum := 0.0
- for i := range out {
- out[i] = sampleGamma(alpha, rng)
- sum += out[i]
- }
- if sum <= 0 {
- return uniformPriors(n)
- }
- for i := range out {
- out[i] /= sum
- }
- return out
-}
-
-// sampleGamma draws from Gamma(shape, 1) using Marsaglia-Tsang, with the
-// shape<1 boost trick. Correct for the small alpha we use at the root.
-func sampleGamma(shape float64, rng *rand.Rand) float64 {
- if shape < 1 {
- return sampleGamma(shape+1, rng) * math.Pow(rng.Float64(), 1/shape)
- }
- d := shape - 1.0/3.0
- c := 1.0 / math.Sqrt(9*d)
- for {
- x := rng.NormFloat64()
- v := 1 + c*x
- if v <= 0 {
- continue
- }
- v = v * v * v
- u := rng.Float64()
- if u < 1-0.0331*x*x*x*x {
- return d * v
- }
- if math.Log(u) < 0.5*x*x+d*(1-v+math.Log(v)) {
- return d * v
- }
- }
-}
diff --git a/cmd/gofer/features.go b/cmd/gofer/features.go
deleted file mode 100644
index 8e32e6f..0000000
--- a/cmd/gofer/features.go
+++ /dev/null
@@ -1,146 +0,0 @@
-package main
-
-import (
- "crypto/sha256"
- "encoding/binary"
- "encoding/hex"
- "math"
-)
-
-const (
- FeatureSchemaVersion = 2
- featurePlanesV1 = 5
- featurePlanesV2 = 8
- featureGlobalsV2 = 4
-)
-
-// FeatureSchemaV1 describes tensor layout for NN input (simplified paper subset).
-type FeatureSchemaV1 struct {
- BoardSize int `json:"board_size"`
- Planes int `json:"planes"`
- Globals int `json:"globals"`
-}
-
-// BuildFeaturesV1 encodes board state as NCHW-flat planes + globals.
-// Planes: own stones, opponent stones, empty, ko point, player-to-move marker.
-func BuildFeaturesV1(b *Board) []float32 {
- size := b.Size()
- n := size * size
- planes := featurePlanesV1
- out := make([]float32, planes*n+2)
- player := b.Player()
- opp := player.Opposite()
- ko := b.Ko()
- for i := 0; i < n; i++ {
- switch b.AtIndex(i) {
- case player:
- out[i] = 1
- case opp:
- out[n+i] = 1
- case Empty:
- out[2*n+i] = 1
- }
- if i == ko {
- out[3*n+i] = 1
- }
- }
- if player == Black {
- out[planes*n] = 1
- } else {
- out[planes*n+1] = 1
- }
- return out
-}
-
-// BuildFeaturesV2 encodes board state for bootstrap ONNX (schema version 2).
-// Planes: own, opp, empty, ko, to-move fill; history t-3, t-2, t-1.
-// Globals: komi/10, move_num/(size²+1), black-to-move, white-to-move.
-func BuildFeaturesV2(b *Board) (spatial []float32, globals []float32) {
- size := b.Size()
- n := size * size
- planes := featurePlanesV2
- spatial = make([]float32, planes*n)
- player := b.Player()
- fillStonePlanes(b, spatial, player, n)
- fillToMovePlane(spatial, player, n)
- fillHistoryPlanes(b, spatial, n)
- return spatial, buildGlobalFeatures(b, player, size)
-}
-
-func fillStonePlanes(b *Board, spatial []float32, player Color, n int) {
- opp := player.Opposite()
- for i := 0; i < n; i++ {
- switch b.AtIndex(i) {
- case player:
- spatial[i] = 1
- case opp:
- spatial[n+i] = 1
- case Empty:
- spatial[2*n+i] = 1
- }
- if i == b.Ko() {
- spatial[3*n+i] = 1
- }
- }
-}
-
-func fillToMovePlane(spatial []float32, player Color, n int) {
- if player != Black {
- return
- }
- for i := 0; i < n; i++ {
- spatial[4*n+i] = 1
- }
-}
-
-func fillHistoryPlanes(b *Board, spatial []float32, n int) {
- for h, snap := range b.historyMoves(3) {
- if snap.move.Pass {
- continue
- }
- idx := snap.move.Point.Idx(b.Size())
- if idx >= 0 {
- spatial[(5+h)*n+idx] = 1
- }
- }
-}
-
-func buildGlobalFeatures(b *Board, player Color, size int) []float32 {
- denom := float32(size*size + 1)
- globals := []float32{
- float32(b.Komi()) / 10,
- float32(b.MoveNum()) / denom,
- }
- if player == Black {
- globals = append(globals, 1, 0)
- } else {
- globals = append(globals, 0, 1)
- }
- return globals
-}
-
-// FeatureHashV2 hashes spatial + globals for golden tests.
-func FeatureHashV2(spatial, globals []float32) string {
- h := sha256.New()
- var buf [4]byte
- for _, v := range spatial {
- binary.LittleEndian.PutUint32(buf[:], math.Float32bits(v))
- h.Write(buf[:])
- }
- for _, v := range globals {
- binary.LittleEndian.PutUint32(buf[:], math.Float32bits(v))
- h.Write(buf[:])
- }
- return hex.EncodeToString(h.Sum(nil))
-}
-
-// FeatureHash returns stable hex hash for golden tests.
-func FeatureHash(f []float32) string {
- h := sha256.New()
- var buf [4]byte
- for _, v := range f {
- binary.LittleEndian.PutUint32(buf[:], math.Float32bits(v))
- h.Write(buf[:])
- }
- return hex.EncodeToString(h.Sum(nil))
-}
diff --git a/cmd/gofer/features_test.go b/cmd/gofer/features_test.go
deleted file mode 100644
index b78bb92..0000000
--- a/cmd/gofer/features_test.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "testing"
-)
-
-func TestFeatureGolden(t *testing.T) {
- b := NewBoard(9, 6.5)
- r := Chinese()
- if !r.Play(b, StoneMove(At(3, 3))) {
- t.Fatal("setup play")
- }
- f := BuildFeaturesV1(b)
- if len(f) != featurePlanesV1*9*9+2 {
- t.Fatalf("feature len %d", len(f))
- }
- got := FeatureHash(f)
- path := filepath.Join("testdata", "features_golden.json")
- data, err := os.ReadFile(path)
- if err != nil {
- t.Fatal(err)
- }
- var golden struct {
- Hash string `json:"hash"`
- }
- if err := json.Unmarshal(data, &golden); err != nil {
- t.Fatal(err)
- }
- if got != golden.Hash {
- t.Fatalf("hash %s want %s", got, golden.Hash)
- }
-}
diff --git a/cmd/gofer/features_v2_test.go b/cmd/gofer/features_v2_test.go
deleted file mode 100644
index 3fb3cbf..0000000
--- a/cmd/gofer/features_v2_test.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "testing"
-)
-
-func TestFeatureGoldenV2(t *testing.T) {
- b := NewBoard(9, 6.5)
- r := Chinese()
- if !r.Play(b, StoneMove(At(3, 3))) {
- t.Fatal("setup play")
- }
- sp, gl := BuildFeaturesV2(b)
- if len(sp) != featurePlanesV2*9*9 {
- t.Fatalf("spatial len %d", len(sp))
- }
- if len(gl) != featureGlobalsV2 {
- t.Fatalf("globals len %d", len(gl))
- }
- got := FeatureHashV2(sp, gl)
- path := filepath.Join("testdata", "features_golden_v2.json")
- data, err := os.ReadFile(path)
- if err != nil {
- t.Fatal(err)
- }
- var golden struct {
- SchemaVersion int `json:"schema_version"`
- Hash string `json:"hash"`
- BoardSize int `json:"board_size"`
- }
- if err := json.Unmarshal(data, &golden); err != nil {
- t.Fatal(err)
- }
- if golden.SchemaVersion != FeatureSchemaVersion {
- t.Fatalf("schema %d", golden.SchemaVersion)
- }
- if got != golden.Hash {
- t.Fatalf("hash %s want %s", got, golden.Hash)
- }
-}
diff --git a/cmd/gofer/game.go b/cmd/gofer/game.go
deleted file mode 100644
index 89987f7..0000000
--- a/cmd/gofer/game.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package main
-
-import "os"
-
-// GameLog records moves for SGF export.
-type GameLog struct {
- Size int
- Komi float64
- Moves []SGFMove
-}
-
-// NewGameLog starts an empty game record.
-func NewGameLog(size int, komi float64) *GameLog {
- return &GameLog{Size: size, Komi: komi}
-}
-
-// Record appends a move by the given color.
-func (g *GameLog) Record(color Color, m Move) {
- sm := SGFMove{Color: color}
- if !m.Pass {
- pt := m.Point
- sm.Point = &pt
- }
- g.Moves = append(g.Moves, sm)
-}
-
-// ExportSGF returns the game as an SGF string.
-func (g *GameLog) ExportSGF() string {
- meta := &SGFGame{Size: g.Size, Komi: g.Komi}
- return ExportSGF(meta, g.Moves)
-}
-
-// WriteSGF writes the game to path.
-func (g *GameLog) WriteSGF(path string) error {
- return os.WriteFile(path, []byte(g.ExportSGF()), 0644)
-}
diff --git a/cmd/gofer/gating.go b/cmd/gofer/gating.go
deleted file mode 100644
index 35a5255..0000000
--- a/cmd/gofer/gating.go
+++ /dev/null
@@ -1,9 +0,0 @@
-package main
-
-// Gating constants — keep in sync with scripts/gating.env.
-const (
- // PromoteMin is the head-to-head win rate a challenger must clear to be
- // flagged promoted (candidate vs current champion). The loop additionally
- // requires the Wilson CI lower bound above 0.5; there is no win-target stop.
- PromoteMin = 0.55
-)
diff --git a/cmd/gofer/gofer_test.go b/cmd/gofer/gofer_test.go
deleted file mode 100644
index 7f1c689..0000000
--- a/cmd/gofer/gofer_test.go
+++ /dev/null
@@ -1,458 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "testing"
-)
-
-func TestCoordRoundTrip(t *testing.T) {
- size := 9
- for y := 0; y < size; y++ {
- for x := 0; x < size; x++ {
- p := At(x, y)
- if p.X != x || p.Y != y {
- t.Fatalf("round trip failed (%d,%d)", x, y)
- }
- if p.Idx(size) != y*size+x {
- t.Fatalf("idx mismatch (%d,%d)", x, y)
- }
- }
- }
-}
-
-func TestZobristChangesOnPlay(t *testing.T) {
- b := NewBoard(9, 6.5)
- h0 := b.Hash()
- p := At(4, 4)
- b.StartPlay(StoneMove(p), nil, p.Idx(9), Empty)
- b.SetStoneIndex(p.Idx(9), Black)
- b.FinishTurn(-1)
- if b.Hash() == h0 {
- t.Fatal("hash should change")
- }
-}
-
-func TestUndo(t *testing.T) {
- b := NewBoard(9, 6.5)
- p := At(2, 2)
- idx := p.Idx(9)
- b.StartPlay(StoneMove(p), nil, idx, Empty)
- b.SetStoneIndex(idx, Black)
- b.FinishTurn(-1)
- b.Undo()
- if b.StoneAt(p) != Empty {
- t.Fatal("undo failed")
- }
-}
-
-func TestSnapshotRestore(t *testing.T) {
- b := NewBoard(9, 6.5)
- s := b.Snapshot()
- p := At(1, 1)
- idx := p.Idx(9)
- b.StartPlay(StoneMove(p), nil, idx, Empty)
- b.SetStoneIndex(idx, Black)
- b.FinishTurn(-1)
- b.Restore(s)
- if b.StoneAt(p) != Empty {
- t.Fatal("restore failed")
- }
-}
-
-func TestNewBoard(t *testing.T) {
- b := NewBoard(9, 6.5)
- if b.Size() != 9 {
- t.Fatalf("size %d", b.Size())
- }
-}
-
-func TestRunSelfplaySamples(t *testing.T) {
- cfg := DefaultSelfplayConfig()
- cfg.Games = 1
- cfg.Playouts = 5
- cfg.BoardSize = 5
- cfg.FullOnlyExport = false
- samples := RunSelfplay(cfg)
- if len(samples) == 0 {
- t.Fatal("expected samples")
- }
- for _, s := range samples {
- if s.Komi != cfg.Komi {
- t.Fatalf("komi %v want %v", s.Komi, cfg.Komi)
- }
- if s.Value != 1 && s.Value != -1 && s.Value != 0 {
- t.Fatalf("value out of range: %v", s.Value)
- }
- if len(s.Ownership) == 0 {
- t.Fatal("expected ownership labels")
- }
- }
-}
-
-func TestOwnershipEndgameSGF(t *testing.T) {
- path := filepath.Join("testdata", "simple.sgf")
- data, err := os.ReadFile(path)
- if err != nil {
- t.Fatal(err)
- }
- g, err := ParseSGF(string(data))
- if err != nil {
- t.Fatal(err)
- }
- r := Chinese()
- b := NewBoard(g.Size, g.Komi)
- if err := g.Setup(b); err != nil {
- t.Fatal(err)
- }
- moves, err := g.MainLine()
- if err != nil {
- t.Fatal(err)
- }
- for _, m := range moves {
- if !r.Play(b, sgfMoveToPlay(m)) {
- t.Fatal("illegal replay")
- }
- }
- own := OwnershipLabel(b)
- if len(own) != g.Size*g.Size {
- t.Fatalf("ownership len %d", len(own))
- }
- nonZero := 0
- for _, v := range own {
- if v != 0 {
- nonZero++
- }
- }
- if nonZero == 0 {
- t.Fatal("expected some ownership signal after replay")
- }
-}
-
-func TestMatchResultJSONFields(t *testing.T) {
- result := RunMatch(MatchConfig{Games: 1, Size: 5, Playouts: 4, BlackEval: "uniform", WhiteEval: "uniform", Seed: 1})
- data, err := json.Marshal(result)
- if err != nil {
- t.Fatal(err)
- }
- var m map[string]any
- if err := json.Unmarshal(data, &m); err != nil {
- t.Fatal(err)
- }
- for _, key := range []string{"game_count", "games", "config_hash", "baseline_wilson_ci_low", "wins_baseline"} {
- if _, ok := m[key]; !ok {
- t.Fatalf("missing json field %q", key)
- }
- }
-}
-
-func TestGatingHarness(t *testing.T) {
- g := GatingHarness{Games: 100, MinWinRateMargin: PromoteMin}
- if !g.Pass(0.4, 0.56) {
- t.Fatal("should pass")
- }
-}
-
-func play(t *testing.T, r Ruleset, b *Board, x, y int) {
- t.Helper()
- if !r.Play(b, StoneMove(At(x, y))) {
- t.Fatalf("illegal play at %d,%d", x, y)
- }
-}
-
-func replaySGF(t *testing.T, path string, rules Ruleset, check func(t *testing.T, r Ruleset, b *Board)) {
- t.Helper()
- data, err := os.ReadFile(path)
- if err != nil {
- t.Fatal(err)
- }
- parsed, err := ParseSGF(string(data))
- if err != nil {
- t.Fatal(err)
- }
- b := NewBoard(parsed.Size, parsed.Komi)
- if err := parsed.Setup(b); err != nil {
- t.Fatal(err)
- }
- moves, err := parsed.MainLine()
- if err != nil {
- t.Fatal(err)
- }
- for i, m := range moves {
- if b.Player() != m.Color {
- t.Fatalf("move %d wrong side", i)
- }
- if !rules.Play(b, sgfMoveToPlay(m)) {
- t.Fatalf("illegal replay move %d in %s", i, filepath.Base(path))
- }
- }
- if check != nil {
- check(t, rules, b)
- }
-}
-
-func TestTrompTaylorLegalMoves(t *testing.T) {
- r := TrompTaylor()
- b := NewBoard(9, 6.5)
- moves := r.LegalMoves(b)
- if len(moves) != 9*9+1 {
- t.Fatalf("want 82 got %d", len(moves))
- }
-}
-
-func TestTrompTaylorSuicideAllowed(t *testing.T) {
- r := TrompTaylor()
- b := NewBoard(9, 6.5)
- play(t, r, b, 0, 0)
- play(t, r, b, 2, 0)
- play(t, r, b, 0, 1)
- play(t, r, b, 1, 1)
- _ = r.Play(b, StoneMove(At(1, 0)))
-}
-
-func TestTrompTaylorScore(t *testing.T) {
- r := TrompTaylor()
- b := NewBoard(9, 6.5)
- play(t, r, b, 3, 3)
- bl, wl := r.Score(b)
- if bl < 1 || wl != 6.5 {
- t.Fatalf("score bl=%v wl=%v", bl, wl)
- }
-}
-
-func TestChineseFactory(t *testing.T) {
- r := Chinese()
- b := NewBoard(9, 6.5)
- if !r.Play(b, StoneMove(At(4, 4))) {
- t.Fatal("play failed")
- }
-}
-
-func TestCaptureRemovesStones(t *testing.T) {
- r := Chinese()
- b := NewBoard(9, 6.5)
- play(t, r, b, 1, 0)
- play(t, r, b, 0, 0)
- play(t, r, b, 0, 1)
- if b.StoneAt(At(0, 0)) != Empty {
- t.Fatal("captured stone should be gone")
- }
-}
-
-func TestSuicideIllegal(t *testing.T) {
- r := Chinese()
- b := NewBoard(9, 6.5)
- play(t, r, b, 0, 0)
- play(t, r, b, 3, 4)
- play(t, r, b, 0, 1)
- play(t, r, b, 5, 4)
- play(t, r, b, 0, 2)
- play(t, r, b, 4, 3)
- play(t, r, b, 0, 3)
- play(t, r, b, 4, 5)
- if r.Play(b, StoneMove(At(4, 4))) {
- t.Fatal("suicide should fail")
- }
-}
-
-func TestSimpleKo(t *testing.T) {
- r := Chinese()
- b := NewBoard(9, 6.5)
- play(t, r, b, 0, 0)
- play(t, r, b, 4, 4)
- play(t, r, b, 0, 1)
- play(t, r, b, 0, 2)
- play(t, r, b, 3, 4)
- play(t, r, b, 0, 3)
- play(t, r, b, 5, 4)
- play(t, r, b, 0, 4)
- play(t, r, b, 3, 3)
- play(t, r, b, 0, 5)
- play(t, r, b, 3, 5)
- play(t, r, b, 0, 6)
- play(t, r, b, 5, 3)
- play(t, r, b, 0, 7)
- play(t, r, b, 5, 5)
- play(t, r, b, 0, 8)
- play(t, r, b, 4, 3)
- play(t, r, b, 1, 0)
- play(t, r, b, 4, 5)
- wantKo := At(4, 4).Idx(9)
- if b.Ko() != wantKo {
- t.Fatalf("ko want %d got %d", wantKo, b.Ko())
- }
- if r.Play(b, StoneMove(At(4, 4))) {
- t.Fatal("ko recapture illegal")
- }
-}
-
-func TestPassAndUndo(t *testing.T) {
- r := Chinese()
- b := NewBoard(9, 6.5)
- if !r.Play(b, PassMove) {
- t.Fatal("pass failed")
- }
- if b.Player() != White {
- t.Fatal("turn switch")
- }
- play(t, r, b, 2, 2)
- b.Undo()
- if b.StoneAt(At(2, 2)) != Empty {
- t.Fatal("undo failed")
- }
-}
-
-func TestLegalMovesEmpty19(t *testing.T) {
- r := Chinese()
- b := NewBoard(19, 7.5)
- moves := r.LegalMoves(b)
- if len(moves) != 19*19+1 {
- t.Fatalf("want 362 got %d", len(moves))
- }
-}
-
-func TestScoreCountsStones(t *testing.T) {
- r := Chinese()
- b := NewBoard(9, 6.5)
- play(t, r, b, 0, 0)
- bl, _ := r.Score(b)
- if bl < 1 {
- t.Fatalf("expected black stones got %v", bl)
- }
-}
-
-func TestSuperkoWrapper(t *testing.T) {
- r := WithSuperko(Chinese())
- b := NewBoard(9, 6.5)
- if !r.Play(b, StoneMove(At(4, 4))) {
- t.Fatal("superko wrapped play failed")
- }
- if len(r.LegalMoves(b)) == 0 {
- t.Fatal("expected legal moves")
- }
-}
-
-func TestTrompVsChineseScoreDivergence(t *testing.T) {
- ch := Chinese()
- tr := TrompTaylor()
- bc := NewBoard(9, 6.5)
- bt := NewBoard(9, 6.5)
- play(t, ch, bc, 2, 2)
- play(t, tr, bt, 2, 2)
- play(t, ch, bc, 3, 3)
- play(t, tr, bt, 3, 3)
- cbl, cwl := ch.Score(bc)
- tbl, twl := tr.Score(bt)
- if cbl == tbl && cwl == twl {
- t.Log("scores equal on this position — divergence may appear with captures")
- }
-}
-func TestParseSGFCoord(t *testing.T) {
- p, err := ParseSGFCoord(9, "bc")
- if err != nil {
- t.Fatal(err)
- }
- if p.X != 1 || p.Y != 2 {
- t.Fatalf("bc on 9x9 want (1,2) got (%d,%d)", p.X, p.Y)
- }
-}
-
-func TestGoldenCaptureSGF(t *testing.T) {
- replaySGF(t, filepath.Join("testdata", "capture.sgf"), Chinese(), func(t *testing.T, _ Ruleset, b *Board) {
- if b.StoneAt(At(0, 0)) != Empty {
- t.Fatal("white at aa should be captured")
- }
- })
-}
-
-func TestGoldenKoSGF(t *testing.T) {
- replaySGF(t, filepath.Join("testdata", "ko.sgf"), Chinese(), func(t *testing.T, r Ruleset, b *Board) {
- wantKo := At(4, 4).Idx(9)
- if b.Ko() != wantKo {
- t.Fatalf("ko want %d got %d", wantKo, b.Ko())
- }
- if r.Play(b, StoneMove(At(4, 4))) {
- t.Fatal("immediate ko recapture should be illegal")
- }
- })
-}
-
-func TestGoldenPassSGF(t *testing.T) {
- replaySGF(t, filepath.Join("testdata", "pass.sgf"), Chinese(), func(t *testing.T, _ Ruleset, b *Board) {
- if b.Player() != Black {
- t.Fatal("two passes should return turn to black")
- }
- })
-}
-
-func TestTrompReplayCorpus(t *testing.T) {
- for _, name := range []string{"capture.sgf"} {
- t.Run(name, func(t *testing.T) {
- replaySGF(t, filepath.Join("testdata", name), TrompTaylor(), nil)
- })
- }
-}
-
-func TestParseGameMeta(t *testing.T) {
- g, err := ParseSGF("(;FF[4]SZ[9]KM[6.5];B[cc];W[dd])")
- if err != nil {
- t.Fatal(err)
- }
- if g.Size != 9 || g.Komi != 6.5 {
- t.Fatalf("meta size=%d komi=%v", g.Size, g.Komi)
- }
- moves, err := g.MainLine()
- if err != nil || len(moves) != 2 {
- t.Fatalf("moves %d err=%v", len(moves), err)
- }
-}
-
-func TestReplayCorpus(t *testing.T) {
- for _, name := range []string{"capture.sgf", "ko.sgf", "pass.sgf", "setup.sgf", "simple.sgf", "open.sgf"} {
- t.Run(name, func(t *testing.T) {
- replaySGF(t, filepath.Join("testdata", name), Chinese(), nil)
- })
- }
-}
-
-func TestExportSGFRoundTrip(t *testing.T) {
- g := &SGFGame{Size: 9, Komi: 6.5}
- pt := At(2, 2)
- moves := []SGFMove{{Color: Black, Point: &pt}}
- out := ExportSGF(g, moves)
- g2, err := ParseSGF(out)
- if err != nil {
- t.Fatal(err)
- }
- m2, err := g2.MainLine()
- if err != nil || len(m2) != 1 {
- t.Fatalf("round trip moves %d err=%v", len(m2), err)
- }
-}
-
-func TestGameLogExport(t *testing.T) {
- log := NewGameLog(9, 6.5)
- log.Record(Black, StoneMove(At(2, 2)))
- log.Record(White, PassMove)
- out := log.ExportSGF()
- g, err := ParseSGF(out)
- if err != nil {
- t.Fatal(err)
- }
- moves, err := g.MainLine()
- if err != nil || len(moves) != 2 {
- t.Fatalf("moves %d err=%v", len(moves), err)
- }
-}
-
-func TestSelfplaySGFLogs(t *testing.T) {
- cfg := DefaultSelfplayConfig()
- cfg.Games = 1
- cfg.Playouts = 4
- cfg.BoardSize = 5
- _, logs := RunSelfplayWithLogs(cfg)
- if len(logs) != 1 || len(logs[0].Moves) == 0 {
- t.Fatalf("log moves %d", len(logs[0].Moves))
- }
-}
diff --git a/cmd/gofer/groups.go b/cmd/gofer/groups.go
deleted file mode 100644
index 3199239..0000000
--- a/cmd/gofer/groups.go
+++ /dev/null
@@ -1,165 +0,0 @@
-package main
-
-// visitMark tracks flood-fill visits without per-call map allocations.
-// visitMark gen overflow clears the slice (rare).
-type visitMark struct {
- gen []uint32
- cur uint32
-}
-
-func (v *visitMark) ensure(n int) {
- if len(v.gen) < n {
- v.gen = make([]uint32, n)
- v.cur = 1
- return
- }
- v.cur++
- if v.cur == 0 {
- clear(v.gen)
- v.cur = 1
- }
-}
-
-func (v *visitMark) bump() {
- v.cur++
- if v.cur == 0 {
- clear(v.gen)
- v.cur = 1
- }
-}
-
-func (v *visitMark) mark(i int) { v.gen[i] = v.cur }
-func (v *visitMark) seen(i int) bool { return v.gen[i] == v.cur }
-
-// legalityScratch holds reusable buffers for LegalMoves hot path.
-type legalityScratch struct {
- trial *Board
- snap trialSnap
- mark visitMark
- groupBuf []int
- stackBuf []int
-}
-
-func (s *legalityScratch) prepare(b *Board) {
- if s.trial == nil || s.trial.Size() != b.Size() {
- s.trial = b.cloneTrial()
- } else {
- s.trial.restoreTrialSnap(s.snap)
- }
- s.snap = b.captureTrialSnap()
- n := b.Size() * b.Size()
- if cap(s.groupBuf) < n {
- s.groupBuf = make([]int, 0, n)
- s.stackBuf = make([]int, 0, n)
- }
- s.groupBuf = s.groupBuf[:0]
- s.stackBuf = s.stackBuf[:0]
- s.mark.ensure(n)
-}
-
-func collectGroup(b *Board, start int, color Color) []int {
- var mark visitMark
- return collectGroupMark(b, start, color, &mark, nil, nil)
-}
-
-func collectGroupMark(b *Board, start int, color Color, mark *visitMark, groupBuf, stackBuf *[]int) []int {
- if b.AtIndex(start) != color {
- return nil
- }
- n := b.Size() * b.Size()
- mark.ensure(n)
- if groupBuf != nil {
- *groupBuf = (*groupBuf)[:0]
- *stackBuf = (*stackBuf)[:0]
- *groupBuf = append(*groupBuf, start)
- *stackBuf = append(*stackBuf, start)
- } else {
- groupBuf = new([]int)
- stackBuf = new([]int)
- *groupBuf = append(*groupBuf, start)
- *stackBuf = append(*stackBuf, start)
- }
- mark.mark(start)
- for len(*stackBuf) > 0 {
- i := (*stackBuf)[len(*stackBuf)-1]
- *stackBuf = (*stackBuf)[:len(*stackBuf)-1]
- b.forEachNeighbor(i, func(nb int) {
- if b.AtIndex(nb) != color || mark.seen(nb) {
- return
- }
- mark.mark(nb)
- *groupBuf = append(*groupBuf, nb)
- *stackBuf = append(*stackBuf, nb)
- })
- }
- return *groupBuf
-}
-
-func libertyCount(b *Board, start int, color Color) int {
- var mark visitMark
- return libertyCountMark(b, start, color, &mark, nil, nil)
-}
-
-func libertyCountMark(b *Board, start int, color Color, mark *visitMark, groupBuf, stackBuf *[]int) int {
- group := collectGroupMark(b, start, color, mark, groupBuf, stackBuf)
- mark.bump()
- libs := 0
- for _, g := range group {
- b.forEachNeighbor(g, func(nb int) {
- if b.AtIndex(nb) != Empty || mark.seen(nb) {
- return
- }
- mark.mark(nb)
- libs++
- })
- }
- return libs
-}
-
-func removeDeadGroups(b *Board, color Color) []int {
- var mark visitMark
- return removeDeadGroupsMark(b, color, &mark, nil, nil)
-}
-
-func removeDeadGroupsMark(b *Board, color Color, mark *visitMark, groupBuf, stackBuf *[]int) []int {
- n := b.Size() * b.Size()
- var captured []int
- for i := 0; i < n; i++ {
- if b.AtIndex(i) != color {
- continue
- }
- if libertyCountMark(b, i, color, mark, groupBuf, stackBuf) == 0 {
- for _, g := range collectGroupMark(b, i, color, mark, groupBuf, stackBuf) {
- b.SetStoneIndex(g, Empty)
- captured = append(captured, g)
- }
- }
- }
- return captured
-}
-
-func floodEmpty(b *Board, start int, seen []bool) (territory int, touchBlack, touchWhite bool) {
- stack := []int{start}
- for len(stack) > 0 {
- i := stack[len(stack)-1]
- stack = stack[:len(stack)-1]
- if seen[i] {
- continue
- }
- seen[i] = true
- switch b.AtIndex(i) {
- case Empty:
- territory++
- for _, nb := range b.Neighbors(i) {
- if !seen[nb] {
- stack = append(stack, nb)
- }
- }
- case Black:
- touchBlack = true
- case White:
- touchWhite = true
- }
- }
- return territory, touchBlack, touchWhite
-}
diff --git a/cmd/gofer/gtp.go b/cmd/gofer/gtp.go
deleted file mode 100644
index aa9ea9a..0000000
--- a/cmd/gofer/gtp.go
+++ /dev/null
@@ -1,226 +0,0 @@
-package main
-
-import (
- "strconv"
- "strings"
- "time"
-)
-
-var gtpCommands = []string{
- "boardsize", "clear_board", "final_score", "genmove", "komi",
- "known_command", "list_commands", "name", "play", "protocol_version",
- "quit", "showboard", "time_left", "time_settings", "version",
-}
-
-// SessionConfig holds GTP session parameters.
-type SessionConfig struct {
- Playouts int
- ThinkTime time.Duration
- Eval string // "uniform" or "heuristic"
- BoardSize int
-}
-
-// Session holds GTP engine state.
-type Session struct {
- Board *Board
- Rules Ruleset
- Search *Engine
- log *GameLog
- Size int
- Komi float64
- playouts int
- thinkTime time.Duration
- nextThink time.Duration
-}
-
-// NewSession creates a GTP session with the given config.
-func NewSession(cfg SessionConfig) *Session {
- size := cfg.BoardSize
- if size < 2 {
- size = 19
- }
- komi := 7.5
- b := NewBoard(size, komi)
- r := Chinese()
- playouts := cfg.Playouts
- if playouts <= 0 {
- playouts = defaultPlayoutsForSize(size)
- }
- ev := parseEvaluator(cfg.Eval)
- scfg := DefaultConfig()
- scfg.Playouts = playouts
- scfg.ThinkTime = cfg.ThinkTime
- scfg.Seed = 1
- return &Session{
- Board: b,
- Rules: r,
- Search: NewEngine(r, ev, scfg),
- log: NewGameLog(size, komi),
- Size: size,
- Komi: komi,
- playouts: playouts,
- thinkTime: cfg.ThinkTime,
- }
-}
-
-func (s *Session) resetGameLog() {
- s.log = NewGameLog(s.Size, s.Komi)
-}
-
-// Handle processes one GTP command line and returns the response body (without =/? prefix).
-func (s *Session) Handle(line string) string {
- line = strings.TrimSpace(line)
- if line == "" {
- return ""
- }
- parts := strings.Fields(line)
- cmd := strings.ToLower(parts[0])
- switch cmd {
- case "protocol_version":
- return "2"
- case "name":
- return "Gofer"
- case "version":
- return "1.0"
- case "known_command":
- return s.handleKnownCommand(parts)
- case "list_commands":
- return strings.Join(gtpCommands, " ")
- case "boardsize":
- return s.handleBoardsize(parts)
- case "clear_board":
- return s.handleClearBoard()
- case "komi":
- return s.handleKomi(parts)
- case "play":
- return s.handlePlay(parts)
- case "genmove":
- return s.handleGenmove(parts)
- case "showboard":
- return formatGTPBoard(s.Board, s.Size)
- case "final_score":
- return formatGTPScore(s.Board, s.Rules)
- case "time_settings":
- return ""
- case "time_left":
- return s.handleTimeLeft(parts)
- case "quit":
- return ""
- default:
- return "? unknown command"
- }
-}
-
-func (s *Session) handleKnownCommand(parts []string) string {
- if len(parts) < 2 {
- return "false"
- }
- cmd := strings.ToLower(parts[1])
- for _, c := range gtpCommands {
- if c == cmd {
- return "true"
- }
- }
- return "false"
-}
-
-func (s *Session) handleBoardsize(parts []string) string {
- if len(parts) < 2 {
- return "boardsize not an integer"
- }
- n, err := strconv.Atoi(parts[1])
- if err != nil || n < 2 || n > 19 {
- return "unacceptable size"
- }
- s.Size = n
- s.Board = NewBoard(n, s.Komi)
- s.playouts = defaultPlayoutsForSize(n)
- s.Search.ResetArena()
- s.resetGameLog()
- return ""
-}
-
-func (s *Session) handleClearBoard() string {
- s.Board = NewBoard(s.Size, s.Komi)
- s.Search.ResetArena()
- s.resetGameLog()
- return ""
-}
-
-func (s *Session) handleKomi(parts []string) string {
- if len(parts) < 2 {
- return "komi not a float"
- }
- k, err := strconv.ParseFloat(parts[1], 64)
- if err != nil {
- return "komi not a float"
- }
- s.Komi = k
- s.Board = NewBoard(s.Size, k)
- s.Search.ResetArena()
- s.resetGameLog()
- return ""
-}
-
-func (s *Session) handlePlay(parts []string) string {
- if len(parts) < 3 {
- return "syntax error"
- }
- color, err := parseGTPColor(parts[1])
- if err != nil {
- return err.Error()
- }
- if s.Board.Player() != color {
- return "wrong color to move"
- }
- m, err := parseGTPVertex(parts[2], s.Size)
- if err != nil {
- return err.Error()
- }
- if !s.Rules.Play(s.Board, m) {
- return "illegal move"
- }
- s.log.Record(color, m)
- s.Search.AdvanceTree(m)
- return ""
-}
-
-func (s *Session) handleGenmove(parts []string) string {
- if len(parts) < 2 {
- return "syntax error"
- }
- color, err := parseGTPColor(parts[1])
- if err != nil {
- return err.Error()
- }
- if s.Board.Player() != color {
- return "wrong color to move"
- }
- think := s.nextThink
- if think <= 0 {
- think = s.thinkTime
- }
- s.Search.SetLimits(s.playouts, think)
- s.nextThink = 0
- m := s.Search.BestMove(s.Board)
- if !s.Rules.Play(s.Board, m) {
- m = PassMove
- s.Rules.Play(s.Board, m)
- }
- s.log.Record(color, m)
- s.Search.AdvanceTree(m)
- if m.Pass {
- return "pass"
- }
- return moveToGTPVertex(m, s.Size)
-}
-
-func (s *Session) handleTimeLeft(parts []string) string {
- // Remaining clock becomes next genmove budget; no byo-yomi split.
- if len(parts) >= 3 {
- if sec, err := strconv.Atoi(parts[2]); err == nil && sec > 0 {
- s.nextThink = time.Duration(sec) * time.Second
- }
- }
- return ""
-}
diff --git a/cmd/gofer/gtp_test.go b/cmd/gofer/gtp_test.go
deleted file mode 100644
index 6cb2b7c..0000000
--- a/cmd/gofer/gtp_test.go
+++ /dev/null
@@ -1,105 +0,0 @@
-package main
-
-import (
- "strings"
- "testing"
- "time"
-)
-
-func TestGTPVersion(t *testing.T) {
- s := NewSession(SessionConfig{})
- if out := s.Handle("version"); out != "1.0" {
- t.Fatalf("version: %q", out)
- }
-}
-
-func TestGTPSGFLog(t *testing.T) {
- s := NewSession(SessionConfig{Playouts: 4, BoardSize: 5})
- s.Handle("boardsize 5")
- s.Handle("play black D3")
- s.Handle("genmove white")
- if len(s.log.Moves) != 2 {
- t.Fatalf("moves %d want 2", len(s.log.Moves))
- }
- out := s.log.ExportSGF()
- if !strings.Contains(out, ";B[") || !strings.Contains(out, ";W[") {
- t.Fatalf("sgf missing moves: %s", out)
- }
-}
-
-func TestGTPClearResetsLog(t *testing.T) {
- s := NewSession(SessionConfig{BoardSize: 5})
- s.Handle("boardsize 5")
- s.Handle("play black D3")
- s.Handle("clear_board")
- if len(s.log.Moves) != 0 {
- t.Fatalf("clear_board should reset log, got %d moves", len(s.log.Moves))
- }
-}
-
-func TestWatchGameEnds(t *testing.T) {
- const size = 5
- r := Chinese()
- b := NewBoard(size, 6.5)
- cfg := DefaultConfig()
- cfg.Playouts = 4
- eng := NewEngine(r, Uniform{}, cfg)
- moves := 0
- passes := watchUntilEnd(r, b, eng, size, func(_ Color, _ Move) { moves++ })
- if moves == 0 {
- t.Fatal("watch game produced no moves")
- }
- moveCap := size*size + 2
- ended := passes >= 2 || onlyPass(r.LegalMoves(b)) || moves >= moveCap
- if !ended {
- t.Fatalf("watch game did not end (passes=%d moves=%d cap=%d)", passes, moves, moveCap)
- }
-}
-
-func TestGTPBoardsize(t *testing.T) {
- s := NewSession(SessionConfig{})
- if out := s.Handle("boardsize 9"); out != "" {
- t.Fatalf("boardsize: %q", out)
- }
-}
-
-func TestGTPKnownCommand(t *testing.T) {
- s := NewSession(SessionConfig{})
- if out := s.Handle("known_command genmove"); out != "true" {
- t.Fatalf("known genmove: %q", out)
- }
- if out := s.Handle("known_command not_a_command"); out != "false" {
- t.Fatalf("unknown command: %q", out)
- }
-}
-
-func TestGTPShowboard(t *testing.T) {
- s := NewSession(SessionConfig{})
- s.Handle("boardsize 9")
- out := s.Handle("showboard")
- if out == "" || !strings.Contains(out, ".") {
- t.Fatalf("showboard empty or missing dots: %q", out)
- }
-}
-
-func TestGTPFinalScore(t *testing.T) {
- s := NewSession(SessionConfig{})
- s.Handle("boardsize 9")
- s.Handle("play black pass")
- s.Handle("play white pass")
- out := s.Handle("final_score")
- if out == "" {
- t.Fatal("final_score empty")
- }
-}
-
-func TestGTPTimeLeft(t *testing.T) {
- s := NewSession(SessionConfig{Playouts: 10})
- s.Handle("boardsize 9")
- if out := s.Handle("time_left black 30 0"); out != "" {
- t.Fatalf("time_left: %q", out)
- }
- if s.nextThink != 30*time.Second {
- t.Fatalf("nextThink=%v", s.nextThink)
- }
-}
diff --git a/cmd/gofer/inference.go b/cmd/gofer/inference.go
deleted file mode 100644
index 9ff0b0a..0000000
--- a/cmd/gofer/inference.go
+++ /dev/null
@@ -1,189 +0,0 @@
-package main
-
-import (
- "sync"
- "time"
-)
-
-// EvalBackend evaluates a batch of boards (ONNX/mock worker).
-// Implementations: SidecarBackend (HTTP), ORTBackend (in-process, -tags=onnx).
-type EvalBackend interface {
- EvalBatch(boards []*Board) []Result
-}
-
-// Inference is a mock neural-network evaluator hook for external runtimes.
-type Inference struct {
- MockValue float64
- MockPolicy []float32
- Latency time.Duration // simulated inference delay per batch
-}
-
-// EvalBatch implements EvalBackend.
-func (inf Inference) EvalBatch(boards []*Board) []Result {
- if inf.Latency > 0 {
- time.Sleep(inf.Latency)
- }
- out := make([]Result, len(boards))
- for i, b := range boards {
- out[i] = inf.Evaluate(b)
- }
- return out
-}
-
-// Evaluate implements Evaluator (single-position).
-func (inf Inference) Evaluate(b *Board) Result {
- n := b.Size()*b.Size() + 1
- p := inf.MockPolicy
- if len(p) != n {
- p = make([]float32, n)
- for i := range p {
- p[i] = 1
- }
- }
- return Result{Value: inf.MockValue, Policy: p, HasValue: true}
-}
-
-type batchReq struct {
- b *Board
- resp chan Result
-}
-
-// BatchedEvaluator queues positions and evaluates in batches.
-type BatchedEvaluator struct {
- backend EvalBackend
- fallback Evaluator
- minBatch int
- maxWait time.Duration
- reqTimeout time.Duration
- reqCh chan batchReq
- stopCh chan struct{}
- wg sync.WaitGroup
- once sync.Once
-}
-
-// NewBatchedEvaluator starts the batch worker.
-func NewBatchedEvaluator(backend EvalBackend, fallback Evaluator, minBatch int, maxWait time.Duration) *BatchedEvaluator {
- return NewBatchedEvaluatorWithTimeout(backend, fallback, minBatch, maxWait, maxWait*4)
-}
-
-// NewBatchedEvaluatorWithTimeout starts the batch worker with an explicit request timeout.
-func NewBatchedEvaluatorWithTimeout(backend EvalBackend, fallback Evaluator, minBatch int, maxWait, reqTimeout time.Duration) *BatchedEvaluator {
- if minBatch < 1 {
- minBatch = 8
- }
- if maxWait <= 0 {
- maxWait = 2 * time.Millisecond
- }
- if reqTimeout <= 0 {
- reqTimeout = maxWait * 4
- }
- b := &BatchedEvaluator{
- backend: backend,
- fallback: fallback,
- minBatch: minBatch,
- maxWait: maxWait,
- reqTimeout: reqTimeout,
- reqCh: make(chan batchReq, 256),
- stopCh: make(chan struct{}),
- }
- b.wg.Add(1)
- go b.worker()
- return b
-}
-
-// QueueDepth returns pending evaluate requests (measurement only).
-func (b *BatchedEvaluator) QueueDepth() int {
- return len(b.reqCh)
-}
-
-// Close stops the batch worker.
-func (b *BatchedEvaluator) Close() {
- b.once.Do(func() {
- close(b.stopCh)
- b.wg.Wait()
- })
-}
-
-// Evaluate submits one position and waits for batched result.
-func (b *BatchedEvaluator) Evaluate(board *Board) Result {
- resp := make(chan Result, 1)
- req := batchReq{b: board, resp: resp}
- select {
- case b.reqCh <- req:
- case <-time.After(b.reqTimeout):
- return b.fallback.Evaluate(board)
- }
- select {
- case r := <-resp:
- return r
- case <-time.After(b.reqTimeout):
- return b.fallback.Evaluate(board)
- }
-}
-
-func (b *BatchedEvaluator) worker() {
- defer b.wg.Done()
- for {
- select {
- case <-b.stopCh:
- return
- case req := <-b.reqCh:
- batch, boards, stopped := b.gatherBatch(req)
- if stopped {
- return
- }
- b.dispatchBatch(batch, boards)
- }
- }
-}
-
-func (b *BatchedEvaluator) gatherBatch(first batchReq) ([]batchReq, []*Board, bool) {
- batch := []batchReq{first}
- boards := []*Board{first.b}
- deadline := time.Now().Add(b.maxWait)
- for len(batch) < b.minBatch {
- wait := time.Until(deadline)
- if wait <= 0 {
- break
- }
- select {
- case <-b.stopCh:
- return nil, nil, true
- case r := <-b.reqCh:
- batch = append(batch, r)
- boards = append(boards, r.b)
- case <-time.After(wait):
- return batch, boards, false
- }
- }
- return batch, boards, false
-}
-
-func (b *BatchedEvaluator) dispatchBatch(batch []batchReq, boards []*Board) {
- results := b.backend.EvalBatch(boards)
- for i, r := range batch {
- if i < len(results) {
- r.resp <- results[i]
- } else {
- r.resp <- b.fallback.Evaluate(r.b)
- }
- }
-}
-
-// GatingHarness compares win rates for model promotion.
-type GatingHarness struct {
- Games int
- MinWinRateMargin float64 // e.g. 0.55 vs 0.45
-}
-
-// Pass returns true if candidate beats baseline by the configured margin.
-func (g GatingHarness) Pass(baselineWin, candidateWin float64) bool {
- if g.Games <= 0 {
- return true
- }
- margin := g.MinWinRateMargin
- if margin == 0 {
- margin = PromoteMin
- }
- return candidateWin >= margin && candidateWin > baselineWin
-}
diff --git a/cmd/gofer/inference_test.go b/cmd/gofer/inference_test.go
deleted file mode 100644
index 6716a9c..0000000
--- a/cmd/gofer/inference_test.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package main
-
-import (
- "sync"
- "testing"
- "time"
-)
-
-func TestBatchedEvaluator(t *testing.T) {
- ev := NewBatchedEvaluator(Inference{MockValue: 0.5}, Heuristic{}, 2, time.Millisecond)
- defer ev.Close()
- b := NewBoard(5, 6.5)
- r := ev.Evaluate(b)
- if r.Value != 0.5 {
- t.Fatalf("value %v", r.Value)
- }
-}
-
-func TestBatchedEvaluatorParallel(t *testing.T) {
- ev := NewBatchedEvaluator(Inference{Latency: 100 * time.Microsecond}, Heuristic{}, 4, 2*time.Millisecond)
- defer ev.Close()
- b := NewBoard(9, 6.5)
- var wg sync.WaitGroup
- for i := 0; i < 16; i++ {
- wg.Add(1)
- go func() {
- defer wg.Done()
- _ = ev.Evaluate(b)
- }()
- }
- done := make(chan struct{})
- go func() {
- wg.Wait()
- close(done)
- }()
- select {
- case <-done:
- case <-time.After(5 * time.Second):
- t.Fatal("deadlock in parallel batched eval")
- }
-}
-
-func TestBatchedSearch(t *testing.T) {
- r := Chinese()
- cfg := DefaultConfig()
- cfg.Playouts = 20
- cfg.Workers = 4
- e := NewEngine(r, NewBatchedEvaluator(Inference{}, Heuristic{}, 4, 2*time.Millisecond), cfg)
- defer func() {
- if c, ok := e.Eval.(*BatchedEvaluator); ok {
- c.Close()
- }
- }()
- b := NewBoard(5, 6.5)
- _ = e.BestMove(b)
-}
diff --git a/cmd/gofer/komi_sweep_test.go b/cmd/gofer/komi_sweep_test.go
deleted file mode 100644
index 4844312..0000000
--- a/cmd/gofer/komi_sweep_test.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package main
-
-import "testing"
-
-// TestHeuristicFairKomiSweep documents stone-color win rate vs komi for equal heuristic
-// engines (same eval name — swap does not alternate stone color). Fair komi for
-// stone-color balance at 50 playouts is near 0..-1, not tournament 6.5.
-func TestHeuristicFairKomiSweep(t *testing.T) {
- if testing.Short() {
- t.Skip("short")
- }
- old := minGamesBeforePromote
- minGamesBeforePromote = 201
- t.Cleanup(func() { minGamesBeforePromote = old })
-
- const games = 100
- for _, komi := range []float64{-2, -1, 0, 3.5, 6.5} {
- res := RunMatch(MatchConfig{
- Games: games, Size: 9, Komi: komi, Playouts: 50,
- BlackEval: "heuristic", WhiteEval: "heuristic",
- Seed: 99, SwapColors: true, OpeningMoves: 8, OpeningTemp: 1.0, Parallel: 4,
- })
- t.Logf("komi=%.1f stone_black=%d stone_white=%d draws=%d", komi, res.WinsBlack, res.WinsWhite, res.Draws)
- }
-}
diff --git a/cmd/gofer/latency_test.go b/cmd/gofer/latency_test.go
deleted file mode 100644
index 0fbd1d1..0000000
--- a/cmd/gofer/latency_test.go
+++ /dev/null
@@ -1,67 +0,0 @@
-package main
-
-import (
- "sort"
- "testing"
- "time"
-)
-
-func TestLatencyReport9x9ONNX(t *testing.T) {
- if testing.Short() {
- t.Skip("latency harness skipped in -short")
- }
- url := evalConfig.ONNXURL
- if url == "" {
- url = "http://127.0.0.1:8080"
- }
- SetEvalConfig(EvalConfig{
- Backend: "sidecar",
- ONNXURL: url,
- BatchSize: 8,
- EvalTimeout: 50 * time.Millisecond,
- MaxWait: 2 * time.Millisecond,
- })
- client := SidecarBackend{URL: url}.httpClient()
- if err := CheckSidecarHealth(url, client); err != nil {
- t.Skip("sidecar not running:", err)
- }
-
- r := Chinese()
- // warmup sidecar batch path
- w := newSearchEngine(r, 400, 0, "onnx")
- _ = w.BestMove(NewBoard(9, 6.5))
- w.Close()
-
- samples := make([]float64, 0, 50)
- for i := 0; i < 50; i++ {
- b := NewBoard(9, 6.5)
- for j := 0; j < i%3; j++ {
- _ = r.Play(b, StoneMove(At((i+j)%9, (i*2+j)%9)))
- }
- eng := newSearchEngine(r, 400, 0, "onnx")
- start := time.Now()
- _ = eng.BestMove(b)
- eng.Close()
- samples = append(samples, float64(time.Since(start).Milliseconds()))
- }
- sort.Float64s(samples)
- p50 := samples[len(samples)/2]
- p99 := samples[int(float64(len(samples)-1)*0.99)]
- t.Logf("9x9@400 playouts onnx: p50=%.0fms p99=%.0fms n=%d", p50, p99, len(samples))
-}
-
-func TestLatencyReport19x19Heuristic(t *testing.T) {
- if testing.Short() {
- t.Skip("latency harness skipped in -short")
- }
- r := Chinese()
- b := NewBoard(19, 7.5)
- eng := newSearchEngine(r, 1600, 0, "heuristic")
- start := time.Now()
- a := eng.Analyze(b, 1)
- elapsed := float64(time.Since(start).Milliseconds())
- if a.Playouts != 1600 {
- t.Fatalf("playouts %d want 1600", a.Playouts)
- }
- t.Logf("19x19@1600 heuristic: wall=%.0fms playouts=%d", elapsed, a.Playouts)
-}
diff --git a/cmd/gofer/main.go b/cmd/gofer/main.go
index 2870087..6a1a3dc 100644
--- a/cmd/gofer/main.go
+++ b/cmd/gofer/main.go
@@ -1,13 +1,55 @@
+// Command gofer is a Go (the board game) engine: GTP server, terminal play,
+// self-play data generation, arena matches and benchmarks.
package main
+import (
+ "fmt"
+ "os"
+)
+
+var version = "3.0.0-dev"
+
+const usage = `gofer — AlphaZero-style Go engine
+
+Usage:
+ gofer gtp [flags] serve GTP on stdin/stdout (Sabaki, Lizzie, GoGui)
+ gofer play [flags] play against the engine in the terminal
+ gofer selfplay [flags] generate training shards (.npz)
+ gofer arena [flags] play a match between two engines
+ gofer bench [flags] measure search and network throughput
+ gofer version
+
+Run "gofer -h" for the flags of a command.
+`
+
func main() {
- f := parseCLIFlags()
- if f.sgfPath != "" {
- runSGFReplay(f.sgfPath)
- return
+ if len(os.Args) < 2 {
+ fmt.Fprint(os.Stderr, usage)
+ os.Exit(2)
}
- if runMode(f) {
+ cmds := map[string]func([]string) error{
+ "gtp": cmdGTP,
+ "play": cmdPlay,
+ "selfplay": cmdSelfplay,
+ "arena": cmdArena,
+ "bench": cmdBench,
+ }
+ switch name := os.Args[1]; name {
+ case "version", "-version", "--version":
+ fmt.Println("gofer", version)
+ return
+ case "help", "-h", "--help":
+ fmt.Print(usage)
return
+ default:
+ run, ok := cmds[name]
+ if !ok {
+ fmt.Fprintf(os.Stderr, "gofer: unknown command %q\n\n%s", name, usage)
+ os.Exit(2)
+ }
+ if err := run(os.Args[2:]); err != nil {
+ fmt.Fprintln(os.Stderr, "gofer:", err)
+ os.Exit(1)
+ }
}
- printUsage(f.size, f.komi)
}
diff --git a/cmd/gofer/match.go b/cmd/gofer/match.go
deleted file mode 100644
index e372dac..0000000
--- a/cmd/gofer/match.go
+++ /dev/null
@@ -1,419 +0,0 @@
-package main
-
-import (
- "crypto/sha256"
- "encoding/hex"
- "fmt"
- "math"
- "math/rand"
- "runtime/debug"
- "strings"
- "sync"
- "time"
-)
-
-// MatchConfig configures champion/challenger arena matches.
-type MatchConfig struct {
- Games int
- Size int
- Komi float64
- Playouts int
- BlackPlayouts int // 0 = Playouts
- WhitePlayouts int // 0 = Playouts
- ThinkTime time.Duration
- BlackEval string
- WhiteEval string
- Seed int64
- SwapColors bool
- ArenaEnhanced string // none, baseline, both — forced root playouts
- Parallel int // concurrent games sharing one evaluator per role
- OpeningMoves int // opening plies sampled from the visit distribution (0 = deterministic argmax)
- OpeningTemp float64 // sampling temperature for those opening plies
-}
-
-// GameSummary is one arena game outcome.
-type GameSummary struct {
- Game int `json:"game"`
- BlackEval string `json:"black_eval"`
- WhiteEval string `json:"white_eval"`
- BlackWins bool `json:"black_wins"`
- WhiteWins bool `json:"white_wins"`
- Draw bool `json:"draw"`
- Moves int `json:"moves"`
-}
-
-// MatchResult is JSON output for arena runs.
-type MatchResult struct {
- WinsBlack int `json:"wins_black"`
- WinsWhite int `json:"wins_white"`
- Draws int `json:"draws"`
- WinsBaseline int `json:"wins_baseline"`
- WinsChallenger int `json:"wins_challenger"`
- WinRateBlack float64 `json:"win_rate_black"`
- WinRateBaseline float64 `json:"win_rate_baseline"`
- WinRateChallenger float64 `json:"win_rate_challenger"`
- WilsonCILow float64 `json:"wilson_ci_low"`
- WilsonCIHigh float64 `json:"wilson_ci_high"`
- BaselineWilsonLow float64 `json:"baseline_wilson_ci_low"`
- BaselineWilsonHigh float64 `json:"baseline_wilson_ci_high"`
- ConfigHash string `json:"config_hash"`
- Games int `json:"game_count"`
- BaselineEval string `json:"baseline_eval"`
- ChallengerEval string `json:"challenger_eval"`
- Promoted bool `json:"promoted"`
- GameSummaries []GameSummary `json:"games,omitempty"`
-}
-
-// WilsonCI returns Wilson score interval for binomial proportion (z=1.96 ~ 95%).
-func WilsonCI(wins, n int, z float64) (low, high float64) {
- if n == 0 {
- return 0, 1
- }
- p := float64(wins) / float64(n)
- z2 := z * z
- denom := 1 + z2/float64(n)
- center := (p + z2/(2*float64(n))) / denom
- margin := z * math.Sqrt((p*(1-p)/float64(n) + z2/(4*float64(n)*float64(n)))) / denom
- low = center - margin
- high = center + margin
- if low < 0 {
- low = 0
- }
- if high > 1 {
- high = 1
- }
- return low, high
-}
-
-func matchConfigHash(cfg MatchConfig) string {
- h := sha256.New()
- fmt.Fprintf(h, "games=%d size=%d komi=%.2f playouts=%d bpl=%d wpl=%d think=%d black=%s white=%s seed=%d swap=%v enhanced=%s",
- cfg.Games, cfg.Size, cfg.Komi, cfg.Playouts, cfg.BlackPlayouts, cfg.WhitePlayouts,
- cfg.ThinkTime, cfg.BlackEval, cfg.WhiteEval, cfg.Seed, cfg.SwapColors, cfg.ArenaEnhanced)
- if info, ok := debug.ReadBuildInfo(); ok {
- fmt.Fprintf(h, " mod=%s", info.Main.Version)
- }
- sum := h.Sum(nil)
- return hex.EncodeToString(sum[:8])
-}
-
-func normalizeMatchConfig(cfg MatchConfig) MatchConfig {
- if cfg.Games <= 0 {
- cfg.Games = 1
- }
- if cfg.Playouts <= 0 && cfg.ThinkTime <= 0 {
- cfg.Playouts = defaultPlayoutsForSize(cfg.Size)
- }
- if !cfg.SwapColors {
- cfg.SwapColors = true
- }
- return cfg
-}
-
-// promoteCILow is the Wilson CI lower bound required for promotion
-// (see training/cycle.py should_promote).
-const promoteCILow = 0.5
-
-// minGamesBeforeStop avoids stopping micro-arenas before enough evidence accumulates.
-const minGamesBeforeStop = 20
-
-// minGamesBeforePromote blocks early promotion on short runs; reject can still
-// fire once the win-rate bar is unreachable.
-var minGamesBeforePromote = 100
-
-// promotionGateDecided reports whether the head-to-head gate is already
-// settled after played games out of maxGames.
-func promotionGateDecided(challengerWins, played, maxGames int, promoteWin float64) (accept, reject bool) {
- if played < minGamesBeforeStop || maxGames < minGamesBeforeStop {
- return false, false
- }
- winRate := float64(challengerWins) / float64(played)
- ciLow, _ := WilsonCI(challengerWins, played, 1.96)
- if played >= minGamesBeforePromote && winRate >= promoteWin && ciLow > promoteCILow {
- return true, false
- }
- remaining := maxGames - played
- maxWins := challengerWins + remaining
- maxWinRate := float64(maxWins) / float64(maxGames)
- // Wilson reject is evaluated at end-of-match only; early reject fires when the
- // win-rate bar is unreachable even if every remaining game is won.
- if maxWinRate < promoteWin {
- return false, true
- }
- return false, false
-}
-
-// RunMatch plays up to cfg.Games between BlackEval (baseline) and WhiteEval (challenger).
-// Games run concurrently (cfg.Parallel) over one shared evaluator per role so the
-// inference sidecars receive real batches. Stops early once the promotion gate is
-// statistically decided (same Wilson CI bar as the train loop).
-func RunMatch(cfg MatchConfig) MatchResult {
- cfg = normalizeMatchConfig(cfg)
- r := Chinese()
- maxGames := cfg.Games
- out := MatchResult{
- Games: maxGames,
- ConfigHash: matchConfigHash(cfg),
- BaselineEval: cfg.BlackEval,
- ChallengerEval: cfg.WhiteEval,
- }
-
- evals, closeEvals := buildArenaEvaluators(cfg)
- defer closeEvals()
-
- par := cfg.Parallel
- if par < 1 {
- par = 1
- }
- if par > maxGames {
- par = maxGames
- }
-
- type arenaProgress struct {
- mu sync.Mutex
- nextIdx int
- stop bool
- played int
- baselineWins int
- challengerWins int
- summaries []GameSummary
- }
-
- prog := &arenaProgress{}
- sem := make(chan struct{}, par)
- var wg sync.WaitGroup
-
- worker := func() {
- defer wg.Done()
- for {
- prog.mu.Lock()
- if prog.stop || prog.nextIdx >= maxGames {
- prog.mu.Unlock()
- return
- }
- idx := prog.nextIdx
- prog.nextIdx++
- prog.mu.Unlock()
-
- sem <- struct{}{}
- summary := runArenaSummary(r, cfg, idx, evals)
- <-sem
-
- prog.mu.Lock()
- if prog.stop {
- prog.mu.Unlock()
- return
- }
- prog.summaries = append(prog.summaries, summary)
- accumulateArenaSummary(&out, summary)
- prog.baselineWins, prog.challengerWins = countRoleWin(
- cfg, summary, prog.baselineWins, prog.challengerWins,
- )
- prog.played++
- if cfg.BlackEval != cfg.WhiteEval {
- accept, reject := promotionGateDecided(
- prog.challengerWins, prog.played, maxGames, PromoteMin,
- )
- if accept || reject {
- prog.stop = true
- }
- }
- prog.mu.Unlock()
- }
- }
-
- for w := 0; w < par; w++ {
- wg.Add(1)
- go worker()
- }
- wg.Wait()
-
- played := prog.played
- out.Games = played
- out.GameSummaries = prog.summaries
- out.WinsBaseline = prog.baselineWins
- out.WinsChallenger = prog.challengerWins
- if played == 0 {
- return out
- }
- out.WinRateBlack = float64(out.WinsBlack) / float64(played)
- out.WinRateBaseline = float64(prog.baselineWins) / float64(played)
- out.WinRateChallenger = float64(prog.challengerWins) / float64(played)
- out.WilsonCILow, out.WilsonCIHigh = WilsonCI(prog.challengerWins, played, 1.96)
- out.BaselineWilsonLow, out.BaselineWilsonHigh = WilsonCI(prog.baselineWins, played, 1.96)
-
- harness := GatingHarness{Games: played, MinWinRateMargin: PromoteMin}
- out.Promoted = harness.Pass(out.WinRateBaseline, out.WinRateChallenger)
- return out
-}
-
-func arenaEvalsForGame(cfg MatchConfig, gameIdx int) (string, string) {
- if cfg.SwapColors && gameIdx%2 == 1 {
- return cfg.WhiteEval, cfg.BlackEval
- }
- return cfg.BlackEval, cfg.WhiteEval
-}
-
-func arenaPlayouts(cfg MatchConfig) (int, int) {
- bp, wp := cfg.Playouts, cfg.Playouts
- if cfg.BlackPlayouts > 0 {
- bp = cfg.BlackPlayouts
- }
- if cfg.WhitePlayouts > 0 {
- wp = cfg.WhitePlayouts
- }
- return bp, wp
-}
-
-func runArenaSummary(r Ruleset, cfg MatchConfig, gameIdx int, evals map[string]Evaluator) GameSummary {
- blackEval, whiteEval := arenaEvalsForGame(cfg, gameIdx)
- bp, wp := arenaPlayouts(cfg)
- // Evaluators are shared across games; do not Close() the per-game engines.
- blackEng := newArenaEngineEval(r, bp, cfg.ThinkTime, evals[blackEval], cfg.Seed+int64(gameIdx)*2, arenaEnhancedFor(blackEval, cfg))
- whiteEng := newArenaEngineEval(r, wp, cfg.ThinkTime, evals[whiteEval], cfg.Seed+int64(gameIdx)*2+1, arenaEnhancedFor(whiteEval, cfg))
-
- b := NewBoard(cfg.Size, cfg.Komi)
- // Per-game RNG seeds opening-move sampling so the games in a match are
- // actually distinct; without it the deterministic MCTS replays one identical
- // game N times and the win rate is decided by komi symmetry, not strength.
- rng := rand.New(rand.NewSource(cfg.Seed + int64(gameIdx+1)*0x9E3779B1))
- moves := playArenaGame(r, b, blackEng, whiteEng, cfg.Size, cfg.OpeningMoves, cfg.OpeningTemp, rng)
- bl, wl := r.Score(b)
- summary := GameSummary{Game: gameIdx + 1, BlackEval: blackEval, WhiteEval: whiteEval, Moves: moves}
- switch {
- case bl > wl:
- summary.BlackWins = true
- case wl > bl:
- summary.WhiteWins = true
- default:
- summary.Draw = true
- }
- return summary
-}
-
-func accumulateArenaSummary(out *MatchResult, summary GameSummary) {
- switch {
- case summary.BlackWins:
- out.WinsBlack++
- case summary.WhiteWins:
- out.WinsWhite++
- default:
- out.Draws++
- }
-}
-
-func countRoleWin(cfg MatchConfig, summary GameSummary, baselineWins, challengerWins int) (int, int) {
- if cfg.BlackEval == cfg.WhiteEval {
- return baselineWins, challengerWins
- }
- if summary.BlackWins && summary.BlackEval == cfg.BlackEval {
- baselineWins++
- } else if summary.WhiteWins && summary.WhiteEval == cfg.BlackEval {
- baselineWins++
- } else if summary.BlackWins || summary.WhiteWins {
- challengerWins++
- }
- return baselineWins, challengerWins
-}
-
-func playArenaGame(r Ruleset, b *Board, blackEng, whiteEng *Engine, size, openingMoves int, temp float64, rng *rand.Rand) int {
- passes := 0
- moves := 0
- for moveNum := 0; moveNum < size*size+2; moveNum++ {
- if onlyPass(r.LegalMoves(b)) {
- break
- }
- eng := whiteEng
- if b.Player() == Black {
- eng = blackEng
- }
- var m Move
- if moveNum < openingMoves && temp > 0 {
- m = eng.SelectMove(b, rng, temp)
- } else {
- m = eng.BestMove(b)
- }
- if !r.Play(b, m) {
- m = PassMove
- r.Play(b, m)
- }
- eng.AdvanceTree(m)
- moves++
- if m.Pass {
- passes++
- } else {
- passes = 0
- }
- if passes >= 2 {
- break
- }
- }
- return moves
-}
-
-func arenaEnhancedFor(evalName string, cfg MatchConfig) bool {
- switch strings.ToLower(cfg.ArenaEnhanced) {
- case "both":
- return true
- case "baseline":
- return evalIsBaseline(evalName, cfg.BlackEval)
- default:
- return false
- }
-}
-
-// buildArenaEvaluators constructs one shared evaluator per distinct eval role.
-// Stateless evaluators (heuristic/uniform) and the concurrency-safe batched ONNX
-// evaluator are all safe to share across concurrent games.
-func buildArenaEvaluators(cfg MatchConfig) (map[string]Evaluator, func()) {
- minBatch := cfg.Parallel
- if minBatch < 1 {
- minBatch = 1
- }
- evals := make(map[string]Evaluator)
- var closers []func()
- for _, name := range []string{cfg.BlackEval, cfg.WhiteEval} {
- if _, ok := evals[name]; ok {
- continue
- }
- ev := buildSharedEvaluator(name, minBatch)
- evals[name] = ev
- if c, ok := ev.(*BatchedEvaluator); ok {
- closers = append(closers, c.Close)
- }
- }
- return evals, func() {
- for _, c := range closers {
- c()
- }
- }
-}
-
-func buildSharedEvaluator(name string, minBatch int) Evaluator {
- switch {
- case strings.EqualFold(name, "onnx"), strings.EqualFold(name, "onnx-batch"):
- return newONNXEvaluator(minBatch)
- case strings.EqualFold(name, "onnx2"):
- return newONNXEvaluatorSecondary(minBatch)
- case strings.EqualFold(name, "heuristic2"):
- return Heuristic{} // alias for symmetric arena bias tests (same strength as heuristic)
- default:
- return parseEvaluator(name)
- }
-}
-
-func newArenaEngineEval(r Ruleset, playouts int, think time.Duration, ev Evaluator, seed int64, enhanced bool) *Engine {
- scfg := DefaultConfig()
- scfg.Playouts = playouts
- scfg.ThinkTime = think
- scfg.Seed = seed
- scfg.Workers = 1 // concurrency comes from parallel games feeding the shared batcher
- if enhanced {
- scfg.ForcedRootPlayouts = defaultForcedRoot
- }
- return NewEngine(r, ev, scfg)
-}
-
-func evalIsBaseline(evalName, baselineName string) bool {
- return evalName == baselineName
-}
diff --git a/cmd/gofer/match_test.go b/cmd/gofer/match_test.go
deleted file mode 100644
index 27f7ba1..0000000
--- a/cmd/gofer/match_test.go
+++ /dev/null
@@ -1,363 +0,0 @@
-package main
-
-import (
- "fmt"
- "math"
- "math/rand"
- "testing"
-)
-
-func TestWilsonCI(t *testing.T) {
- low, high := WilsonCI(55, 100, 1.96)
- if low > 0.55 || high < 0.45 {
- t.Fatalf("unexpected CI [%f,%f] for 55/100", low, high)
- }
- low0, high0 := WilsonCI(0, 0, 1.96)
- if low0 != 0 || high0 != 1 {
- t.Fatalf("empty CI want [0,1] got [%f,%f]", low0, high0)
- }
-}
-
-func TestPromotionGateDecided(t *testing.T) {
- accept, reject := promotionGateDecided(120, 200, 200, PromoteMin)
- if !accept || reject {
- t.Fatalf("120/200 should accept got accept=%v reject=%v", accept, reject)
- }
- accept, reject = promotionGateDecided(70, 200, 200, PromoteMin)
- if accept || !reject {
- t.Fatalf("70/200 should reject got accept=%v reject=%v", accept, reject)
- }
- accept, reject = promotionGateDecided(55, 100, 200, PromoteMin)
- if accept || reject {
- t.Fatalf("55/100 mid-match should continue got accept=%v reject=%v", accept, reject)
- }
- // Best-case finish still below promote bar.
- accept, reject = promotionGateDecided(50, 160, 200, PromoteMin)
- if accept || !reject {
- t.Fatalf("50/160 capped at 90/200 should reject got accept=%v reject=%v", accept, reject)
- }
- accept, reject = promotionGateDecided(1, 1, 2, PromoteMin)
- if accept || reject {
- t.Fatalf("micro arena should not early-stop got accept=%v reject=%v", accept, reject)
- }
-}
-
-func TestNoEarlyPromoteBeforeMinGames(t *testing.T) {
- accept, reject := promotionGateDecided(30, 46, 200, PromoteMin)
- if accept {
- t.Fatalf("30/46 hot streak must not accept before %d games", minGamesBeforePromote)
- }
- if reject {
- t.Fatalf("30/46 hot streak must not reject early")
- }
- // Below accept floor: strong rate but not enough games.
- accept, reject = promotionGateDecided(70, 99, 200, PromoteMin)
- if accept {
- t.Fatalf("70/99 must not accept before %d games", minGamesBeforePromote)
- }
- // Strong candidate still accepts once the floor is met.
- accept, reject = promotionGateDecided(120, 200, 200, PromoteMin)
- if !accept || reject {
- t.Fatalf("120/200 should accept after floor got accept=%v reject=%v", accept, reject)
- }
-}
-
-func TestPromotablePathRunsFull200(t *testing.T) {
- const maxGames = 200
- bestCasePromotes := func(wins, played int) bool {
- maxWins := wins + (maxGames - played)
- maxRate := float64(maxWins) / float64(maxGames)
- maxCiLow, _ := WilsonCI(maxWins, maxGames, 1.96)
- return maxRate >= PromoteMin && maxCiLow > promoteCILow
- }
- // 114/200 is the lowest integer final in the 0.53–0.57 band that clears both gates.
- for _, finalWins := range []int{114, 115, 116} {
- t.Run(fmt.Sprintf("final_%d", finalWins), func(t *testing.T) {
- for played := minGamesBeforeStop; played < maxGames; played++ {
- wins := played * finalWins / maxGames
- if !bestCasePromotes(wins, played) {
- continue
- }
- accept, reject := promotionGateDecided(wins, played, maxGames, PromoteMin)
- if reject {
- maxWins := wins + (maxGames - played)
- maxRate := float64(maxWins) / float64(maxGames)
- rate := float64(wins) / float64(played)
- ciLow, ciHigh := WilsonCI(wins, played, 1.96)
- t.Fatalf(
- "played=%d wins=%d rate=%.3f maxRate=%.3f ci=[%.3f,%.3f] false reject (accept=%v)",
- played, wins, rate, maxRate, ciLow, ciHigh, accept,
- )
- }
- }
- finalRate := float64(finalWins) / float64(maxGames)
- ciLow, ciHigh := WilsonCI(finalWins, maxGames, 1.96)
- accept, reject := promotionGateDecided(finalWins, maxGames, maxGames, PromoteMin)
- t.Logf(
- "final wins=%d rate=%.3f ci=[%.3f,%.3f] accept=%v reject=%v",
- finalWins, finalRate, ciLow, ciHigh, accept, reject,
- )
- })
- }
-}
-
-func TestBorderlinePathPlaysFull200(t *testing.T) {
- const maxGames = 200
- for _, finalWins := range []int{110, 111, 112, 113} {
- t.Run(fmt.Sprintf("final_%d", finalWins), func(t *testing.T) {
- for played := minGamesBeforeStop; played < maxGames; played++ {
- wins := played * finalWins / maxGames
- accept, reject := promotionGateDecided(wins, played, maxGames, PromoteMin)
- if accept {
- t.Fatalf("played=%d wins=%d false accept before 200", played, wins)
- }
- if reject {
- maxWins := wins + (maxGames - played)
- t.Fatalf("played=%d wins=%d maxWins=%d false reject before 200", played, wins, maxWins)
- }
- }
- rate := float64(finalWins) / float64(maxGames)
- ciLow, ciHigh := WilsonCI(finalWins, maxGames, 1.96)
- promote := rate >= PromoteMin && ciLow > promoteCILow
- if promote {
- t.Fatalf("final %d should not promote", finalWins)
- }
- t.Logf("final %d: rate=%.3f ci=[%.3f,%.3f] reject (full 200)", finalWins, rate, ciLow, ciHigh)
- })
- }
-}
-
-func TestPromotionGateFinalOutcomes(t *testing.T) {
- const maxGames = 200
- cases := []struct {
- wins int
- promote bool
- }{
- {106, false}, // 0.53
- {108, false}, // 0.54
- {110, false}, // 0.55 — rate clears bar but Wilson low does not
- {112, false}, // 0.56
- {114, true}, // 0.57 — clears both gates
- }
- for _, tc := range cases {
- rate := float64(tc.wins) / float64(maxGames)
- ciLow, ciHigh := WilsonCI(tc.wins, maxGames, 1.96)
- accept, _ := promotionGateDecided(tc.wins, maxGames, maxGames, PromoteMin)
- promote := rate >= PromoteMin && ciLow > promoteCILow
- if promote != tc.promote {
- t.Fatalf(
- "wins=%d rate=%.3f ci=[%.3f,%.3f] promote=%v want %v",
- tc.wins, rate, ciLow, ciHigh, promote, tc.promote,
- )
- }
- if accept != tc.promote {
- t.Fatalf("wins=%d accept=%v want %v", tc.wins, accept, tc.promote)
- }
- }
-}
-
-func TestEarlyRejectWhenMaxRateTooLow(t *testing.T) {
- const maxGames = 200
- for _, finalPct := range []int{53, 54} {
- finalWins := maxGames * finalPct / 100
- rejectAt := 0
- for played := minGamesBeforeStop; played < maxGames; played++ {
- wins := played * finalWins / maxGames
- _, reject := promotionGateDecided(wins, played, maxGames, PromoteMin)
- if reject {
- rejectAt = played
- break
- }
- }
- if rejectAt == 0 {
- t.Fatalf("%d%% trajectory never rejected early", finalPct)
- }
- if rejectAt >= maxGames {
- t.Fatalf("%d%% rejected at or after cap", finalPct)
- }
- t.Logf("%d%% linear path: first reject at played=%d (before 200)", finalPct, rejectAt)
- }
-}
-
-func TestNoStopBeforeMinGames(t *testing.T) {
- for played := 1; played < minGamesBeforeStop; played++ {
- accept, reject := promotionGateDecided(played, played, 200, PromoteMin)
- if accept || reject {
- t.Fatalf("played=%d should not early-stop before floor", played)
- }
- }
-}
-
-func TestCountRoleWinIdenticalEval(t *testing.T) {
- cfg := MatchConfig{BlackEval: "heuristic", WhiteEval: "heuristic"}
- bw, cw := countRoleWin(cfg, GameSummary{WhiteWins: true, WhiteEval: "heuristic"}, 0, 0)
- if bw != 0 || cw != 0 {
- t.Fatalf("same eval names should not attribute wins got baseline=%d challenger=%d", bw, cw)
- }
-}
-
-func TestIdenticalEvalColorBalance(t *testing.T) {
- if testing.Short() {
- t.Skip("short")
- }
- oldAccept := minGamesBeforePromote
- minGamesBeforePromote = 201
- t.Cleanup(func() { minGamesBeforePromote = oldAccept })
-
- const games = 200
- res := RunMatch(MatchConfig{
- Games: games,
- Size: 9,
- Komi: -1.0, // heuristic fair komi at test playouts; not the arena remap
- Playouts: 50,
- BlackEval: "heuristic",
- WhiteEval: "heuristic",
- Seed: 99,
- SwapColors: true,
- OpeningMoves: 8,
- OpeningTemp: 1.0,
- Parallel: 4,
- })
- if res.Games != games {
- t.Fatalf("want %d games got %d", games, res.Games)
- }
- assertWinsNearExpected(t, res.WinsBlack, res.Games, 0.5, 3.0)
- t.Logf("black=%d white=%d draws=%d komi=%.1f",
- res.WinsBlack, res.WinsWhite, res.Draws, -1.0)
-}
-
-
-func assertWinsNearExpected(t *testing.T, wins, n int, p, sigmaN float64) {
- t.Helper()
- mean := float64(n) * p
- sigma := math.Sqrt(float64(n) * p * (1 - p))
- low := int(math.Floor(mean - sigmaN*sigma))
- high := int(math.Ceil(mean + sigmaN*sigma))
- if wins < low || wins > high {
- t.Fatalf("wins %d/%d outside [%d,%d] (%.1f sigma)", wins, n, low, high, sigmaN)
- }
-}
-
-func TestRunMatchSmoke(t *testing.T) {
- if testing.Short() {
- t.Skip("short")
- }
- result := RunMatch(MatchConfig{
- Games: 2,
- Size: 5,
- Komi: 6.5,
- Playouts: 8,
- BlackEval: "heuristic",
- WhiteEval: "uniform",
- Seed: 42,
- })
- if result.Games != 2 {
- t.Fatalf("games %d", result.Games)
- }
- if result.ConfigHash == "" {
- t.Fatal("missing config hash")
- }
- if len(result.GameSummaries) != 2 {
- t.Fatalf("summaries %d", len(result.GameSummaries))
- }
-}
-
-func TestCapRandomizeDistribution(t *testing.T) {
- cfg := DefaultSelfplayConfig()
- cfg.CapRandomizeP = 0.25
- rng := rand.New(rand.NewSource(99))
- full := 0
- const n = 4000
- for i := 0; i < n; i++ {
- if _, fs := selfplayMovePlayouts(cfg, rng); fs {
- full++
- }
- }
- rate := float64(full) / float64(n)
- if rate < 0.18 || rate > 0.32 {
- t.Fatalf("full-search rate %v want ~0.25", rate)
- }
-}
-
-func TestSelfplayFixedPlayoutsWhenCapZero(t *testing.T) {
- cfg := DefaultSelfplayConfig()
- cfg.Playouts = 200
- cfg.FastPlayouts = 50
- cfg.FullPlayouts = 200
- cfg.CapRandomizeP = 0
- rng := rand.New(rand.NewSource(1))
- for i := 0; i < 100; i++ {
- p, full := selfplayMovePlayouts(cfg, rng)
- if p != 200 || !full {
- t.Fatalf("move %d: playouts=%d full=%v want 200 true", i, p, full)
- }
- }
-}
-
-func TestSelfplayGamesCompleteLegal(t *testing.T) {
- for _, tc := range []struct {
- name string
- cfg SelfplayConfig
- }{
- {
- name: "randomized_caps",
- cfg: SelfplayConfig{
- Games: 2, BoardSize: 9, Komi: 6.5, Playouts: 20,
- FastPlayouts: 8, FullPlayouts: 20, CapRandomizeP: 0.25,
- Seed: 11, EvalMode: "heuristic", FullOnlyExport: true, Parallel: 1,
- },
- },
- {
- name: "fixed_playouts",
- cfg: SelfplayConfig{
- Games: 2, BoardSize: 9, Komi: 6.5, Playouts: 20,
- FastPlayouts: 20, FullPlayouts: 20, CapRandomizeP: 0,
- Seed: 11, EvalMode: "heuristic", FullOnlyExport: true, Parallel: 1,
- },
- },
- } {
- t.Run(tc.name, func(t *testing.T) {
- samples, logs := RunSelfplayWithLogs(tc.cfg)
- if len(logs) != tc.cfg.Games {
- t.Fatalf("logs %d want %d", len(logs), tc.cfg.Games)
- }
- for i, log := range logs {
- if len(log.Moves) == 0 {
- t.Fatalf("game %d: no moves recorded", i)
- }
- }
- for i, s := range samples {
- if len(s.Policy) != 82 {
- t.Fatalf("sample %d: policy len %d", i, len(s.Policy))
- }
- if tc.cfg.FullOnlyExport && !s.FullSearch {
- t.Fatalf("sample %d: exported non-full position with full-only", i)
- }
- }
- })
- }
-}
-
-func TestFullOnlyExportFilters(t *testing.T) {
- cfg := DefaultSelfplayConfig()
- cfg.Games = 1
- cfg.Playouts = 10
- cfg.BoardSize = 5
- cfg.CapRandomizeP = 0.5
- cfg.Seed = 7
- cfg.FullOnlyExport = true
- all, _ := RunSelfplayWithLogs(SelfplayConfig{
- Games: cfg.Games, BoardSize: cfg.BoardSize, Komi: cfg.Komi,
- Playouts: cfg.Playouts, FastPlayouts: 4, FullPlayouts: 10,
- CapRandomizeP: 0.5, Seed: cfg.Seed, FullOnlyExport: false,
- })
- filtered := RunSelfplay(cfg)
- if len(filtered) > len(all) {
- t.Fatalf("filtered %d > all %d", len(filtered), len(all))
- }
- for _, s := range filtered {
- if !s.FullSearch {
- t.Fatal("non-full sample in filtered export")
- }
- }
-}
diff --git a/cmd/gofer/mcts.go b/cmd/gofer/mcts.go
deleted file mode 100644
index 858ac29..0000000
--- a/cmd/gofer/mcts.go
+++ /dev/null
@@ -1,509 +0,0 @@
-package main
-
-import (
- "math"
- "math/rand"
- "runtime"
- "sync"
- "sync/atomic"
- "time"
-)
-
-const (
- defaultCPUCT = 1.1
- defaultFPU = 0.2
- rootTemperature = 1.03
- virtualLoss = 3
- defaultForcedRoot = 2
- minPolicyVisits = 2
-)
-
-// SearchConfig holds MCTS parameters.
-type SearchConfig struct {
- CPUCT float64
- FPU float64
- Playouts int
- Seed int64
- RootNoise bool
- RootTemperature float64
- Workers int // 0 = GOMAXPROCS
- ThinkTime time.Duration // if >0, search until deadline instead of fixed playouts
- ForcedRootPlayouts int // paper k=2 at root; 0 disables
-}
-
-// DefaultConfig returns search defaults aligned with Wu 2020.
-func DefaultConfig() SearchConfig {
- return SearchConfig{
- CPUCT: defaultCPUCT,
- FPU: defaultFPU,
- Playouts: 100,
- Seed: 1,
- RootNoise: false,
- RootTemperature: rootTemperature,
- }
-}
-
-// Engine runs MCTS search.
-type Engine struct {
- Rules Ruleset
- Eval Evaluator
- TT *Table
- cfg SearchConfig
- rng *rand.Rand
- arena *Arena
- root int
- mu sync.Mutex
- rngSeq uint64
-}
-
-// NewEngine constructs an MCTS search engine.
-func NewEngine(r Ruleset, ev Evaluator, cfg SearchConfig) *Engine {
- if cfg.CPUCT == 0 {
- cfg = DefaultConfig()
- }
- if ev == nil {
- ev = Uniform{}
- }
- return &Engine{
- Rules: r,
- Eval: ev,
- TT: NewTable(1 << 16),
- cfg: cfg,
- rng: rand.New(rand.NewSource(cfg.Seed)),
- }
-}
-
-// Close releases batched evaluator resources if present.
-func (e *Engine) Close() {
- if c, ok := e.Eval.(*BatchedEvaluator); ok {
- c.Close()
- }
-}
-
-// ResetArena clears the search tree.
-func (e *Engine) ResetArena() {
- e.mu.Lock()
- defer e.mu.Unlock()
- e.arena = nil
- e.root = 0
-}
-
-// AdvanceTree moves the search root to the child matching m, or resets on miss.
-func (e *Engine) AdvanceTree(m Move) {
- e.mu.Lock()
- defer e.mu.Unlock()
- if e.arena == nil {
- return
- }
- n := e.arena.Get(e.root)
- for _, cidx := range n.Children {
- c := e.arena.Get(cidx)
- if movesEqual(c.Move, m) {
- e.root = cidx
- return
- }
- }
- e.arena = nil
- e.root = 0
-}
-
-// MoveCandidate is a root move with search statistics.
-type MoveCandidate struct {
- Move Move
- Visits uint32
- WinRate float64
- Share float64
-}
-
-// Analysis holds search results for a position.
-type Analysis struct {
- Playouts int
- RootValue float64
- Best Move
- Candidates []MoveCandidate
- PV []Move
-}
-
-// SetLimits configures playout count or think-time (think-time takes precedence when >0).
-func (e *Engine) SetLimits(playouts int, think time.Duration) {
- if think > 0 {
- e.cfg.ThinkTime = think
- return
- }
- e.cfg.ThinkTime = 0
- if playouts > 0 {
- e.cfg.Playouts = playouts
- }
-}
-
-// ConfigureSelfplayMove sets per-move search options for mixed playout caps (paper SE-4.1).
-func (e *Engine) ConfigureSelfplayMove(playouts int, fullSearch bool) {
- e.cfg.Playouts = playouts
- e.cfg.ThinkTime = 0
- e.cfg.RootNoise = true
- if fullSearch {
- e.cfg.ForcedRootPlayouts = defaultForcedRoot
- } else {
- e.cfg.ForcedRootPlayouts = 0
- }
-}
-
-// BestMove runs MCTS and returns the most visited root child move.
-func (e *Engine) BestMove(b *Board) Move {
- e.runSearch(b)
- e.mu.Lock()
- defer e.mu.Unlock()
- return e.arena.bestRootMove(e.root)
-}
-
-// SelectMove runs MCTS and returns a root move. temperature<=0 plays the most
-// visited move; temperature>0 samples proportional to visits^(1/temperature).
-func (e *Engine) SelectMove(b *Board, rng *rand.Rand, temperature float64) Move {
- e.runSearch(b)
- e.mu.Lock()
- defer e.mu.Unlock()
- if temperature <= 0 {
- return e.arena.bestRootMove(e.root)
- }
- return e.sampleRootMoveLocked(rng, temperature)
-}
-
-func (e *Engine) sampleRootMoveLocked(rng *rand.Rand, temperature float64) Move {
- root := e.arena.Get(e.root)
- if len(root.Children) == 0 {
- return PassMove
- }
- weights := make([]float64, len(root.Children))
- sum := 0.0
- invT := 1.0 / temperature
- for i, cidx := range root.Children {
- v := float64(e.arena.Get(cidx).Visits)
- if v <= 0 {
- continue
- }
- weights[i] = math.Pow(v, invT)
- sum += weights[i]
- }
- if sum <= 0 {
- return e.arena.bestRootMove(e.root)
- }
- target := rng.Float64() * sum
- for i, cidx := range root.Children {
- target -= weights[i]
- if target <= 0 {
- return e.arena.Get(cidx).Move
- }
- }
- return e.arena.Get(root.Children[len(root.Children)-1]).Move
-}
-
-// Analyze runs search and returns ranked candidates plus a principal variation.
-func (e *Engine) Analyze(b *Board, topN int) Analysis {
- playouts := e.runSearch(b)
- e.mu.Lock()
- defer e.mu.Unlock()
- return e.analyzeLocked(topN, playouts)
-}
-
-func (e *Engine) runSearch(b *Board) int {
- if e.arena == nil {
- e.arena = NewArena()
- e.root = e.arena.Root()
- }
- e.ensureRootExpanded(b)
- if e.cfg.ForcedRootPlayouts > 0 {
- e.runForcedRootPlayouts(b)
- }
- if e.cfg.ThinkTime > 0 {
- deadline := time.Now().Add(e.cfg.ThinkTime)
- n := 0
- for time.Now().Before(deadline) {
- e.runPlayout(b, e.root)
- n++
- }
- return n
- }
- e.runPlayouts(b, e.cfg.Playouts)
- return e.cfg.Playouts
-}
-
-func (e *Engine) runPlayouts(b *Board, playouts int) {
- workers := e.cfg.Workers
- if workers <= 0 {
- workers = runtime.GOMAXPROCS(0)
- }
- if workers < 1 {
- workers = 1
- }
- if workers == 1 || playouts < workers {
- for i := 0; i < playouts; i++ {
- e.runPlayout(b, e.root)
- }
- return
- }
- perWorker := playouts / workers
- extra := playouts % workers
- var wg sync.WaitGroup
- for w := 0; w < workers; w++ {
- n := perWorker
- if w < extra {
- n++
- }
- if n == 0 {
- continue
- }
- wg.Add(1)
- go func(count int) {
- defer wg.Done()
- for i := 0; i < count; i++ {
- e.runPlayout(b, e.root)
- }
- }(n)
- }
- wg.Wait()
-}
-
-func (e *Engine) ensureRootExpanded(b *Board) {
- e.mu.Lock()
- defer e.mu.Unlock()
- root := e.arena.Get(e.root)
- if !root.Expanded {
- e.expandLocked(e.root, b)
- }
-}
-
-func (e *Engine) runForcedRootPlayouts(b *Board) {
- k := e.cfg.ForcedRootPlayouts
- if k <= 0 {
- k = defaultForcedRoot
- }
- e.mu.Lock()
- root := e.arena.Get(e.root)
- children := append([]int(nil), root.Children...)
- e.mu.Unlock()
- for _, cidx := range children {
- e.mu.Lock()
- c := e.arena.Get(cidx)
- target := k + int(math.Sqrt(c.Prior*float64(e.cfg.Playouts+1)))
- need := target - int(c.Visits)
- e.mu.Unlock()
- for need > 0 {
- e.runPlayoutForced(b, cidx)
- need--
- }
- }
-}
-
-func (e *Engine) runPlayoutForced(b *Board, firstChild int) {
- br := b.Clone()
- e.mu.Lock()
- move := e.arena.Get(firstChild).Move
- e.mu.Unlock()
- path := []int{e.root, firstChild}
- e.applyMove(br, move)
- node := firstChild
-
- for {
- e.mu.Lock()
- n := e.arena.Get(node)
- if !n.Expanded || len(n.Children) == 0 {
- e.mu.Unlock()
- break
- }
- child := e.selectChildLocked(node, false)
- move = e.arena.Get(child).Move
- e.mu.Unlock()
- path = append(path, child)
- e.applyMove(br, move)
- node = child
- }
-
- e.mu.Lock()
- n := e.arena.Get(node)
- if !n.Expanded {
- e.expandLocked(node, br)
- }
- e.mu.Unlock()
-
- value := e.leafValue(br)
- e.backup(path, value)
-}
-
-func (e *Engine) runPlayout(b *Board, root int) {
- br := b.Clone()
- path := make([]int, 0, 24)
- path = append(path, root)
- node := root
-
- for {
- e.mu.Lock()
- n := e.arena.Get(node)
- if !n.Expanded || len(n.Children) == 0 {
- e.mu.Unlock()
- break
- }
- child := e.selectChildLocked(node, node == e.root)
- move := e.arena.Get(child).Move
- e.mu.Unlock()
- path = append(path, child)
- e.applyMove(br, move)
- node = child
- }
-
- e.mu.Lock()
- n := e.arena.Get(node)
- if !n.Expanded {
- if v, ok := e.TT.Get(br.Hash()); ok && v.Depth != 0 {
- e.backupLocked(path, v.Value)
- e.mu.Unlock()
- return
- }
- e.expandLocked(node, br)
- }
- e.mu.Unlock()
-
- value := e.leafValue(br)
- e.backup(path, value)
-}
-
-func (e *Engine) backup(path []int, value float64) {
- e.mu.Lock()
- defer e.mu.Unlock()
- e.backupLocked(path, value)
-}
-
-func (e *Engine) backupLocked(path []int, value float64) {
- for i := len(path) - 1; i >= 0; i-- {
- nd := e.arena.Get(path[i])
- if i > 0 && nd.Visits >= virtualLoss {
- nd.Visits -= virtualLoss
- }
- nd.Visits++
- nd.ValueSum += value
- value = -value
- }
-}
-
-func (e *Engine) applyMove(br *Board, m Move) {
- if m.Pass {
- e.Rules.Play(br, PassMove)
- } else {
- e.Rules.Play(br, StoneMove(m.Point))
- }
-}
-
-func (e *Engine) expandLocked(node int, b *Board) {
- n := e.arena.Get(node)
- if n.Expanded {
- return
- }
- if e.isTerminal(b) {
- n.Expanded = true
- return
- }
- moves := e.Rules.LegalMoves(b)
- res := e.Eval.Evaluate(b)
- priors := uniformPriors(len(moves))
- if len(res.Policy) > 0 {
- priors = policyPriors(b, moves, res.Policy)
- }
- if node == e.root && e.cfg.RootNoise {
- priors = blendDirichlet(priors, e.rng)
- }
- for i, m := range moves {
- e.arena.AddChild(node, m, priors[i])
- }
- n.Expanded = true
- e.TT.Store(b.Hash(), Entry{Depth: 1, Value: res.Value})
-}
-
-func (e *Engine) selectChildLocked(node int, isRoot bool) int {
- n := e.arena.Get(node)
- parentVisits := float64(n.Visits)
- if parentVisits == 0 {
- parentVisits = 1
- }
- best := -1
- bestScore := math.Inf(-1)
- for _, cidx := range n.Children {
- c := e.arena.Get(cidx)
- score := puctScore(c, parentVisits, isRoot, e.cfg)
- if score > bestScore {
- bestScore = score
- best = cidx
- }
- }
- if best >= 0 {
- e.arena.Get(best).Visits += virtualLoss
- }
- return best
-}
-
-func (e *Engine) leafValue(b *Board) float64 {
- hash := b.Hash()
- e.mu.Lock()
- if v, ok := e.TT.Get(hash); ok && v.Depth != 0 {
- e.mu.Unlock()
- return v.Value
- }
- e.mu.Unlock()
-
- res := e.Eval.Evaluate(b)
- if res.HasValue {
- e.mu.Lock()
- e.TT.Store(hash, Entry{Depth: 1, Value: res.Value})
- e.mu.Unlock()
- return res.Value
- }
- v := e.randomPlayout(b)
- e.mu.Lock()
- e.TT.Store(hash, Entry{Depth: 1, Value: v})
- e.mu.Unlock()
- return v
-}
-
-func (e *Engine) randomPlayout(b *Board) float64 {
- rng := e.playoutRand()
- br := b.Clone()
- player := br.Player()
- passes := 0
- for move := 0; move < maxRolloutMoves && passes < 2; move++ {
- moves := e.Rules.LegalMoves(br)
- if len(moves) == 0 {
- break
- }
- m := moves[rng.Intn(len(moves))]
- e.Rules.Play(br, m)
- if m.Pass {
- passes++
- } else {
- passes = 0
- }
- }
- bl, wl := e.Rules.Score(br)
- diff := bl - wl
- if player == White {
- diff = wl - bl
- }
- if diff > 0 {
- return 1
- }
- if diff < 0 {
- return -1
- }
- return 0
-}
-
-func (e *Engine) playoutRand() *rand.Rand {
- seq := atomic.AddUint64(&e.rngSeq, 1)
- return rand.New(rand.NewSource(e.cfg.Seed + int64(seq)))
-}
-
-func (e *Engine) isTerminal(b *Board) bool {
- for _, m := range e.Rules.LegalMoves(b) {
- if !m.Pass {
- return false
- }
- }
- return true
-}
diff --git a/cmd/gofer/mcts_policy.go b/cmd/gofer/mcts_policy.go
deleted file mode 100644
index 64fec06..0000000
--- a/cmd/gofer/mcts_policy.go
+++ /dev/null
@@ -1,207 +0,0 @@
-package main
-
-import "sort"
-
-func (e *Engine) analyzeLocked(topN, playouts int) Analysis {
- out := Analysis{Playouts: playouts}
- if e.arena == nil || len(e.arena.nodes) == 0 {
- out.Best = PassMove
- return out
- }
- root := e.arena.Get(e.root)
- out.RootValue = root.Mean()
- out.Candidates = rootCandidates(e.arena, e.root, topN)
- if len(out.Candidates) > 0 {
- out.Best = out.Candidates[0].Move
- } else {
- out.Best = PassMove
- }
- out.PV = e.pvLocked(8)
- return out
-}
-
-func rootCandidates(a *Arena, root, topN int) []MoveCandidate {
- n := a.Get(root)
- if len(n.Children) == 0 {
- return nil
- }
- total := rootVisitTotal(a, n)
- cands := sortedRootCandidates(a, n)
- applyCandidateShares(cands, total)
- if topN <= 0 {
- topN = 5
- }
- if len(cands) > topN {
- cands = cands[:topN]
- }
- return cands
-}
-
-func rootVisitTotal(a *Arena, n *Node) uint32 {
- var total uint32
- for _, cidx := range n.Children {
- total += a.Get(cidx).Visits
- }
- return total
-}
-
-func sortedRootCandidates(a *Arena, n *Node) []MoveCandidate {
- cands := make([]MoveCandidate, 0, len(n.Children))
- for _, cidx := range n.Children {
- c := a.Get(cidx)
- cands = append(cands, MoveCandidate{Move: c.Move, Visits: c.Visits, WinRate: c.Mean()})
- }
- sort.Slice(cands, func(i, j int) bool {
- if cands[i].Visits != cands[j].Visits {
- return cands[i].Visits > cands[j].Visits
- }
- return cands[i].WinRate > cands[j].WinRate
- })
- return cands
-}
-
-func applyCandidateShares(cands []MoveCandidate, total uint32) {
- if total == 0 {
- return
- }
- inv := 1 / float64(total)
- for i := range cands {
- cands[i].Share = float64(cands[i].Visits) * inv
- }
-}
-
-func (e *Engine) pvLocked(maxLen int) []Move {
- if e.arena == nil {
- return nil
- }
- pv := make([]Move, 0, maxLen)
- node := e.root
- for len(pv) < maxLen {
- next, ok := e.bestPVChild(node)
- if !ok {
- break
- }
- pv = append(pv, e.arena.Get(next).Move)
- node = next
- }
- return pv
-}
-
-func (e *Engine) bestPVChild(node int) (int, bool) {
- n := e.arena.Get(node)
- if len(n.Children) == 0 {
- return 0, false
- }
- best := n.Children[0]
- maxV := e.arena.Get(best).Visits
- for _, cidx := range n.Children[1:] {
- if visits := e.arena.Get(cidx).Visits; visits > maxV {
- maxV = visits
- best = cidx
- }
- }
- return best, true
-}
-
-// RootPolicy returns visit-weighted policy over legal moves.
-func (e *Engine) RootPolicy(legal []Move) []float32 {
- pi := make([]float32, len(legal))
- e.mu.Lock()
- defer e.mu.Unlock()
- if e.arena == nil || len(e.arena.nodes) == 0 {
- return uniformPolicy32(len(legal))
- }
- root := e.arena.Get(e.root)
- total := rootVisitTotal(e.arena, root)
- if total == 0 {
- return uniformPolicy32(len(legal))
- }
- fillLegalPolicy(pi, legal, root, e.arena, total)
- return pi
-}
-
-func fillLegalPolicy(pi []float32, legal []Move, root *Node, arena *Arena, total uint32) {
- inv := 1 / float32(total)
- for _, cidx := range root.Children {
- c := arena.Get(cidx)
- for i, m := range legal {
- if movesEqual(c.Move, m) {
- pi[i] = float32(c.Visits) * inv
- break
- }
- }
- }
-}
-
-// RootPolicyPruned returns visit-weighted policy with low-visit moves zeroed (paper SE-4.3).
-func (e *Engine) RootPolicyPruned(legal []Move) []float32 {
- pi := e.RootPolicy(legal)
- e.mu.Lock()
- defer e.mu.Unlock()
- if e.arena == nil {
- return pi
- }
- root := e.arena.Get(e.root)
- if !rootHasStrongChild(e.arena, root) {
- return pi
- }
- return prunePolicy(pi, legal, root, e)
-}
-
-func rootHasStrongChild(a *Arena, root *Node) bool {
- for _, cidx := range root.Children {
- if a.Get(cidx).Visits >= minPolicyVisits {
- return true
- }
- }
- return false
-}
-
-func prunePolicy(pi []float32, legal []Move, root *Node, e *Engine) []float32 {
- sum := float32(0)
- for i, m := range legal {
- if e.rootChildVisitsLocked(root, m) < minPolicyVisits {
- pi[i] = 0
- }
- sum += pi[i]
- }
- if sum == 0 {
- return uniformPolicy32(len(legal))
- }
- inv := 1 / sum
- for i := range pi {
- pi[i] *= inv
- }
- return pi
-}
-
-// RootPolicyBoard returns visit-weighted policy over board indices (size² + pass).
-func (e *Engine) RootPolicyBoard(b *Board, legal []Move) []float32 {
- legalPi := e.RootPolicyPruned(legal)
- n := b.Size()*b.Size() + 1
- out := make([]float32, n)
- for i, m := range legal {
- idx := policyIndex(m, b.Size())
- if idx >= 0 && idx < n {
- out[idx] = legalPi[i]
- }
- }
- return out
-}
-
-func policyIndex(m Move, size int) int {
- if m.Pass {
- return size * size
- }
- return m.Point.Idx(size)
-}
-
-func (e *Engine) rootChildVisitsLocked(root *Node, m Move) uint32 {
- for _, cidx := range root.Children {
- c := e.arena.Get(cidx)
- if movesEqual(c.Move, m) {
- return c.Visits
- }
- }
- return 0
-}
diff --git a/cmd/gofer/mcts_test.go b/cmd/gofer/mcts_test.go
deleted file mode 100644
index a52aba8..0000000
--- a/cmd/gofer/mcts_test.go
+++ /dev/null
@@ -1,174 +0,0 @@
-package main
-
-import (
- "math"
- "testing"
- "time"
-)
-
-func treeNodes(eng *Engine) int {
- eng.mu.Lock()
- defer eng.mu.Unlock()
- if eng.arena == nil {
- return 0
- }
- return len(eng.arena.nodes)
-}
-
-func TestPUCTFormula(t *testing.T) {
- q, prior, pv := 0.5, 0.1, 100.0
- visits, c := uint32(10), 1.1
- got := q + c*prior*math.Sqrt(pv)/(1+float64(visits))
- want := 0.5 + 1.1*0.1*10/11
- if got < want-1e-9 || got > want+1e-9 {
- t.Fatalf("puct got %v want %v", got, want)
- }
-}
-
-func TestDeterministicPlayout(t *testing.T) {
- r := Chinese()
- cfg := DefaultConfig()
- cfg.Seed = 42
- cfg.Playouts = 6
- b := NewBoard(5, 6.5)
- m1 := NewEngine(r, Uniform{}, cfg).BestMove(b)
- m2 := NewEngine(r, Uniform{}, cfg).BestMove(b)
- if m1 != m2 {
- t.Fatalf("deterministic seed mismatch %v vs %v", m1, m2)
- }
-}
-
-func TestTTStoresAfterSearch(t *testing.T) {
- r := Chinese()
- cfg := DefaultConfig()
- cfg.Playouts = 20
- e := NewEngine(r, nil, cfg)
- b := NewBoard(5, 6.5)
- _ = e.BestMove(b)
- if _, ok := e.TT.Get(b.Hash()); !ok {
- t.Fatal("expected TT entry after search")
- }
-}
-
-func TestRootPolicySumsToOne(t *testing.T) {
- r := Chinese()
- cfg := DefaultConfig()
- cfg.Playouts = 15
- e := NewEngine(r, nil, cfg)
- b := NewBoard(5, 6.5)
- legal := r.LegalMoves(b)
- _ = e.BestMove(b)
- pi := e.RootPolicy(legal)
- var sum float32
- for _, p := range pi {
- sum += p
- }
- if sum < 0.99 || sum > 1.01 {
- t.Fatalf("policy sum %v", sum)
- }
-}
-
-func TestMCTSTreeReuse(t *testing.T) {
- r := Chinese()
- b := NewBoard(9, 6.5)
- cfg := DefaultConfig()
- cfg.Playouts = 30
- cfg.Workers = 1
- eng := NewEngine(r, Uniform{}, cfg)
- m1 := eng.BestMove(b)
- len1 := treeNodes(eng)
- if len1 == 0 {
- t.Fatal("expected nodes after search")
- }
- eng.BestMove(b)
- if treeNodes(eng) < len1 {
- t.Fatalf("tree shrank on reuse: %d -> %d", len1, treeNodes(eng))
- }
- r.Play(b, m1)
- eng.AdvanceTree(m1)
- if treeNodes(eng) == 0 {
- t.Fatal("tree cleared after advancing to best child")
- }
- eng.ResetArena()
- if treeNodes(eng) != 0 {
- t.Fatal("reset failed")
- }
-}
-
-func TestAnalyzeCandidates(t *testing.T) {
- r := Chinese()
- cfg := DefaultConfig()
- cfg.Playouts = 30
- e := NewEngine(r, Heuristic{}, cfg)
- b := NewBoard(5, 6.5)
- a := e.Analyze(b, 3)
- if a.Playouts <= 0 || len(a.Candidates) == 0 {
- t.Fatalf("analyze: playouts=%d cands=%d", a.Playouts, len(a.Candidates))
- }
-}
-
-func TestThinkTimeSearch(t *testing.T) {
- r := Chinese()
- cfg := DefaultConfig()
- cfg.ThinkTime = 50 * time.Millisecond
- e := NewEngine(r, Heuristic{}, cfg)
- b := NewBoard(5, 6.5)
- a := e.Analyze(b, 3)
- if a.Playouts <= 0 {
- t.Fatal("expected playouts during think window")
- }
-}
-
-func TestForcedRootPlayouts(t *testing.T) {
- r := Chinese()
- cfg := DefaultConfig()
- cfg.Playouts = 20
- cfg.ForcedRootPlayouts = 2
- cfg.Workers = 1
- e := NewEngine(r, Uniform{}, cfg)
- b := NewBoard(5, 6.5)
- legal := r.LegalMoves(b)
- _ = e.BestMove(b)
- e.mu.Lock()
- root := e.arena.Get(e.root)
- for _, cidx := range root.Children {
- c := e.arena.Get(cidx)
- if c.Visits < uint32(cfg.ForcedRootPlayouts) {
- t.Fatalf("child %+v visits %d < forced %d", c.Move, c.Visits, cfg.ForcedRootPlayouts)
- }
- }
- e.mu.Unlock()
- _ = legal
-}
-
-func TestRootPolicyPruned(t *testing.T) {
- r := Chinese()
- cfg := DefaultConfig()
- cfg.Playouts = 25
- cfg.ForcedRootPlayouts = 2
- e := NewEngine(r, Uniform{}, cfg)
- b := NewBoard(5, 6.5)
- legal := r.LegalMoves(b)
- _ = e.BestMove(b)
- pi := e.RootPolicyPruned(legal)
- var sum float32
- for _, p := range pi {
- sum += p
- }
- if sum < 0.99 || sum > 1.01 {
- t.Fatalf("pruned policy sum %v", sum)
- }
-}
-
-func TestOwnershipLabel(t *testing.T) {
- b := NewBoard(5, 6.5)
- r := Chinese()
- _ = r.Play(b, StoneMove(At(2, 2)))
- own := OwnershipLabel(b)
- if len(own) != 25 {
- t.Fatalf("len %d", len(own))
- }
- if own[2*5+2] != 1 {
- t.Fatalf("stone ownership %v", own[2*5+2])
- }
-}
diff --git a/cmd/gofer/move.go b/cmd/gofer/move.go
deleted file mode 100644
index 24a6af2..0000000
--- a/cmd/gofer/move.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package main
-
-// Move is a stone play or pass.
-type Move struct {
- Point Point
- Pass bool
-}
-
-// PassMove is the pass move sentinel.
-var PassMove = Move{Pass: true}
-
-// StoneMove returns a move at p.
-func StoneMove(p Point) Move {
- return Move{Point: p}
-}
-
-func movesEqual(a, b Move) bool {
- if a.Pass != b.Pass {
- return false
- }
- if a.Pass {
- return true
- }
- return a.Point == b.Point
-}
diff --git a/cmd/gofer/noise_test.go b/cmd/gofer/noise_test.go
deleted file mode 100644
index a94c464..0000000
--- a/cmd/gofer/noise_test.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package main
-
-import (
- "math"
- "math/rand"
- "testing"
-)
-
-func TestSampleDirichletIsSimplex(t *testing.T) {
- rng := rand.New(rand.NewSource(1))
- for _, n := range []int{2, 5, 82} {
- d := sampleDirichlet(n, dirichletAlpha, rng)
- if len(d) != n {
- t.Fatalf("len=%d want %d", len(d), n)
- }
- sum := 0.0
- for _, v := range d {
- if v < 0 || math.IsNaN(v) || math.IsInf(v, 0) {
- t.Fatalf("bad component %v", v)
- }
- sum += v
- }
- if math.Abs(sum-1) > 1e-9 {
- t.Fatalf("dirichlet sum=%v want 1", sum)
- }
- }
-}
-
-func TestBlendDirichletStaysOnSimplex(t *testing.T) {
- rng := rand.New(rand.NewSource(2))
- priors := uniformPriors(82)
- out := blendDirichlet(priors, rng)
- sum := 0.0
- for _, v := range out {
- if v < 0 {
- t.Fatalf("negative prior %v", v)
- }
- sum += v
- }
- if math.Abs(sum-1) > 1e-9 {
- t.Fatalf("blended sum=%v want 1", sum)
- }
-}
-
-func TestSampleGammaMeanApproxShape(t *testing.T) {
- rng := rand.New(rand.NewSource(3))
- const shape = 0.15
- const iters = 200000
- total := 0.0
- for i := 0; i < iters; i++ {
- g := sampleGamma(shape, rng)
- if g < 0 || math.IsNaN(g) {
- t.Fatalf("bad gamma %v", g)
- }
- total += g
- }
- mean := total / iters
- // E[Gamma(shape,1)] = shape; allow generous slack for a low-shape estimator.
- if math.Abs(mean-shape) > 0.03 {
- t.Fatalf("gamma mean=%v want ~%v", mean, shape)
- }
-}
diff --git a/cmd/gofer/onnx_integration_test.go b/cmd/gofer/onnx_integration_test.go
deleted file mode 100644
index d89b17f..0000000
--- a/cmd/gofer/onnx_integration_test.go
+++ /dev/null
@@ -1,27 +0,0 @@
-//go:build onnx_integration
-
-package main
-
-import (
- "net/http"
- "os"
- "testing"
- "time"
-)
-
-func TestSidecarLive(t *testing.T) {
- url := os.Getenv("GOFER_ONNX_URL")
- if url == "" {
- url = "http://127.0.0.1:8080"
- }
- client := &http.Client{Timeout: 2 * time.Second}
- if err := CheckSidecarHealth(url, client); err != nil {
- t.Skip("sidecar not running:", err)
- }
- b := NewBoard(9, 6.5)
- backend := SidecarBackend{URL: url, Client: client}
- out := backend.EvalBatch([]*Board{b})
- if len(out) != 1 || len(out[0].Policy) != 82 {
- t.Fatalf("unexpected result: %+v", out)
- }
-}
diff --git a/cmd/gofer/onnx_parity_test.go b/cmd/gofer/onnx_parity_test.go
deleted file mode 100644
index 82b4c37..0000000
--- a/cmd/gofer/onnx_parity_test.go
+++ /dev/null
@@ -1,199 +0,0 @@
-//go:build onnx
-
-package main
-
-import (
- "bufio"
- "encoding/json"
- "fmt"
- "math"
- "os"
- "path/filepath"
- "testing"
- "time"
-)
-
-const (
- parityValueTol = 1e-6
- parityPolicyTol = 1e-5
-)
-
-type parityRefRow struct {
- Index int `json:"index"`
- BoardHash uint64 `json:"board_hash"`
- Value float64 `json:"value"`
- Policy []float32 `json:"policy"`
-}
-
-type paritySampleRow struct {
- FeaturesSpatial []float32 `json:"features_spatial"`
- FeaturesGlobal []float32 `json:"features_global"`
- Type string `json:"type"`
-}
-
-func repoRoot(t *testing.T) string {
- t.Helper()
- wd, err := os.Getwd()
- if err != nil {
- t.Fatal(err)
- }
- // cmd/gofer -> repo root
- return filepath.Clean(filepath.Join(wd, "..", ".."))
-}
-
-func loadParitySamples(t *testing.T, path string, limit int) [][]float32 {
- t.Helper()
- f, err := os.Open(path)
- if err != nil {
- t.Fatal(err)
- }
- defer f.Close()
- var spatials [][]float32
- var globals [][]float32
- sc := bufio.NewScanner(f)
- for sc.Scan() {
- var row paritySampleRow
- if err := json.Unmarshal(sc.Bytes(), &row); err != nil {
- t.Fatal(err)
- }
- if row.Type == "header" {
- continue
- }
- if len(row.FeaturesSpatial) == 0 {
- continue
- }
- spatials = append(spatials, row.FeaturesSpatial)
- globals = append(globals, row.FeaturesGlobal)
- if len(spatials) >= limit {
- break
- }
- }
- if err := sc.Err(); err != nil {
- t.Fatal(err)
- }
- // zip into pairs for evalOne
- out := make([][]float32, 0, len(spatials)*2)
- for i := range spatials {
- out = append(out, spatials[i], globals[i])
- }
- return out
-}
-
-func loadParityRef(t *testing.T, path string) []parityRefRow {
- t.Helper()
- f, err := os.Open(path)
- if err != nil {
- t.Fatal(err)
- }
- defer f.Close()
- var rows []parityRefRow
- sc := bufio.NewScanner(f)
- for sc.Scan() {
- var row parityRefRow
- if err := json.Unmarshal(sc.Bytes(), &row); err != nil {
- t.Fatal(err)
- }
- rows = append(rows, row)
- }
- if err := sc.Err(); err != nil {
- t.Fatal(err)
- }
- return rows
-}
-
-func TestONNXParityPythonRef(t *testing.T) {
- root := repoRoot(t)
- model := filepath.Join(root, "models", "gofer-9x9-best.onnx")
- if _, err := os.Stat(model); err != nil {
- model = filepath.Join(root, "models", "gofer-9x9-bootstrap.onnx")
- }
- refPath := os.Getenv("GOFER_PARITY_REF")
- if refPath == "" {
- refPath = filepath.Join(root, ".tectonix", "reports", "parity-ref.jsonl")
- }
- samplesPath := os.Getenv("GOFER_PARITY_SAMPLES")
- if samplesPath == "" {
- samplesPath = filepath.Join(root, "training", "data", "samples.jsonl")
- }
- limit := 500
- if v := os.Getenv("GOFER_PARITY_LIMIT"); v != "" {
- if _, err := fmt.Sscanf(v, "%d", &limit); err != nil {
- t.Fatalf("bad GOFER_PARITY_LIMIT: %v", err)
- }
- }
-
- if _, err := os.Stat(refPath); err != nil {
- t.Fatalf("missing reference %s — run: scripts/parity-onnx.sh or python training/parity_harness.py", refPath)
- }
- refs := loadParityRef(t, refPath)
- if len(refs) < 100 {
- t.Fatalf("reference too small: %d rows", len(refs))
- }
-
- eval, err := newORTEval(model)
- if err != nil {
- t.Fatal(err)
- }
- defer eval.Close()
-
- pairs := loadParitySamples(t, samplesPath, len(refs))
- if len(pairs) < len(refs)*2 {
- t.Fatalf("samples %d pairs, ref %d rows", len(pairs)/2, len(refs))
- }
-
- var pass, fail int
- var totalGo time.Duration
- var worstValue, worstPolicy float64
- var worstIdx int
- var worstKind string
-
- for _, ref := range refs {
- i := ref.Index
- spatial := pairs[i*2]
- globals := pairs[i*2+1]
- t0 := time.Now()
- policy, value, err := eval.evalOne(spatial, globals)
- totalGo += time.Since(t0)
- if err != nil {
- t.Fatalf("position %d: %v", i, err)
- }
- vDiff := math.Abs(float64(value) - ref.Value)
- maxPDiff := float64(0)
- if len(policy) != len(ref.Policy) {
- fail++
- t.Errorf("position %d: policy len %d != ref %d", i, len(policy), len(ref.Policy))
- continue
- }
- for j := range policy {
- d := math.Abs(float64(policy[j] - ref.Policy[j]))
- if d > maxPDiff {
- maxPDiff = d
- }
- }
- ok := vDiff < parityValueTol && maxPDiff < parityPolicyTol
- if ok {
- pass++
- } else {
- fail++
- if vDiff >= parityValueTol && vDiff > worstValue {
- worstValue = vDiff
- worstIdx = i
- worstKind = "value"
- }
- if maxPDiff >= parityPolicyTol && maxPDiff > worstPolicy {
- worstPolicy = maxPDiff
- worstIdx = i
- worstKind = "policy"
- }
- t.Errorf("position %d FAIL value_diff=%.3e (tol %.0e) policy_max_diff=%.3e (tol %.0e)",
- i, vDiff, parityValueTol, maxPDiff, parityPolicyTol)
- }
- }
-
- msPer := float64(totalGo.Milliseconds()) / float64(len(refs))
- t.Logf("go_ort positions=%d pass=%d fail=%d ms_per_pos=%.4f worst_idx=%d worst_kind=%s worst_value_diff=%.3e worst_policy_diff=%.3e",
- len(refs), pass, fail, msPer, worstIdx, worstKind, worstValue, worstPolicy)
- if fail > 0 {
- t.Fatalf("%d/%d positions failed parity", fail, len(refs))
- }
-}
diff --git a/cmd/gofer/onnx_sidecar.go b/cmd/gofer/onnx_sidecar.go
deleted file mode 100644
index bba2d1d..0000000
--- a/cmd/gofer/onnx_sidecar.go
+++ /dev/null
@@ -1,169 +0,0 @@
-package main
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "sync/atomic"
-)
-
-const sidecarEvalPath = "/v1/eval"
-
-// SidecarStats counts inference outcomes (for latency reports).
-type SidecarStats struct {
- Requests atomic.Uint64
- Fallbacks atomic.Uint64
- Timeouts atomic.Uint64
-}
-
-// SidecarBackend calls a Python ONNX Runtime HTTP sidecar.
-type SidecarBackend struct {
- URL string
- Client *http.Client
- Fallback Evaluator
- Stats *SidecarStats
-}
-
-func (s SidecarBackend) fallback() Evaluator {
- if s.Fallback != nil {
- return s.Fallback
- }
- return Heuristic{}
-}
-
-func (s SidecarBackend) httpClient() *http.Client {
- if s.Client != nil {
- return s.Client
- }
- return http.DefaultClient
-}
-
-func (s SidecarBackend) stats() *SidecarStats {
- if s.Stats != nil {
- return s.Stats
- }
- return &SidecarStats{}
-}
-
-type sidecarEvalReq struct {
- SchemaVersion int `json:"schema_version"`
- BatchSize int `json:"batch_size"`
- Spatial [][]float32 `json:"spatial"`
- Globals [][]float32 `json:"globals"`
-}
-
-type sidecarEvalResp struct {
- Results []struct {
- Value float64 `json:"value"`
- Policy []float32 `json:"policy"`
- } `json:"results"`
-}
-
-func makeSidecarRequest(boards []*Board) ([]byte, error) {
- reqBody := sidecarEvalReq{
- SchemaVersion: FeatureSchemaVersion,
- BatchSize: len(boards),
- Spatial: make([][]float32, len(boards)),
- Globals: make([][]float32, len(boards)),
- }
- for i, b := range boards {
- sp, gl := BuildFeaturesV2(b)
- reqBody.Spatial[i] = sp
- reqBody.Globals[i] = gl
- }
- return json.Marshal(reqBody)
-}
-
-func fallbackBatch(out []Result, boards []*Board, fb Evaluator) []Result {
- for i, b := range boards {
- out[i] = fb.Evaluate(b)
- }
- return out
-}
-
-func (s SidecarBackend) postEval(data []byte) ([]byte, error) {
- url := s.URL + sidecarEvalPath
- httpReq, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
- if err != nil {
- return nil, err
- }
- httpReq.Header.Set("Content-Type", "application/json")
-
- resp, err := s.httpClient().Do(httpReq)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil || resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("sidecar eval: status=%d err=%w", resp.StatusCode, err)
- }
- return body, nil
-}
-
-func decodeSidecarResults(out []Result, boards []*Board, body []byte, fb Evaluator, st *SidecarStats) []Result {
- var parsed sidecarEvalResp
- if err := json.Unmarshal(body, &parsed); err != nil {
- st.Fallbacks.Add(uint64(len(boards)))
- return fallbackBatch(out, boards, fb)
- }
-
- for i, b := range boards {
- want := b.Size()*b.Size() + 1
- if i >= len(parsed.Results) {
- st.Fallbacks.Add(1)
- out[i] = fb.Evaluate(b)
- continue
- }
- r := parsed.Results[i]
- if len(r.Policy) != want {
- st.Fallbacks.Add(1)
- out[i] = fb.Evaluate(b)
- continue
- }
- out[i] = Result{Value: r.Value, Policy: r.Policy, HasValue: true}
- }
- return out
-}
-
-// EvalBatch implements EvalBackend.
-func (s SidecarBackend) EvalBatch(boards []*Board) []Result {
- out := make([]Result, len(boards))
- if len(boards) == 0 {
- return out
- }
- fb := s.fallback()
- st := s.stats()
- st.Requests.Add(uint64(len(boards)))
-
- data, err := makeSidecarRequest(boards)
- if err != nil {
- st.Fallbacks.Add(uint64(len(boards)))
- return fallbackBatch(out, boards, fb)
- }
- body, err := s.postEval(data)
- if err != nil {
- st.Fallbacks.Add(uint64(len(boards)))
- return fallbackBatch(out, boards, fb)
- }
- return decodeSidecarResults(out, boards, body, fb, st)
-}
-
-// CheckSidecarHealth GETs /health and returns an error if unreachable.
-func CheckSidecarHealth(url string, client *http.Client) error {
- if client == nil {
- client = http.DefaultClient
- }
- resp, err := client.Get(url + "/health")
- if err != nil {
- return err
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return fmt.Errorf("sidecar health: HTTP %d", resp.StatusCode)
- }
- return nil
-}
diff --git a/cmd/gofer/onnx_sidecar_test.go b/cmd/gofer/onnx_sidecar_test.go
deleted file mode 100644
index 0ea2d62..0000000
--- a/cmd/gofer/onnx_sidecar_test.go
+++ /dev/null
@@ -1,145 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "sync"
- "testing"
- "time"
-)
-
-func TestSidecarBackendEvalBatch(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/v1/eval" {
- http.NotFound(w, r)
- return
- }
- var req sidecarEvalReq
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- http.Error(w, err.Error(), 400)
- return
- }
- results := make([]map[string]any, req.BatchSize)
- for i := 0; i < req.BatchSize; i++ {
- policy := make([]float32, 82)
- for j := range policy {
- policy[j] = 1.0 / 82
- }
- results[i] = map[string]any{"value": 0.25, "policy": policy}
- }
- _ = json.NewEncoder(w).Encode(map[string]any{"results": results})
- }))
- defer srv.Close()
-
- b := NewBoard(9, 6.5)
- backend := SidecarBackend{URL: srv.URL, Client: srv.Client()}
- out := backend.EvalBatch([]*Board{b})
- if len(out) != 1 {
- t.Fatalf("len %d", len(out))
- }
- if out[0].Value != 0.25 {
- t.Fatalf("value %v", out[0].Value)
- }
- if len(out[0].Policy) != 82 {
- t.Fatalf("policy len %d", len(out[0].Policy))
- }
-}
-
-func TestSidecarBackendShapeError(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _ = json.NewEncoder(w).Encode(map[string]any{
- "results": []map[string]any{{"value": 0.0, "policy": []float32{1, 2, 3}}},
- })
- }))
- defer srv.Close()
-
- b := NewBoard(9, 6.5)
- backend := SidecarBackend{URL: srv.URL, Client: srv.Client()}
- out := backend.EvalBatch([]*Board{b})
- if len(out[0].Policy) != 82 {
- // fallback heuristic policy
- h := Heuristic{}.Evaluate(b)
- if len(out[0].Policy) != len(h.Policy) {
- t.Fatalf("expected heuristic fallback policy len %d got %d", len(h.Policy), len(out[0].Policy))
- }
- }
-}
-
-func TestSidecarBackendHTTPError(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- http.Error(w, "fail", 500)
- }))
- defer srv.Close()
-
- b := NewBoard(9, 6.5)
- backend := SidecarBackend{URL: srv.URL, Client: srv.Client()}
- out := backend.EvalBatch([]*Board{b})
- if len(out) != 1 || len(out[0].Policy) == 0 {
- t.Fatal("expected heuristic fallback")
- }
-}
-
-func TestSidecarBackendTimeout(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- time.Sleep(50 * time.Millisecond)
- w.WriteHeader(200)
- }))
- defer srv.Close()
-
- b := NewBoard(9, 6.5)
- backend := SidecarBackend{
- URL: srv.URL,
- Client: &http.Client{Timeout: 5 * time.Millisecond},
- }
- out := backend.EvalBatch([]*Board{b})
- if len(out) != 1 {
- t.Fatalf("len %d", len(out))
- }
-}
-
-func TestBatchedEvaluatorSidecarParallel(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- time.Sleep(100 * time.Microsecond)
- var req sidecarEvalReq
- _ = json.NewDecoder(r.Body).Decode(&req)
- results := make([]map[string]any, req.BatchSize)
- for i := 0; i < req.BatchSize; i++ {
- policy := make([]float32, 82)
- for j := range policy {
- policy[j] = 1.0 / 82
- }
- results[i] = map[string]any{"value": 0.0, "policy": policy}
- }
- _ = json.NewEncoder(w).Encode(map[string]any{"results": results})
- }))
- defer srv.Close()
-
- ev := NewBatchedEvaluatorWithTimeout(
- SidecarBackend{URL: srv.URL, Client: srv.Client()},
- Heuristic{},
- 4,
- 2*time.Millisecond,
- 20*time.Millisecond,
- )
- defer ev.Close()
- board := NewBoard(9, 6.5)
- var wg sync.WaitGroup
- for i := 0; i < 16; i++ {
- wg.Add(1)
- go func() {
- defer wg.Done()
- _ = ev.Evaluate(board)
- }()
- }
- done := make(chan struct{})
- go func() {
- wg.Wait()
- close(done)
- }()
- select {
- case <-done:
- case <-time.After(5 * time.Second):
- t.Fatal("deadlock in parallel sidecar batched eval")
- }
-}
diff --git a/cmd/gofer/ort_backend.go b/cmd/gofer/ort_backend.go
deleted file mode 100644
index 7aad7ba..0000000
--- a/cmd/gofer/ort_backend.go
+++ /dev/null
@@ -1,51 +0,0 @@
-//go:build onnx
-
-package main
-
-// ORTBackend evaluates positions in-process via ONNX Runtime (see ort_infer.go).
-type ORTBackend struct {
- session *ortSession
- Fallback Evaluator
-}
-
-func newORTBackend(modelPath string, fallback Evaluator) (*ORTBackend, error) {
- sess, err := newORTSession(modelPath)
- if err != nil {
- return nil, err
- }
- if fallback == nil {
- fallback = Heuristic{}
- }
- return &ORTBackend{session: sess, Fallback: fallback}, nil
-}
-
-// Close releases the ORT session (process-wide ORT env stays up).
-func (o *ORTBackend) Close() {
- if o.session != nil {
- o.session.Close()
- o.session = nil
- }
-}
-
-// EvalBatch implements EvalBackend.
-func (o ORTBackend) EvalBatch(boards []*Board) []Result {
- out := make([]Result, len(boards))
- if len(boards) == 0 {
- return out
- }
- results, err := o.session.evalBatch(boards)
- if err != nil {
- for i, b := range boards {
- out[i] = o.Fallback.Evaluate(b)
- }
- return out
- }
- for i := range boards {
- if i < len(results) && len(results[i].Policy) == boards[i].Size()*boards[i].Size()+1 {
- out[i] = results[i]
- } else {
- out[i] = o.Fallback.Evaluate(boards[i])
- }
- }
- return out
-}
diff --git a/cmd/gofer/ort_backend_stub.go b/cmd/gofer/ort_backend_stub.go
deleted file mode 100644
index 676e348..0000000
--- a/cmd/gofer/ort_backend_stub.go
+++ /dev/null
@@ -1,21 +0,0 @@
-//go:build !onnx
-
-package main
-
-import "fmt"
-
-var errInprocessUnavailable = fmt.Errorf("rebuild with -tags=onnx and CGO_ENABLED=1 for in-process ONNX")
-
-func newORTBackend(modelPath string, fallback Evaluator) (*ORTBackend, error) {
- return nil, errInprocessUnavailable
-}
-
-// ORTBackend is a stub when built without the onnx tag.
-type ORTBackend struct{}
-
-func (o *ORTBackend) Close() {}
-
-func (o ORTBackend) EvalBatch(boards []*Board) []Result {
- out := make([]Result, len(boards))
- return out
-}
diff --git a/cmd/gofer/ort_infer.go b/cmd/gofer/ort_infer.go
deleted file mode 100644
index 3aa870d..0000000
--- a/cmd/gofer/ort_infer.go
+++ /dev/null
@@ -1,185 +0,0 @@
-//go:build onnx
-
-package main
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "runtime"
- "sync"
-
- ort "github.com/yalue/onnxruntime_go"
-)
-
-const (
- ortBoardSize = 9
- ortPlanes = 8
- ortGlobals = 4
- ortPolicySize = ortBoardSize*ortBoardSize + 1
-)
-
-var (
- ortEnvOnce sync.Once
- ortEnvInitErr error
-)
-
-func resolveORTSharedLibrary() (string, error) {
- if p := os.Getenv("ONNXRUNTIME_SHARED_LIBRARY_PATH"); p != "" {
- return p, nil
- }
- if runtime.GOOS == "windows" && runtime.GOARCH == "amd64" {
- modRoot := os.Getenv("ONNXRUNTIME_GO_MODROOT")
- if modRoot == "" {
- return "", fmt.Errorf("set ONNXRUNTIME_SHARED_LIBRARY_PATH or ONNXRUNTIME_GO_MODROOT")
- }
- return filepath.Join(modRoot, "test_data", "onnxruntime.dll"), nil
- }
- return "", fmt.Errorf("set ONNXRUNTIME_SHARED_LIBRARY_PATH to libonnxruntime.so.1.26.0")
-}
-
-func ensureORTEnv() error {
- ortEnvOnce.Do(func() {
- lib, err := resolveORTSharedLibrary()
- if err != nil {
- ortEnvInitErr = err
- return
- }
- ort.SetSharedLibraryPath(lib)
- ortEnvInitErr = ort.InitializeEnvironment()
- })
- return ortEnvInitErr
-}
-
-// ortSession holds one loaded ONNX model session.
-type ortSession struct {
- session *ort.DynamicAdvancedSession
-}
-
-func newORTSession(modelPath string) (*ortSession, error) {
- if err := ensureORTEnv(); err != nil {
- return nil, fmt.Errorf("ort env: %w", err)
- }
- opts, err := ort.NewSessionOptions()
- if err != nil {
- return nil, fmt.Errorf("session options: %w", err)
- }
- defer opts.Destroy()
- if err := opts.SetIntraOpNumThreads(1); err != nil {
- return nil, err
- }
- if err := opts.SetInterOpNumThreads(1); err != nil {
- return nil, err
- }
- sess, err := ort.NewDynamicAdvancedSession(
- modelPath,
- []string{"spatial_input", "global_input"},
- []string{"policy_logits", "value"},
- opts,
- )
- if err != nil {
- return nil, fmt.Errorf("new session: %w", err)
- }
- return &ortSession{session: sess}, nil
-}
-
-func (s *ortSession) Close() {
- if s.session != nil {
- s.session.Destroy()
- s.session = nil
- }
-}
-
-func (s *ortSession) evalBatch(boards []*Board) ([]Result, error) {
- batch := len(boards)
- if batch == 0 {
- return nil, nil
- }
- spatialFlat := make([]float32, batch*ortPlanes*ortBoardSize*ortBoardSize)
- globalFlat := make([]float32, batch*ortGlobals)
- planeN := ortPlanes * ortBoardSize * ortBoardSize
- for i, b := range boards {
- if b.Size() != ortBoardSize {
- return nil, fmt.Errorf("board size %d, model expects %d", b.Size(), ortBoardSize)
- }
- sp, gl := BuildFeaturesV2(b)
- copy(spatialFlat[i*planeN:(i+1)*planeN], sp)
- copy(globalFlat[i*ortGlobals:(i+1)*ortGlobals], gl)
- }
- spIn, err := ort.NewTensor(ort.NewShape(int64(batch), ortPlanes, ortBoardSize, ortBoardSize), spatialFlat)
- if err != nil {
- return nil, err
- }
- defer spIn.Destroy()
- glIn, err := ort.NewTensor(ort.NewShape(int64(batch), ortGlobals), globalFlat)
- if err != nil {
- return nil, err
- }
- defer glIn.Destroy()
- logitsOut, err := ort.NewEmptyTensor[float32](ort.NewShape(int64(batch), ortPolicySize))
- if err != nil {
- return nil, err
- }
- defer logitsOut.Destroy()
- valueOut, err := ort.NewEmptyTensor[float32](ort.NewShape(int64(batch)))
- if err != nil {
- return nil, err
- }
- defer valueOut.Destroy()
- if err := s.session.Run([]ort.Value{spIn, glIn}, []ort.Value{logitsOut, valueOut}); err != nil {
- return nil, err
- }
- logits := logitsOut.GetData()
- values := valueOut.GetData()
- out := make([]Result, batch)
- for i := 0; i < batch; i++ {
- row := logits[i*ortPolicySize : (i+1)*ortPolicySize]
- out[i] = Result{
- Value: float64(values[i]),
- Policy: softmaxPolicy(row),
- HasValue: true,
- }
- }
- return out, nil
-}
-
-// evalOne runs a single position (parity harness convenience).
-func (s *ortSession) evalOne(spatial, globals []float32) (policy []float32, value float32, err error) {
- if len(spatial) != ortPlanes*ortBoardSize*ortBoardSize {
- return nil, 0, fmt.Errorf("spatial len %d want %d", len(spatial), ortPlanes*ortBoardSize*ortBoardSize)
- }
- if len(globals) != ortGlobals {
- return nil, 0, fmt.Errorf("globals len %d want %d", len(globals), ortGlobals)
- }
- spIn, err := ort.NewTensor(ort.NewShape(1, ortPlanes, ortBoardSize, ortBoardSize), spatial)
- if err != nil {
- return nil, 0, err
- }
- defer spIn.Destroy()
- glIn, err := ort.NewTensor(ort.NewShape(1, ortGlobals), globals)
- if err != nil {
- return nil, 0, err
- }
- defer glIn.Destroy()
- logitsOut, err := ort.NewEmptyTensor[float32](ort.NewShape(1, ortPolicySize))
- if err != nil {
- return nil, 0, err
- }
- defer logitsOut.Destroy()
- valueOut, err := ort.NewEmptyTensor[float32](ort.NewShape(1))
- if err != nil {
- return nil, 0, err
- }
- defer valueOut.Destroy()
- if err := s.session.Run([]ort.Value{spIn, glIn}, []ort.Value{logitsOut, valueOut}); err != nil {
- return nil, 0, err
- }
- logits := logitsOut.GetData()
- value = valueOut.GetData()[0]
- return softmaxPolicy(logits), value, nil
-}
-
-// newORTEval opens a session for parity tests (alias).
-func newORTEval(modelPath string) (*ortSession, error) {
- return newORTSession(modelPath)
-}
diff --git a/cmd/gofer/ort_softmax.go b/cmd/gofer/ort_softmax.go
deleted file mode 100644
index 0123c65..0000000
--- a/cmd/gofer/ort_softmax.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package main
-
-import "math"
-
-// softmaxPolicy mirrors training/inference_server.py softmax on policy logits.
-func softmaxPolicy(logits []float32) []float32 {
- if len(logits) == 0 {
- return nil
- }
- maxv := logits[0]
- for _, v := range logits[1:] {
- if v > maxv {
- maxv = v
- }
- }
- out := make([]float32, len(logits))
- var sum float64
- for i, v := range logits {
- e := math.Exp(float64(v - maxv))
- out[i] = float32(e)
- sum += e
- }
- inv := float32(1.0 / sum)
- for i := range out {
- out[i] *= inv
- }
- return out
-}
diff --git a/cmd/gofer/ort_softmax_test.go b/cmd/gofer/ort_softmax_test.go
deleted file mode 100644
index 90b022a..0000000
--- a/cmd/gofer/ort_softmax_test.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package main
-
-import (
- "math"
- "testing"
-)
-
-func TestSoftmaxPolicyMatchesPython(t *testing.T) {
- // Same logits as numpy reference: x - max(x); exp; normalize
- logits := []float32{1.0, 2.0, 0.5, -1.0}
- got := softmaxPolicy(logits)
- var sum float64
- for _, p := range got {
- sum += float64(p)
- }
- if math.Abs(sum-1.0) > 1e-6 {
- t.Fatalf("sum=%v", sum)
- }
- if len(got) != len(logits) {
- t.Fatal("length mismatch")
- }
-}
diff --git a/cmd/gofer/point.go b/cmd/gofer/point.go
deleted file mode 100644
index 80c1db9..0000000
--- a/cmd/gofer/point.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package main
-
-import (
- "fmt"
- "strconv"
- "strings"
-)
-
-// Point is a board intersection.
-type Point struct {
- X, Y int
-}
-
-// Idx returns linear index or -1 if off
-func (p Point) Idx(size int) int {
- if p.X < 0 || p.Y < 0 || p.X >= size || p.Y >= size {
- return -1
- }
- return p.Y*size + p.X
-}
-
-// At builds a Point from coordinates.
-func At(x, y int) Point {
- return Point{X: x, Y: y}
-}
-
-// PointFromIdx converts linear index to Point.
-func PointFromIdx(size, idx int) Point {
- return Point{X: idx % size, Y: idx / size}
-}
-
-func parseGTPVertex(v string, size int) (Move, error) {
- if strings.ToLower(v) == "pass" {
- return PassMove, nil
- }
- v = strings.ToUpper(strings.TrimSpace(v))
- if len(v) < 2 {
- return Move{}, fmt.Errorf("invalid vertex")
- }
- x := int(v[0] - 'A')
- if v[0] >= 'I' {
- x--
- }
- row, err := strconv.Atoi(v[1:])
- if err != nil || row < 1 || row > size {
- return Move{}, fmt.Errorf("invalid vertex")
- }
- y := size - row
- if x < 0 || y < 0 || x >= size || y >= size {
- return Move{}, fmt.Errorf("invalid vertex")
- }
- return StoneMove(At(x, y)), nil
-}
-
-func moveToGTPVertex(m Move, size int) string {
- if m.Pass {
- return "pass"
- }
- col := 'A' + m.Point.X
- if col >= 'I' {
- col++
- }
- row := size - m.Point.Y
- return fmt.Sprintf("%c%d", col, row)
-}
diff --git a/cmd/gofer/rules.go b/cmd/gofer/rules.go
deleted file mode 100644
index 446fefa..0000000
--- a/cmd/gofer/rules.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package main
-
-// Ruleset applies rule-specific legality, scoring, and move application.
-type Ruleset interface {
- LegalMoves(b *Board) []Move
- Play(b *Board, m Move) bool
- Score(b *Board) (black, white float64)
-}
-
-// Chinese returns the v1 Chinese rules implementation.
-func Chinese() Ruleset {
- return &chineseRules{}
-}
-
-// TrompTaylor returns Tromp-Taylor rules with positional superko.
-func TrompTaylor() Ruleset {
- return newTrompRules()
-}
diff --git a/cmd/gofer/sample.go b/cmd/gofer/sample.go
deleted file mode 100644
index 8600f7c..0000000
--- a/cmd/gofer/sample.go
+++ /dev/null
@@ -1,87 +0,0 @@
-package main
-
-import (
- "bufio"
- "encoding/json"
- "os"
- "runtime/debug"
-)
-
-const SampleSchemaVersion = 1
-
-// Sample is one self-play training record.
-type Sample struct {
- BoardHash uint64 `json:"board_hash"`
- MoveNum int `json:"move_num"`
- Policy []float32 `json:"policy"`
- PolicyOpp []float32 `json:"policy_opp,omitempty"`
- FeaturesSpatial []float32 `json:"features_spatial,omitempty"`
- FeaturesGlobal []float32 `json:"features_global,omitempty"`
- ToPlay Color `json:"to_play"`
- Value float32 `json:"value"`
- Komi float64 `json:"komi,omitempty"`
- Ownership []float32 `json:"ownership,omitempty"`
- FullSearch bool `json:"full_search,omitempty"`
- ScorePDF []float64 `json:"score_pdf,omitempty"`
- ScoreCDF []float64 `json:"score_cdf,omitempty"`
-}
-
-// SampleExport wraps training rows with schema metadata.
-type SampleExport struct {
- SchemaVersion int `json:"schema_version"`
- GitCommit string `json:"git_commit,omitempty"`
- Samples []Sample `json:"samples"`
-}
-
-func buildInfoVersion() string {
- if info, ok := debug.ReadBuildInfo(); ok {
- return info.Main.Version
- }
- return ""
-}
-
-// WriteSampleJSONL writes one JSON object per line with a header line.
-func WriteSampleJSONL(path string, samples []Sample) error {
- f, err := os.Create(path)
- if err != nil {
- return err
- }
- defer f.Close()
- w := bufio.NewWriter(f)
- header, err := json.Marshal(map[string]any{
- "schema_version": SampleSchemaVersion,
- "git_commit": buildInfoVersion(),
- "type": "header",
- })
- if err != nil {
- return err
- }
- if _, err := w.Write(header); err != nil {
- return err
- }
- if _, err := w.WriteString("\n"); err != nil {
- return err
- }
- for _, s := range samples {
- line, err := json.Marshal(s)
- if err != nil {
- return err
- }
- if _, err := w.Write(line); err != nil {
- return err
- }
- if _, err := w.WriteString("\n"); err != nil {
- return err
- }
- }
- return w.Flush()
-}
-
-// MarshalSampleExport returns pretty JSON with schema wrapper.
-func MarshalSampleExport(samples []Sample) ([]byte, error) {
- return json.MarshalIndent(SampleExport{
- SchemaVersion: SampleSchemaVersion,
- GitCommit: buildInfoVersion(),
- Samples: samples,
- }, "", " ")
-}
diff --git a/cmd/gofer/score_symmetry_test.go b/cmd/gofer/score_symmetry_test.go
deleted file mode 100644
index 898854b..0000000
--- a/cmd/gofer/score_symmetry_test.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package main
-
-import "testing"
-
-// Mirror all stones on the board; scores should swap (before komi, which is symmetric on white).
-func TestChineseScoreMirrorSymmetry(t *testing.T) {
- r := Chinese()
- size := 9
- // Black L-corner at (0,0); white mirror L at (8,8). Position is mirror-symmetric
- // with colors swapped, so Score(mirror) should exchange black and white area.
- build := func() *Board {
- b := NewBoard(size, 0)
- for _, c := range [][2]int{{0, 0}, {1, 0}, {0, 1}} {
- b.SetStoneIndex(c[1]*size+c[0], Black)
- }
- for _, c := range [][2]int{{8, 8}, {7, 8}, {8, 7}} {
- b.SetStoneIndex(c[1]*size+c[0], White)
- }
- return b
- }
- mirrorBoard := func(b *Board) *Board {
- m := NewBoard(size, 0)
- for y := 0; y < size; y++ {
- for x := 0; x < size; x++ {
- s := b.StoneAt(At(x, y))
- if s == Empty {
- continue
- }
- mx, my := size-1-x, size-1-y
- m.SetStoneIndex(my*size+mx, s)
- }
- }
- return m
- }
- orig := build()
- mir := mirrorBoard(orig)
- bl, wl := r.Score(orig)
- mbl, mwl := r.Score(mir)
- if bl != mwl || wl != mbl {
- t.Fatalf("mirror should swap area scores: orig B=%.0f W=%.0f mirrored B=%.0f W=%.0f", bl, wl, mbl, mwl)
- }
-}
-
-// Enclosed territories: only one color touches each empty region.
-func TestChineseScoreEnclosedTerritories(t *testing.T) {
- r := Chinese()
- b := NewBoard(9, 0)
- // Black surrounds center 3x3 at (3..5, 3..5) with a gap at (4,4) for one white dead stone.
- for y := 3; y <= 5; y++ {
- for x := 3; x <= 5; x++ {
- if x == 4 && y == 4 {
- b.SetStoneIndex(y*9+x, White)
- continue
- }
- b.SetStoneIndex(y*9+x, Black)
- }
- }
- // Outside liberty so enclosure is meaningful.
- b.SetStoneIndex(0, Black)
- bl, wl, neutral, _ := chineseAreaDecomposition(b)
- if neutral != 0 {
- t.Fatalf("expected no neutral in single-color enclosure, neutral=%d", neutral)
- }
- // 8 black stones in ring + 1 white inside + 72 outside empty touches black via (0,0).
- if bl < 8 || wl != 1 {
- t.Fatalf("want black>=8 white=1 (dead stone counts for owner) got black=%d white=%d", bl, wl)
- }
- _, wScore := r.Score(b)
- if wScore != 1 {
- t.Fatalf("komi=0 white score should equal white area=1 got %v", wScore)
- }
- _ = bl
-}
diff --git a/cmd/gofer/scoring.go b/cmd/gofer/scoring.go
deleted file mode 100644
index 5175c64..0000000
--- a/cmd/gofer/scoring.go
+++ /dev/null
@@ -1,84 +0,0 @@
-package main
-
-// OwnershipLabel returns per-intersection training labels: +1 black, -1 white, 0 neutral/seki.
-// Area-flood ownership labels; full Benson pass-alive not implemented.
-// Upgrade: Benson pass-alive stone marking before territory flood.
-func OwnershipLabel(b *Board) []float32 {
- n := b.Size() * b.Size()
- out := make([]float32, n)
- for i := 0; i < n; i++ {
- out[i] = ownershipValue(b.AtIndex(i))
- }
- seen := make([]bool, n)
- for i := 0; i < n; i++ {
- if b.AtIndex(i) != Empty || seen[i] {
- continue
- }
- indices := emptyRegionIndices(b, i)
- for _, idx := range indices {
- seen[idx] = true
- }
- fillRegion(out, indices, regionOwner(b, indices))
- }
- return out
-}
-
-func ownershipValue(c Color) float32 {
- switch c {
- case Black:
- return 1
- case White:
- return -1
- default:
- return 0
- }
-}
-
-func regionOwner(b *Board, indices []int) float32 {
- touchB, touchW := false, false
- for _, idx := range indices {
- b.forEachNeighbor(idx, func(nb int) {
- switch b.AtIndex(nb) {
- case Black:
- touchB = true
- case White:
- touchW = true
- }
- })
- }
- if touchB && !touchW {
- return 1
- }
- if touchW && !touchB {
- return -1
- }
- return 0
-}
-
-func fillRegion(out []float32, indices []int, val float32) {
- for _, idx := range indices {
- out[idx] = val
- }
-}
-
-func emptyRegionIndices(b *Board, start int) []int {
- n := b.Size() * b.Size()
- seen := make([]bool, n)
- var out []int
- stack := []int{start}
- for len(stack) > 0 {
- i := stack[len(stack)-1]
- stack = stack[:len(stack)-1]
- if seen[i] || b.AtIndex(i) != Empty {
- continue
- }
- seen[i] = true
- out = append(out, i)
- b.forEachNeighbor(i, func(nb int) {
- if b.AtIndex(nb) == Empty && !seen[nb] {
- stack = append(stack, nb)
- }
- })
- }
- return out
-}
diff --git a/cmd/gofer/selfplay.go b/cmd/gofer/selfplay.go
deleted file mode 100644
index 7be602c..0000000
--- a/cmd/gofer/selfplay.go
+++ /dev/null
@@ -1,302 +0,0 @@
-package main
-
-import (
- "math/rand"
- "strings"
- "sync"
-)
-
-// SelfplayConfig holds self-play parameters.
-type SelfplayConfig struct {
- Games int
- BoardSize int
- Komi float64
- Playouts int
- FastPlayouts int
- FullPlayouts int
- CapRandomizeP float64
- Seed int64
- RulesRandomize bool
- FullOnlyExport bool // export only full-search positions (paper training set)
- EvalMode string // heuristic, onnx, mix
- ONNXFraction float64 // used by mix mode (odd/even when 0.5 default)
- Parallel int // concurrent games; >1 lets the batched evaluator fill real batches
- TemperatureMoves int // opening plies sampled ~ visits (>0 = enabled) for game diversity
-}
-
-// DefaultSelfplayConfig returns reasonable defaults.
-func DefaultSelfplayConfig() SelfplayConfig {
- return SelfplayConfig{
- Games: 1,
- BoardSize: 9,
- Komi: 6.5,
- Playouts: 30,
- FastPlayouts: 0,
- FullPlayouts: 0,
- CapRandomizeP: 0.20,
- Seed: 1,
- RulesRandomize: false,
- FullOnlyExport: true,
- EvalMode: "heuristic",
- ONNXFraction: 0.7,
- Parallel: 1,
- TemperatureMoves: 0,
- }
-}
-
-// RunSelfplay plays games and returns training samples with visit-weighted policy.
-func RunSelfplay(cfg SelfplayConfig) []Sample {
- samples, _ := RunSelfplayWithLogs(cfg)
- return samples
-}
-
-// RunSelfplayWithLogs returns samples and SGF-ready game logs.
-// Games run concurrently (cfg.Parallel) over one shared batched evaluator so the
-// inference sidecar sees real batches instead of one position at a time. Per-game
-// RNG is derived from the seed alone, so output is deterministic regardless of Parallel.
-func RunSelfplayWithLogs(cfg SelfplayConfig) ([]Sample, []*GameLog) {
- normalizeSelfplayPlayouts(&cfg)
- pool, closePool := newSelfplayEvalPool(cfg)
- defer closePool()
-
- par := cfg.Parallel
- if par < 1 {
- par = 1
- }
- if par > cfg.Games {
- par = cfg.Games
- }
-
- type gameResult struct {
- samples []Sample
- log *GameLog
- }
- results := make([]gameResult, cfg.Games)
- sem := make(chan struct{}, par)
- var wg sync.WaitGroup
- for g := 0; g < cfg.Games; g++ {
- wg.Add(1)
- sem <- struct{}{}
- go func(idx int) {
- defer wg.Done()
- defer func() { <-sem }()
- s, log := playSelfplayGameWithLog(cfg, idx, pool)
- results[idx] = gameResult{s, log}
- }(g)
- }
- wg.Wait()
-
- var samples []Sample
- logs := make([]*GameLog, 0, cfg.Games)
- for _, r := range results {
- samples = append(samples, r.samples...)
- logs = append(logs, r.log)
- }
- if cfg.FullOnlyExport {
- samples = FilterFullSearchSamples(samples)
- }
- return samples, logs
-}
-
-// evalPool holds the shared evaluators reused across concurrent self-play games.
-type evalPool struct {
- onnx Evaluator // shared batched ONNX evaluator, or nil when unused
- heuristic Evaluator
-}
-
-// newSelfplayEvalPool builds one shared evaluator set and a closer for its worker.
-func newSelfplayEvalPool(cfg SelfplayConfig) (evalPool, func()) {
- pool := evalPool{heuristic: Heuristic{}}
- mode := strings.ToLower(cfg.EvalMode)
- needONNX := mode == "onnx" || (mode == "mix" && cfg.ONNXFraction > 0)
- if !needONNX {
- return pool, func() {}
- }
- minBatch := cfg.Parallel
- if minBatch < 1 {
- minBatch = 1
- }
- onnx := newONNXEvaluator(minBatch)
- pool.onnx = onnx
- return pool, func() {
- if c, ok := onnx.(*BatchedEvaluator); ok {
- c.Close()
- }
- }
-}
-
-func normalizeSelfplayPlayouts(cfg *SelfplayConfig) {
- if cfg.FullPlayouts <= 0 {
- cfg.FullPlayouts = cfg.Playouts
- }
- if cfg.FastPlayouts <= 0 {
- cfg.FastPlayouts = cfg.FullPlayouts / 4
- if cfg.FastPlayouts < 8 {
- cfg.FastPlayouts = 8
- }
- }
-}
-
-func playSelfplayGameWithLog(cfg SelfplayConfig, gameIdx int, pool evalPool) ([]Sample, *GameLog) {
- // Per-game RNG keyed only by gameIdx keeps output deterministic under Parallel.
- rng := rand.New(rand.NewSource(cfg.Seed + int64(gameIdx)))
- rs, size := selfplayRuleset(cfg, rng)
- b := NewBoard(size, cfg.Komi)
- log := NewGameLog(size, cfg.Komi)
- scfg := DefaultConfig()
- scfg.Seed = cfg.Seed + int64(gameIdx)
- scfg.Workers = 1 // concurrency comes from parallel games feeding the shared batcher
- eval := selfplayEvaluator(cfg, gameIdx, rng, pool)
- eng := NewEngine(rs, eval, scfg)
- // Do not eng.Close(): the ONNX evaluator is shared and closed once by the pool.
- game, _ := collectSelfplaySamples(cfg, rs, b, eng, size, log, rng)
- bl, wl := rs.Score(b)
- ownership := OwnershipLabel(b)
- labelGameSamples(game, bl, wl, ownership)
- return game, log
-}
-
-func selfplayEvaluator(cfg SelfplayConfig, gameIdx int, rng *rand.Rand, pool evalPool) Evaluator {
- _ = gameIdx
- switch strings.ToLower(cfg.EvalMode) {
- case "onnx":
- return pool.onnx
- case "mix":
- frac := cfg.ONNXFraction
- if frac < 0 {
- frac = 0.7
- }
- if pool.onnx != nil && (frac >= 1 || rng.Float64() < frac) {
- return pool.onnx
- }
- return pool.heuristic
- default:
- return pool.heuristic
- }
-}
-
-func selfplayRuleset(cfg SelfplayConfig, rng *rand.Rand) (Ruleset, int) {
- rs := Chinese()
- size := cfg.BoardSize
- if cfg.RulesRandomize {
- if rng.Float64() < 0.5 {
- rs = TrompTaylor()
- }
- if rng.Float64() < 0.25 {
- rs = WithSuperko(rs)
- }
- sizes := []int{9, 13, 19}
- size = sizes[rng.Intn(len(sizes))]
- }
- return rs, size
-}
-
-func selfplayMovePlayouts(cfg SelfplayConfig, rng *rand.Rand) (playouts int, fullSearch bool) {
- // CapRandomizeP<=0: fixed full cap every move (comparison control; not fast-only).
- if cfg.CapRandomizeP <= 0 {
- return cfg.FullPlayouts, true
- }
- fullSearch = rng.Float64() < cfg.CapRandomizeP
- playouts = cfg.FastPlayouts
- if fullSearch {
- playouts = cfg.FullPlayouts
- }
- return playouts, fullSearch
-}
-
-func collectSelfplaySamples(cfg SelfplayConfig, rs Ruleset, b *Board, eng *Engine, size int, log *GameLog, rng *rand.Rand) ([]Sample, int) {
- var game []Sample
- var prevPolicy []float32
- passes := 0
- for moveNum := 0; moveNum < size*size+2; moveNum++ {
- moves := rs.LegalMoves(b)
- if onlyPass(moves) {
- break
- }
- playouts, fullSearch := selfplayMovePlayouts(cfg, rng)
- eng.ConfigureSelfplayMove(playouts, fullSearch)
- color := b.Player()
- // Sample from the visit distribution during the opening (paper SE: tau=1
- // for the first plies) so games diverge; play argmax afterwards. The policy
- // TARGET is always the tau=1 visit distribution regardless of what we play.
- temp := 0.0
- if moveNum < cfg.TemperatureMoves {
- temp = 1.0
- }
- m := eng.SelectMove(b, rng, temp)
- policy := eng.RootPolicyBoard(b, moves)
- spatial, globals := BuildFeaturesV2(b)
- game = append(game, Sample{
- BoardHash: b.Hash(),
- MoveNum: moveNum,
- Policy: policy,
- PolicyOpp: append([]float32(nil), prevPolicy...),
- FeaturesSpatial: spatial,
- FeaturesGlobal: globals,
- ToPlay: color,
- Komi: cfg.Komi,
- FullSearch: fullSearch,
- })
- prevPolicy = policy
- rs.Play(b, m)
- log.Record(color, m)
- eng.AdvanceTree(m)
- if m.Pass {
- passes++
- } else {
- passes = 0
- }
- if passes >= 2 {
- break
- }
- }
- return game, passes
-}
-
-func labelGameSamples(game []Sample, bl, wl float64, ownership []float32) {
- diff := bl - wl
- for i := range game {
- if game[i].ToPlay == Black {
- game[i].Value = outcomeValue(diff)
- } else {
- game[i].Value = outcomeValue(-diff)
- }
- if len(ownership) > 0 {
- game[i].Ownership = append([]float32(nil), ownership...)
- }
- }
-}
-
-func outcomeValue(diff float64) float32 {
- if diff > 0 {
- return 1
- }
- if diff < 0 {
- return -1
- }
- return 0
-}
-
-func onlyPass(moves []Move) bool {
- for _, m := range moves {
- if !m.Pass {
- return false
- }
- }
- return true
-}
-
-// FilterFullSearchSamples returns only positions from full-search moves (paper policy training set).
-func FilterFullSearchSamples(samples []Sample) []Sample {
- out := make([]Sample, 0, len(samples))
- for _, s := range samples {
- if s.FullSearch {
- out = append(out, s)
- }
- }
- return out
-}
-
-func isJSONLPath(path string) bool {
- return strings.HasSuffix(strings.ToLower(path), ".jsonl")
-}
diff --git a/cmd/gofer/selfplay_test.go b/cmd/gofer/selfplay_test.go
deleted file mode 100644
index ef38006..0000000
--- a/cmd/gofer/selfplay_test.go
+++ /dev/null
@@ -1,130 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "math/rand"
- "net/http"
- "net/http/httptest"
- "testing"
- "time"
-)
-
-func TestSelfplayEvalONNXEmitsSamples(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(testSidecarEvalHandler(t)))
- defer srv.Close()
-
- setTestEvalConfig(srv.URL)
-
- cfg := testSelfplayConfig("onnx", 1)
- cfg.Seed = 99
-
- samples, _ := RunSelfplayWithLogs(cfg)
- if len(samples) == 0 {
- t.Fatal("expected samples from onnx self-play")
- }
- for _, s := range samples {
- if len(s.FeaturesSpatial) == 0 || len(s.Policy) != 82 {
- t.Fatalf("invalid sample: spatial=%d policy=%d", len(s.FeaturesSpatial), len(s.Policy))
- }
- }
-}
-
-func testSidecarEvalHandler(t *testing.T) http.HandlerFunc {
- t.Helper()
- return func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/v1/eval" {
- http.NotFound(w, r)
- return
- }
- var req sidecarEvalReq
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- t.Errorf("decode: %v", err)
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- _ = json.NewEncoder(w).Encode(testSidecarResponse(len(req.Spatial)))
- }
-}
-
-func testSidecarResponse(n int) sidecarEvalResp {
- resp := sidecarEvalResp{Results: make([]struct {
- Value float64 `json:"value"`
- Policy []float32 `json:"policy"`
- }, n)}
- for i := range resp.Results {
- resp.Results[i].Value = 0.1
- resp.Results[i].Policy = make([]float32, 82)
- resp.Results[i].Policy[0] = 1
- }
- return resp
-}
-
-func TestSelfplayMixModeAlternates(t *testing.T) {
- var onnxHits int
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- onnxHits++
- resp := sidecarEvalResp{Results: []struct {
- Value float64 `json:"value"`
- Policy []float32 `json:"policy"`
- }{{Value: 0, Policy: make([]float32, 82)}}}
- _ = json.NewEncoder(w).Encode(resp)
- }))
- defer srv.Close()
-
- setTestEvalConfig(srv.URL)
-
- cfg := testSelfplayConfig("mix", 4)
- cfg.Seed = 7
-
- _, _ = RunSelfplayWithLogs(cfg)
- if onnxHits == 0 {
- t.Fatal("mix mode should hit ONNX sidecar on odd game indices")
- }
-}
-
-func setTestEvalConfig(url string) {
- SetEvalConfig(EvalConfig{
- Backend: "sidecar",
- ONNXURL: url,
- BatchSize: 1,
- EvalTimeout: 2 * time.Second,
- MaxWait: time.Millisecond,
- })
-}
-
-func testSelfplayConfig(evalMode string, games int) SelfplayConfig {
- cfg := DefaultSelfplayConfig()
- cfg.Games = games
- cfg.Playouts = 6
- cfg.FastPlayouts = 6
- cfg.FullPlayouts = 6
- cfg.CapRandomizeP = 1.0
- cfg.FullOnlyExport = false
- cfg.EvalMode = evalMode
- return cfg
-}
-
-func TestSelfplayEvaluatorSelection(t *testing.T) {
- batched := NewBatchedEvaluatorWithTimeout(Inference{}, Heuristic{}, 1, time.Millisecond, time.Second)
- defer batched.Close()
- pool := evalPool{onnx: batched, heuristic: Heuristic{}}
-
- cfg := DefaultSelfplayConfig()
- cfg.EvalMode = "heuristic"
- if _, ok := selfplayEvaluator(cfg, 0, rand.New(rand.NewSource(1)), pool).(Heuristic); !ok {
- t.Fatal("heuristic mode expected Heuristic evaluator")
- }
- cfg.EvalMode = "onnx"
- if _, ok := selfplayEvaluator(cfg, 0, rand.New(rand.NewSource(1)), pool).(*BatchedEvaluator); !ok {
- t.Fatal("onnx mode expected BatchedEvaluator")
- }
- cfg.EvalMode = "mix"
- cfg.ONNXFraction = 1.0
- if _, ok := selfplayEvaluator(cfg, 0, rand.New(rand.NewSource(1)), pool).(*BatchedEvaluator); !ok {
- t.Fatal("mix with fraction 1.0 expected BatchedEvaluator")
- }
- cfg.ONNXFraction = 0.0
- if _, ok := selfplayEvaluator(cfg, 0, rand.New(rand.NewSource(1)), pool).(Heuristic); !ok {
- t.Fatal("mix with fraction 0.0 expected Heuristic")
- }
-}
diff --git a/cmd/gofer/sgf.go b/cmd/gofer/sgf.go
deleted file mode 100644
index 1355095..0000000
--- a/cmd/gofer/sgf.go
+++ /dev/null
@@ -1,217 +0,0 @@
-package main
-
-import (
- "fmt"
- "os"
- "strconv"
- "strings"
-)
-
-// SGFMove is a color and optional board point from SGF (pass when Point is nil).
-type SGFMove struct {
- Color Color
- Point *Point
-}
-
-// SGFNode is one SGF node with properties and child variations.
-type SGFNode struct {
- Props map[string][]string
- Children []*SGFNode
-}
-
-// SGFGame is a parsed SGF game tree.
-type SGFGame struct {
- Size int
- Komi float64
- Root *SGFNode
-}
-
-// ParseSGFCoord decodes SGF coords (aa = upper-left).
-func ParseSGFCoord(size int, s string) (Point, error) {
- if len(s) != 2 {
- return Point{}, fmt.Errorf("sgf coord %q: want 2 letters", s)
- }
- x := int(s[0] - 'a')
- y := int(s[1] - 'a')
- if x < 0 || y < 0 || x >= size || y >= size {
- return Point{}, fmt.Errorf("sgf coord %q off %dx%d board", s, size, size)
- }
- return At(x, y), nil
-}
-
-// ParseSGF parses an SGF file (FF[4] subset: SZ, KM, AB, AW, PL, B, W).
-func ParseSGF(data string) (*SGFGame, error) {
- p := newSGFParser(data)
- if err := p.skipWS(); err != nil {
- return nil, err
- }
- if p.peek() != '(' {
- return nil, fmt.Errorf("sgf: expected '(' at start")
- }
- root, err := p.parseSequence()
- if err != nil {
- return nil, err
- }
- g := &SGFGame{Size: 19, Komi: 7.5, Root: root}
- if root != nil {
- g.applyMeta(root)
- }
- return g, nil
-}
-
-func (g *SGFGame) applyMeta(n *SGFNode) {
- if sz, ok := n.Props["SZ"]; ok && len(sz) > 0 {
- if v, err := strconv.Atoi(sz[0]); err == nil && v >= 2 {
- g.Size = v
- }
- }
- if km, ok := n.Props["KM"]; ok && len(km) > 0 {
- if v, err := strconv.ParseFloat(km[0], 64); err == nil {
- g.Komi = v
- }
- }
-}
-
-// MainLine returns moves along the first child at each variation point.
-func (g *SGFGame) MainLine() ([]SGFMove, error) {
- var moves []SGFMove
- if err := g.collectMainLine(g.Root, &moves); err != nil {
- return nil, err
- }
- return moves, nil
-}
-
-func (g *SGFGame) collectMainLine(n *SGFNode, moves *[]SGFMove) error {
- if n == nil {
- return nil
- }
- if err := appendSGFMoveProps(g.Size, n.Props, moves); err != nil {
- return err
- }
- if len(n.Children) > 0 {
- return g.collectMainLine(n.Children[0], moves)
- }
- return nil
-}
-
-// sgfMoveToPlay maps an SGF move to an engine Move (pass when Point is nil).
-func sgfMoveToPlay(m SGFMove) Move {
- if m.Point == nil {
- return PassMove
- }
- return StoneMove(*m.Point)
-}
-
-func appendSGFMoveProps(size int, props map[string][]string, moves *[]SGFMove) error {
- for tag, vals := range props {
- if tag != "B" && tag != "W" {
- continue
- }
- c := Black
- if tag == "W" {
- c = White
- }
- for _, v := range vals {
- if v == "" {
- *moves = append(*moves, SGFMove{Color: c})
- continue
- }
- pt, err := ParseSGFCoord(size, v)
- if err != nil {
- return err
- }
- *moves = append(*moves, SGFMove{Color: c, Point: &pt})
- }
- }
- return nil
-}
-
-// SetupSGF applies AB/AW/PL from the root node onto b.
-func (g *SGFGame) Setup(b *Board) error {
- if g.Root == nil {
- return nil
- }
- n := g.Root
- for _, coord := range n.Props["AB"] {
- pt, err := ParseSGFCoord(g.Size, coord)
- if err != nil {
- return err
- }
- b.SetStoneIndex(pt.Idx(g.Size), Black)
- }
- for _, coord := range n.Props["AW"] {
- pt, err := ParseSGFCoord(g.Size, coord)
- if err != nil {
- return err
- }
- b.SetStoneIndex(pt.Idx(g.Size), White)
- }
- if pl, ok := n.Props["PL"]; ok && len(pl) > 0 && pl[0] == "2" {
- b.SetPlayer(White)
- }
- b.Rehash()
- return nil
-}
-
-// ExportSGF writes a minimal SGF for the main line.
-func ExportSGF(g *SGFGame, moves []SGFMove) string {
- var b strings.Builder
- b.WriteString("(;FF[4]")
- fmt.Fprintf(&b, "SZ[%d]", g.Size)
- fmt.Fprintf(&b, "KM[%.1f]", g.Komi)
- for _, m := range moves {
- tag := 'B'
- if m.Color == White {
- tag = 'W'
- }
- b.WriteByte(';')
- b.WriteByte(byte(tag))
- b.WriteByte('[')
- if m.Point != nil {
- p := *m.Point
- b.WriteByte('a' + byte(p.X))
- b.WriteByte('a' + byte(p.Y))
- }
- b.WriteByte(']')
- }
- b.WriteString(")")
- return b.String()
-}
-
-// ReplaySGFFile replays path under Chinese rules and returns move count and score.
-func ReplaySGFFile(path string) (moveCount int, black, white float64, err error) {
- data, err := os.ReadFile(path)
- if err != nil {
- return 0, 0, 0, err
- }
- g, err := ParseSGF(string(data))
- if err != nil {
- return 0, 0, 0, err
- }
- r := Chinese()
- b := NewBoard(g.Size, g.Komi)
- if err := g.Setup(b); err != nil {
- return 0, 0, 0, err
- }
- moves, err := g.MainLine()
- if err != nil {
- return 0, 0, 0, err
- }
- for i, m := range moves {
- if b.Player() != m.Color {
- return i, 0, 0, fmt.Errorf("move %d wrong side", i)
- }
- var play Move
- if m.Point == nil {
- play = PassMove
- } else {
- play = StoneMove(*m.Point)
- }
- if !r.Play(b, play) {
- return i, 0, 0, fmt.Errorf("illegal move %d", i)
- }
- }
- bl, wl := r.Score(b)
- _ = ExportSGF(g, moves)
- return len(moves), bl, wl, nil
-}
diff --git a/cmd/gofer/sgf_convert.go b/cmd/gofer/sgf_convert.go
deleted file mode 100644
index 106cabe..0000000
--- a/cmd/gofer/sgf_convert.go
+++ /dev/null
@@ -1,688 +0,0 @@
-package main
-
-import (
- "bufio"
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "strconv"
- "strings"
-)
-
-const (
- defaultPolicyEpsilon = 0.10
- komiMin = 6.5
- komiMax = 7.5
- badOpeningPlies = 16
-)
-
-// SGFConvertConfig controls supervised SGF → JSONL conversion.
-type SGFConvertConfig struct {
- OutPath string
- MaxRows int
- PerSourceCap int // 0 = auto equal split when MaxRows > 0
- Epsilon float64
- BoardSize int
-}
-
-// SGFConvertStats aggregates conversion outcomes.
-type SGFConvertStats struct {
- FilesSeen int
- GamesAccepted int
- GamesRejected int
- RowsWritten int
- RowsSkippedDup int
- PerSourceCap int
- RejectReason map[string]int
- SourceGames map[string]int
- SourceRows map[string]int
-}
-
-func newSGFConvertStats() *SGFConvertStats {
- return &SGFConvertStats{
- RejectReason: make(map[string]int),
- SourceGames: make(map[string]int),
- SourceRows: make(map[string]int),
- }
-}
-
-func (s *SGFConvertStats) reject(reason string) {
- s.GamesRejected++
- s.RejectReason[reason]++
-}
-
-// SampleJSONLWriter streams schema-v1 training rows to JSONL.
-type SampleJSONLWriter struct {
- f *os.File
- w *bufio.Writer
- n int
- err error
-}
-
-// NewSampleJSONLWriter creates path and writes the header line.
-func NewSampleJSONLWriter(path string) (*SampleJSONLWriter, error) {
- if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
- return nil, err
- }
- f, err := os.Create(path)
- if err != nil {
- return nil, err
- }
- sw := &SampleJSONLWriter{f: f, w: bufio.NewWriter(f)}
- header, err := json.Marshal(map[string]any{
- "schema_version": SampleSchemaVersion,
- "git_commit": buildInfoVersion(),
- "type": "header",
- })
- if err != nil {
- f.Close()
- return nil, err
- }
- if _, err := sw.w.Write(header); err != nil {
- f.Close()
- return nil, err
- }
- if _, err := sw.w.WriteString("\n"); err != nil {
- f.Close()
- return nil, err
- }
- return sw, nil
-}
-
-// WriteSample appends one training row.
-func (sw *SampleJSONLWriter) WriteSample(s Sample) error {
- if sw.err != nil {
- return sw.err
- }
- line, err := json.Marshal(s)
- if err != nil {
- sw.err = err
- return err
- }
- if _, err := sw.w.Write(line); err != nil {
- sw.err = err
- return err
- }
- if _, err := sw.w.WriteString("\n"); err != nil {
- sw.err = err
- return err
- }
- sw.n++
- return nil
-}
-
-// Rows returns rows written (excluding header).
-func (sw *SampleJSONLWriter) Rows() int { return sw.n }
-
-// Close flushes and closes the file.
-func (sw *SampleJSONLWriter) Close() error {
- if sw.err != nil {
- _ = sw.w.Flush()
- _ = sw.f.Close()
- return sw.err
- }
- if err := sw.w.Flush(); err != nil {
- _ = sw.f.Close()
- return err
- }
- return sw.f.Close()
-}
-
-type dedupKey struct {
- hash uint64
- toPlay Color
-}
-
-func smoothedPolicyTarget(legal []Move, played Move, size int, eps float64) []float32 {
- n := size*size + 1
- out := make([]float32, n)
- if len(legal) == 0 {
- return out
- }
- playedIdx := -1
- for i, m := range legal {
- if movesEqual(m, played) {
- playedIdx = i
- break
- }
- }
- if playedIdx < 0 {
- return nil
- }
- if len(legal) == 1 {
- out[policyIndex(legal[0], size)] = 1
- return out
- }
- otherShare := float32(eps) / float32(len(legal)-1)
- peak := float32(1.0 - eps)
- for i, m := range legal {
- idx := policyIndex(m, size)
- if i == playedIdx {
- out[idx] = peak
- } else {
- out[idx] = otherShare
- }
- }
- return out
-}
-
-func komiInRange(k float64) bool {
- return k >= komiMin-1e-9 && k <= komiMax+1e-9
-}
-
-func isNineCorner(p Point, size int) bool {
- if size != 9 {
- return false
- }
- last := size - 1
- return (p.X == 0 || p.X == last) && (p.Y == 0 || p.Y == last)
-}
-
-func hasBadOpening(moves []SGFMove, size int) bool {
- limit := badOpeningPlies
- if len(moves) < limit {
- limit = len(moves)
- }
- for i := 0; i < limit; i++ {
- m := moves[i]
- if m.Point != nil && isNineCorner(*m.Point, size) {
- return true
- }
- }
- return false
-}
-
-func sgfHasHandicap(root *SGFNode) bool {
- if root == nil {
- return false
- }
- if ha, ok := root.Props["HA"]; ok && len(ha) > 0 {
- if n, err := strconv.Atoi(ha[0]); err == nil && n >= 1 {
- return true
- }
- if ha[0] != "" && ha[0] != "0" {
- return true
- }
- }
- if ab, ok := root.Props["AB"]; ok && len(ab) > 0 {
- return true
- }
- if aw, ok := root.Props["AW"]; ok && len(aw) > 0 {
- return true
- }
- return false
-}
-
-func findSGFProp(n *SGFNode, key string) string {
- if n == nil {
- return ""
- }
- if vals, ok := n.Props[key]; ok && len(vals) > 0 && vals[0] != "" {
- return vals[0]
- }
- for _, c := range n.Children {
- if v := findSGFProp(c, key); v != "" {
- return v
- }
- }
- return ""
-}
-
-func parseSGFResult(re string) (winner Color, ok bool) {
- re = strings.TrimSpace(re)
- if re == "" {
- return Empty, false
- }
- upper := strings.ToUpper(re)
- if upper == "0" || upper == "DRAW" || strings.HasPrefix(upper, "DRAW") {
- return Empty, true
- }
- if strings.HasPrefix(upper, "B") {
- return Black, true
- }
- if strings.HasPrefix(upper, "W") {
- return White, true
- }
- return Empty, false
-}
-
-// ConvertSGFBytes turns one SGF record into training samples (no dedup).
-func ConvertSGFBytes(data []byte, cfg SGFConvertConfig) ([]Sample, string, error) {
- g, err := ParseSGF(string(data))
- if err != nil {
- return nil, "parse", err
- }
- if g.Size != cfg.BoardSize {
- return nil, "size", fmt.Errorf("size %d", g.Size)
- }
- if !komiInRange(g.Komi) {
- return nil, "komi", fmt.Errorf("komi %.2f", g.Komi)
- }
- if sgfHasHandicap(g.Root) {
- return nil, "handicap", fmt.Errorf("handicap setup")
- }
- re := findSGFProp(g.Root, "RE")
- if _, ok := parseSGFResult(re); !ok {
- return nil, "result", fmt.Errorf("re %q", re)
- }
- moves, err := g.MainLine()
- if err != nil {
- return nil, "mainline", err
- }
- if hasBadOpening(moves, g.Size) {
- return nil, "bad_opening", fmt.Errorf("corner in opening")
- }
-
- rs := Chinese()
- b := NewBoard(g.Size, g.Komi)
- if err := g.Setup(b); err != nil {
- return nil, "setup", err
- }
-
- eps := cfg.Epsilon
- if eps <= 0 {
- eps = defaultPolicyEpsilon
- }
-
- var samples []Sample
- for moveNum, sm := range moves {
- if b.Player() != sm.Color {
- return nil, "side", fmt.Errorf("move %d wrong side", moveNum)
- }
- legal := rs.LegalMoves(b)
- if onlyPass(legal) {
- break
- }
- play := sgfMoveToPlay(sm)
- policy := smoothedPolicyTarget(legal, play, g.Size, eps)
- if policy == nil {
- return nil, "illegal", fmt.Errorf("illegal move %d", moveNum)
- }
- spatial, globals := BuildFeaturesV2(b)
- samples = append(samples, Sample{
- BoardHash: b.Hash(),
- MoveNum: moveNum,
- Policy: policy,
- FeaturesSpatial: spatial,
- FeaturesGlobal: globals,
- ToPlay: b.Player(),
- Komi: g.Komi,
- })
- if !rs.Play(b, play) {
- return nil, "illegal", fmt.Errorf("replay illegal %d", moveNum)
- }
- }
-
- bl, wl := rs.Score(b)
- ownership := OwnershipLabel(b)
- labelGameSamples(samples, bl, wl, ownership)
- return samples, "", nil
-}
-
-func inferSourceLabel(path string) string {
- p := strings.ToLower(filepath.ToSlash(path))
- switch {
- case strings.Contains(p, "/cgos/"):
- return "cgos"
- case strings.Contains(p, "/minigo/"):
- return "minigo"
- case strings.Contains(p, "/aeb/"):
- return "aeb"
- default:
- return filepath.Base(filepath.Dir(path))
- }
-}
-
-type convertState struct {
- cfg SGFConvertConfig
- dedup map[dedupKey]struct{}
- stats *SGFConvertStats
- w *SampleJSONLWriter
- sourceRows map[string]int
- perSourceCap int
-}
-
-func (st *convertState) sourceAtCap(source string) bool {
- if st.perSourceCap <= 0 {
- return false
- }
- return st.sourceRows[source] >= st.perSourceCap
-}
-
-func (st *convertState) globalAtCap() bool {
- return st.cfg.MaxRows > 0 && st.stats.RowsWritten >= st.cfg.MaxRows
-}
-
-func equalPerSourceCap(maxRows, numSources int) int {
- if maxRows <= 0 || numSources <= 0 {
- return 0
- }
- return (maxRows + numSources - 1) / numSources
-}
-
-type sourceQueue struct {
- label string
- paths []string
- idx int
-}
-
-func collectSGFSourceQueues(inputs []string) ([]sourceQueue, error) {
- var queues []sourceQueue
- for _, root := range inputs {
- label := inferSourceLabel(filepath.ToSlash(root) + "/stub.sgf")
- paths, err := listSGFFiles(root)
- if err != nil {
- return nil, err
- }
- if len(paths) == 0 {
- continue
- }
- queues = append(queues, sourceQueue{label: label, paths: paths})
- }
- return queues, nil
-}
-
-func listSGFFiles(root string) ([]string, error) {
- info, err := os.Stat(root)
- if err != nil {
- return nil, err
- }
- if !info.IsDir() {
- if strings.HasSuffix(strings.ToLower(root), ".sgf") {
- return []string{root}, nil
- }
- return nil, nil
- }
- var paths []string
- err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
- if err != nil {
- return err
- }
- if d.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".sgf") {
- return nil
- }
- paths = append(paths, path)
- return nil
- })
- return paths, err
-}
-
-func (st *convertState) convertSGFFile(path, source string) error {
- if st.globalAtCap() || st.sourceAtCap(source) {
- return errStopConvert
- }
- data, err := os.ReadFile(path)
- if err != nil {
- return err
- }
- st.stats.FilesSeen++
- samples, reason, err := ConvertSGFBytes(data, st.cfg)
- if err != nil {
- if reason == "" {
- reason = "error"
- }
- st.stats.reject(reason)
- return nil
- }
- st.stats.GamesAccepted++
- st.stats.SourceGames[source]++
- for _, s := range samples {
- if st.globalAtCap() || st.sourceAtCap(source) {
- return errStopConvert
- }
- key := dedupKey{hash: s.BoardHash, toPlay: s.ToPlay}
- if _, ok := st.dedup[key]; ok {
- st.stats.RowsSkippedDup++
- continue
- }
- st.dedup[key] = struct{}{}
- if err := st.w.WriteSample(s); err != nil {
- return err
- }
- st.stats.RowsWritten++
- st.sourceRows[source]++
- st.stats.SourceRows[source]++
- }
- return nil
-}
-
-func runBalancedSGFConvert(cfg SGFConvertConfig, inputs []string) (*SGFConvertStats, error) {
- queues, err := collectSGFSourceQueues(inputs)
- if err != nil {
- return nil, err
- }
- if len(queues) == 0 {
- return nil, fmt.Errorf("convert: no SGF files under inputs")
- }
- perSourceCap := cfg.PerSourceCap
- if perSourceCap <= 0 && cfg.MaxRows > 0 {
- perSourceCap = equalPerSourceCap(cfg.MaxRows, len(queues))
- }
- stats := newSGFConvertStats()
- stats.PerSourceCap = perSourceCap
- st := &convertState{
- cfg: cfg,
- dedup: make(map[dedupKey]struct{}),
- stats: stats,
- sourceRows: make(map[string]int),
- perSourceCap: perSourceCap,
- }
- w, err := NewSampleJSONLWriter(cfg.OutPath)
- if err != nil {
- return nil, err
- }
- st.w = w
- defer w.Close()
-
- for {
- progress := false
- for i := range queues {
- q := &queues[i]
- if st.sourceAtCap(q.label) || q.idx >= len(q.paths) {
- continue
- }
- progress = true
- path := q.paths[q.idx]
- q.idx++
- if err := st.convertSGFFile(path, q.label); err != nil {
- if err == errStopConvert {
- if st.globalAtCap() {
- _ = w.Close()
- return stats, nil
- }
- continue
- }
- return stats, err
- }
- if st.globalAtCap() {
- _ = w.Close()
- return stats, nil
- }
- }
- if !progress {
- break
- }
- }
- if err := w.Close(); err != nil {
- return stats, err
- }
- return stats, nil
-}
-
-// RunSGFConvert walks inputs round-robin by source with equal per-source caps.
-func RunSGFConvert(cfg SGFConvertConfig, inputs []string) (*SGFConvertStats, error) {
- if cfg.OutPath == "" {
- return nil, fmt.Errorf("convert: missing output path")
- }
- if cfg.BoardSize <= 0 {
- cfg.BoardSize = 9
- }
- if cfg.Epsilon <= 0 {
- cfg.Epsilon = defaultPolicyEpsilon
- }
- return runBalancedSGFConvert(cfg, inputs)
-}
-
-var errStopConvert = fmt.Errorf("max rows reached")
-
-// VerifyJSONLSamples checks structural invariants on converted JSONL rows.
-func VerifyJSONLSamples(path string, spot int) error {
- f, err := os.Open(path)
- if err != nil {
- return err
- }
- defer f.Close()
- sc := bufio.NewScanner(f)
- const maxLine = 16 << 20
- buf := make([]byte, 0, 1024*1024)
- sc.Buffer(buf, maxLine)
- rows := 0
- var examples []Sample
- for sc.Scan() {
- var row map[string]json.RawMessage
- if err := json.Unmarshal(sc.Bytes(), &row); err != nil {
- return err
- }
- if typ, _ := parseJSONString(row["type"]); typ == "header" {
- continue
- }
- var s Sample
- if err := json.Unmarshal(sc.Bytes(), &s); err != nil {
- return err
- }
- if err := verifySample(s); err != nil {
- return fmt.Errorf("row %d: %w", rows+1, err)
- }
- if len(examples) < spot {
- examples = append(examples, s)
- }
- rows++
- }
- if err := sc.Err(); err != nil {
- return err
- }
- if rows == 0 {
- return fmt.Errorf("no sample rows in %s", path)
- }
- for i, s := range examples {
- peak, share := policyPeak(s.Policy)
- fmt.Printf("spot[%d] move=%d to_play=%d value=%.0f komi=%.1f peak_idx=%d peak=%.3f\n",
- i, s.MoveNum, s.ToPlay, s.Value, s.Komi, peak, share)
- }
- fmt.Printf("verify-jsonl: %d rows OK\n", rows)
- return nil
-}
-
-func parseJSONString(raw json.RawMessage) (string, bool) {
- if len(raw) == 0 {
- return "", false
- }
- var s string
- if err := json.Unmarshal(raw, &s); err != nil {
- return "", false
- }
- return s, true
-}
-
-func verifySample(s Sample) error {
- size := 9
- n := size*size + 1
- if len(s.Policy) != n {
- return fmt.Errorf("policy len %d", len(s.Policy))
- }
- if len(s.FeaturesSpatial) != featurePlanesV2*size*size {
- return fmt.Errorf("spatial len %d", len(s.FeaturesSpatial))
- }
- if len(s.FeaturesGlobal) != featureGlobalsV2 {
- return fmt.Errorf("globals len %d", len(s.FeaturesGlobal))
- }
- if len(s.Ownership) != 0 && len(s.Ownership) != size*size {
- return fmt.Errorf("ownership len %d", len(s.Ownership))
- }
- var sum float32
- for _, v := range s.Policy {
- if v < -1e-6 {
- return fmt.Errorf("negative policy %v", v)
- }
- sum += v
- }
- if sum < 0.999 || sum > 1.001 {
- return fmt.Errorf("policy sum %.4f", sum)
- }
- if s.Value != -1 && s.Value != 0 && s.Value != 1 {
- return fmt.Errorf("value %.2f", s.Value)
- }
- if err := verifySpatialPlanes(s.FeaturesSpatial, s.FeaturesGlobal, size); err != nil {
- return err
- }
- return nil
-}
-
-func verifySpatialPlanes(spatial []float32, globals []float32, size int) error {
- n := size * size
- for i := 0; i < n; i++ {
- own := spatial[i]
- opp := spatial[n+i]
- empty := spatial[2*n+i]
- if own+opp+empty < 0.99 || own+opp+empty > 1.01 {
- return fmt.Errorf("cell %d stone planes sum %.2f", i, own+opp+empty)
- }
- }
- blackMove := globals[2] > 0.5
- for i := 0; i < n; i++ {
- if spatial[4*n+i] > 0.5 && !blackMove {
- return fmt.Errorf("to-move plane black but globals white")
- }
- }
- return nil
-}
-
-func policyPeak(policy []float32) (idx int, share float32) {
- idx = -1
- for i, v := range policy {
- if v > share {
- share = v
- idx = i
- }
- }
- return idx, share
-}
-
-func runVerifyJSONLCLI(path string, spot int) {
- if path == "" {
- fmt.Fprintln(os.Stderr, "-verify-jsonl requires -o path")
- os.Exit(1)
- }
- if spot <= 0 {
- spot = 5
- }
- if err := VerifyJSONLSamples(path, spot); err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
-}
-
-func runSGFConvertCLI(f cliFlags, inputs []string) {
- cfg := SGFConvertConfig{
- OutPath: f.out,
- MaxRows: f.convertMaxRows,
- Epsilon: f.convertEpsilon,
- BoardSize: f.size,
- }
- stats, err := RunSGFConvert(cfg, inputs)
- if err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- fmt.Printf("convert-sgf: files=%d games_ok=%d games_reject=%d rows=%d dup_skip=%d per_source_cap=%d out=%s\n",
- stats.FilesSeen, stats.GamesAccepted, stats.GamesRejected, stats.RowsWritten, stats.RowsSkippedDup,
- stats.PerSourceCap, cfg.OutPath)
- if len(stats.SourceGames) > 0 {
- fmt.Println("source games:", stats.SourceGames)
- fmt.Println("source rows:", stats.SourceRows)
- }
- if len(stats.RejectReason) > 0 {
- fmt.Println("reject reasons:", stats.RejectReason)
- }
-}
diff --git a/cmd/gofer/sgf_convert_test.go b/cmd/gofer/sgf_convert_test.go
deleted file mode 100644
index 22ae1ff..0000000
--- a/cmd/gofer/sgf_convert_test.go
+++ /dev/null
@@ -1,141 +0,0 @@
-package main
-
-import (
- "math"
- "os"
- "path/filepath"
- "testing"
-)
-
-func TestSmoothedPolicySumsToOne(t *testing.T) {
- size := 9
- b := NewBoard(size, 6.5)
- r := Chinese()
- legal := r.LegalMoves(b)
- played := legal[0]
- policy := smoothedPolicyTarget(legal, played, size, 0.10)
- if policy == nil {
- t.Fatal("nil policy")
- }
- var sum float32
- for _, v := range policy {
- sum += v
- }
- if math.Abs(float64(sum-1)) > 1e-5 {
- t.Fatalf("sum=%v", sum)
- }
- if policy[policyIndex(played, size)] < 0.89 {
- t.Fatalf("peak too low: %v", policy[policyIndex(played, size)])
- }
-}
-
-func TestConvertSGFSimpleGame(t *testing.T) {
- sgf := "(;FF[4]GM[1]SZ[9]KM[6.5]RE[B+1.5];B[cc];W[gg];B[ee];W[])"
-
- samples, reason, err := ConvertSGFBytes([]byte(sgf), SGFConvertConfig{BoardSize: 9, Epsilon: 0.10})
- if err != nil {
- t.Fatalf("reason=%q err=%v", reason, err)
- }
- if len(samples) < 2 {
- t.Fatalf("samples=%d", len(samples))
- }
- for _, s := range samples {
- if len(s.FeaturesSpatial) != 8*9*9 {
- t.Fatalf("spatial len %d", len(s.FeaturesSpatial))
- }
- if len(s.Policy) != 82 {
- t.Fatalf("policy len %d", len(s.Policy))
- }
- if len(s.Ownership) != 81 {
- t.Fatalf("ownership len %d", len(s.Ownership))
- }
- if s.Value == 0 && s.ToPlay == Black {
- // may be draw edge case
- }
- }
-}
-
-func TestConvertRejectsHandicap(t *testing.T) {
- sgf := "(;FF[4]SZ[9]KM[6.5]RE[B+1]AB[cc][dd];B[ee];W[gg])"
- _, reason, err := ConvertSGFBytes([]byte(sgf), SGFConvertConfig{BoardSize: 9})
- if err == nil || reason != "handicap" {
- t.Fatalf("reason=%q err=%v", reason, err)
- }
-}
-
-func TestConvertRejectsBadOpeningCorner(t *testing.T) {
- sgf := "(;FF[4]SZ[9]KM[6.5]RE[B+1];B[aa];W[cc];B[dd])"
- _, reason, err := ConvertSGFBytes([]byte(sgf), SGFConvertConfig{BoardSize: 9})
- if err == nil || reason != "bad_opening" {
- t.Fatalf("reason=%q err=%v", reason, err)
- }
-}
-
-func TestConvertRejectsKomiOutOfRange(t *testing.T) {
- sgf := "(;FF[4]SZ[9]KM[5.5]RE[B+1];B[cc];W[dd])"
- _, reason, err := ConvertSGFBytes([]byte(sgf), SGFConvertConfig{BoardSize: 9})
- if err == nil || reason != "komi" {
- t.Fatalf("reason=%q err=%v", reason, err)
- }
-}
-
-func TestBalancedPerSourceCap(t *testing.T) {
- root := t.TempDir()
- write := func(dir, name, sgf string) {
- d := filepath.Join(root, dir)
- if err := os.MkdirAll(d, 0755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(d, name), []byte(sgf), 0644); err != nil {
- t.Fatal(err)
- }
- }
- sgf := "(;FF[4]SZ[9]KM[6.5]RE[B+1];B[cc];W[gg];B[ee];W[hh])"
- write("cgos", "a.sgf", sgf)
- write("minigo", "b.sgf", sgf)
- write("aeb", "c.sgf", sgf)
- out := filepath.Join(root, "out.jsonl")
- stats, err := RunSGFConvert(SGFConvertConfig{OutPath: out, BoardSize: 9, MaxRows: 9, Epsilon: 0.10}, []string{
- filepath.Join(root, "cgos"),
- filepath.Join(root, "minigo"),
- filepath.Join(root, "aeb"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if stats.PerSourceCap != 3 {
- t.Fatalf("per_source_cap=%d want 3", stats.PerSourceCap)
- }
- for src, n := range stats.SourceRows {
- if n > 3 {
- t.Fatalf("source %s rows=%d exceeds cap", src, n)
- }
- }
- if len(stats.SourceRows) < 2 {
- t.Fatalf("expected multiple sources, got %v", stats.SourceRows)
- }
-}
-
-func TestConvertDedupAcrossGames(t *testing.T) {
- dir := t.TempDir()
- // Same opening sequence, different results — should share early positions.
- sgf1 := "(;FF[4]SZ[9]KM[6.5]RE[B+1];B[cc];W[gg];B[ee];W[hh];B[ff])"
- sgf2 := "(;FF[4]SZ[9]KM[6.5]RE[W+1];B[cc];W[gg];B[ee];W[hh];B[ff])"
- if err := os.WriteFile(filepath.Join(dir, "a.sgf"), []byte(sgf1), 0644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(dir, "b.sgf"), []byte(sgf2), 0644); err != nil {
- t.Fatal(err)
- }
- out := filepath.Join(dir, "out.jsonl")
- stats, err := RunSGFConvert(SGFConvertConfig{OutPath: out, BoardSize: 9, Epsilon: 0.10}, []string{dir})
- if err != nil {
- t.Fatal(err)
- }
- if stats.RowsWritten == 0 {
- t.Fatal("no rows")
- }
- if stats.RowsSkippedDup == 0 {
- t.Log("no dup skips (games may not overlap)")
- }
-}
diff --git a/cmd/gofer/sgf_parse.go b/cmd/gofer/sgf_parse.go
deleted file mode 100644
index 0566c75..0000000
--- a/cmd/gofer/sgf_parse.go
+++ /dev/null
@@ -1,188 +0,0 @@
-package main
-
-import (
- "fmt"
- "strings"
-)
-
-type sgfParser struct {
- s string
- i int
-}
-
-func newSGFParser(data string) *sgfParser {
- return &sgfParser{s: data}
-}
-
-func (p *sgfParser) eof() bool { return p.i >= len(p.s) }
-
-func (p *sgfParser) peek() byte {
- if p.eof() {
- return 0
- }
- return p.s[p.i]
-}
-
-func (p *sgfParser) skipWS() error {
- for !p.eof() {
- c := p.s[p.i]
- if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
- p.i++
- continue
- }
- break
- }
- return nil
-}
-
-func (p *sgfParser) parseSequence() (*SGFNode, error) {
- if err := p.skipWS(); err != nil {
- return nil, err
- }
- if p.peek() != '(' {
- return nil, fmt.Errorf("sgf: expected '('")
- }
- p.i++
- return p.parseSequenceBody()
-}
-
-func (p *sgfParser) parseSequenceBody() (*SGFNode, error) {
- var first, cur *SGFNode
- for {
- if err := p.skipWS(); err != nil {
- return nil, err
- }
- if p.eof() {
- return nil, fmt.Errorf("sgf: unclosed '('")
- }
- switch p.peek() {
- case ';':
- node, err := p.parseNode()
- if err != nil {
- return nil, err
- }
- first, cur = sgfLinkNode(first, cur, node)
- case '(':
- child, err := p.parseSequence()
- if err != nil {
- return nil, err
- }
- first, cur = sgfLinkChild(first, cur, child)
- case ')':
- p.i++
- return first, nil
- default:
- return nil, fmt.Errorf("sgf: unexpected %q at %d", p.peek(), p.i)
- }
- }
-}
-
-func sgfLinkNode(first, cur, node *SGFNode) (*SGFNode, *SGFNode) {
- if first == nil {
- return node, node
- }
- if cur != nil {
- cur.Children = append(cur.Children, node)
- }
- return first, node
-}
-
-func sgfLinkChild(first, cur, child *SGFNode) (*SGFNode, *SGFNode) {
- if cur != nil {
- cur.Children = append(cur.Children, child)
- return first, cur
- }
- if first == nil {
- return child, nil
- }
- return first, cur
-}
-
-func (p *sgfParser) parseNode() (*SGFNode, error) {
- if p.peek() != ';' {
- return nil, fmt.Errorf("sgf: expected ';'")
- }
- p.i++
- n := &SGFNode{Props: map[string][]string{}}
- for p.hasMoreProps() {
- key, err := p.readPropKey()
- if err != nil {
- return nil, err
- }
- vals, err := p.readPropValues()
- if err != nil {
- return nil, err
- }
- if len(vals) > 0 {
- n.Props[key] = append(n.Props[key], vals...)
- }
- }
- return n, nil
-}
-
-func (p *sgfParser) hasMoreProps() bool {
- if err := p.skipWS(); err != nil {
- return false
- }
- if p.eof() {
- return false
- }
- c := p.peek()
- return c != '(' && c != ')' && c != ';' && c >= 'A' && c <= 'Z'
-}
-
-func (p *sgfParser) readPropKey() (string, error) {
- if err := p.skipWS(); err != nil {
- return "", err
- }
- start := p.i
- for !p.eof() && p.s[p.i] >= 'A' && p.s[p.i] <= 'Z' {
- p.i++
- }
- if start == p.i {
- return "", fmt.Errorf("sgf: expected property key")
- }
- return p.s[start:p.i], nil
-}
-
-func (p *sgfParser) readPropValues() ([]string, error) {
- var vals []string
- for {
- if err := p.skipWS(); err != nil {
- return vals, err
- }
- if p.peek() != '[' {
- break
- }
- val, err := p.parseValue()
- if err != nil {
- return nil, err
- }
- vals = append(vals, val)
- }
- return vals, nil
-}
-
-func (p *sgfParser) parseValue() (string, error) {
- if p.peek() != '[' {
- return "", fmt.Errorf("sgf: expected '['")
- }
- p.i++
- var b strings.Builder
- for !p.eof() {
- c := p.s[p.i]
- if c == ']' {
- p.i++
- return b.String(), nil
- }
- if c == '\\' && p.i+1 < len(p.s) {
- p.i++
- b.WriteByte(p.s[p.i])
- p.i++
- continue
- }
- b.WriteByte(c)
- p.i++
- }
- return "", fmt.Errorf("sgf: unclosed '['")
-}
diff --git a/cmd/gofer/superko.go b/cmd/gofer/superko.go
deleted file mode 100644
index cd6e589..0000000
--- a/cmd/gofer/superko.go
+++ /dev/null
@@ -1,84 +0,0 @@
-package main
-
-// WithSuperko wraps r with positional superko using board Zobrist hash + side to move.
-func WithSuperko(r Ruleset) Ruleset {
- return &superkoRules{inner: r, seen: map[uint64]struct{}{}}
-}
-
-type superkoRules struct {
- inner Ruleset
- seen map[uint64]struct{}
-}
-
-func (s *superkoRules) LegalMoves(b *Board) []Move {
- s.ensureStart(b)
- all := s.inner.LegalMoves(b)
- out := make([]Move, 0, len(all))
- for _, m := range all {
- if m.Pass {
- out = append(out, m)
- continue
- }
- if s.trialLegal(b, m) {
- out = append(out, m)
- }
- }
- return out
-}
-
-func (s *superkoRules) Play(b *Board, m Move) bool {
- s.ensureStart(b)
- if m.Pass {
- if s.repeats(superkoHash(b)) {
- return false
- }
- if !s.inner.Play(b, m) {
- return false
- }
- s.record(superkoHash(b))
- return true
- }
- if !s.trialLegal(b, m) {
- return false
- }
- if !s.inner.Play(b, m) {
- return false
- }
- s.record(superkoHash(b))
- return true
-}
-
-func (s *superkoRules) Score(b *Board) (black, white float64) {
- return s.inner.Score(b)
-}
-
-func (s *superkoRules) trialLegal(b *Board, m Move) bool {
- trial := b.Clone()
- if !s.inner.Play(trial, m) {
- return false
- }
- return !s.repeats(superkoHash(trial))
-}
-
-func (s *superkoRules) repeats(h uint64) bool {
- _, ok := s.seen[h]
- return ok
-}
-
-func (s *superkoRules) record(h uint64) {
- s.seen[h] = struct{}{}
-}
-
-func superkoHash(b *Board) uint64 {
- h := b.Hash()
- if b.Player() == White {
- h ^= 0x9e3779b97f4a7c15
- }
- return h
-}
-
-func (s *superkoRules) ensureStart(b *Board) {
- if len(s.seen) == 0 {
- s.record(superkoHash(b))
- }
-}
diff --git a/cmd/gofer/testdata/features_golden.json b/cmd/gofer/testdata/features_golden.json
deleted file mode 100644
index 8dc4dd5..0000000
--- a/cmd/gofer/testdata/features_golden.json
+++ /dev/null
@@ -1 +0,0 @@
-{"hash":"082b298ec1f311a7d3100e72cb36de8977dbbd38eb12e86fee748c09661deb9c","board_size":9,"description":"empty 9x9 after black D4, white to move"}
diff --git a/cmd/gofer/testdata/features_golden_v2.json b/cmd/gofer/testdata/features_golden_v2.json
deleted file mode 100644
index 51c908f..0000000
--- a/cmd/gofer/testdata/features_golden_v2.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "schema_version": 2,
- "board_size": 9,
- "hash": "0192a60fb432f3641ca05b26935020f0a619b7e241efa3a7a6b344a62e053e78"
-}
diff --git a/cmd/gofer/tromp_rules.go b/cmd/gofer/tromp_rules.go
deleted file mode 100644
index d6f2f45..0000000
--- a/cmd/gofer/tromp_rules.go
+++ /dev/null
@@ -1,128 +0,0 @@
-package main
-
-type trompRules struct {
- seen map[uint64]struct{}
-}
-
-func newTrompRules() *trompRules {
- return &trompRules{seen: map[uint64]struct{}{}}
-}
-
-func (r *trompRules) LegalMoves(b *Board) []Move {
- r.ensureStart(b)
- size := b.Size()
- n := size * size
- moves := make([]Move, 0, n+1)
- for idx := 0; idx < n; idx++ {
- if b.AtIndex(idx) != Empty {
- continue
- }
- if r.wouldBeLegal(b, idx) {
- moves = append(moves, StoneMove(PointFromIdx(size, idx)))
- }
- }
- moves = append(moves, PassMove)
- return moves
-}
-
-func (r *trompRules) Play(b *Board, m Move) bool {
- r.ensureStart(b)
- if m.Pass {
- b.StartPlay(m, nil, -1, Empty)
- b.FinishTurn(-1)
- pos := trompPositionHash(b)
- if r.repeats(pos) {
- b.Undo()
- return false
- }
- r.record(pos)
- return true
- }
- idx := m.Point.Idx(b.Size())
- if idx < 0 || b.AtIndex(idx) != Empty {
- return false
- }
- player := b.Player()
- prev := b.AtIndex(idx)
- trial := b.Clone()
- trial.SetStoneIndex(idx, player)
- captured := removeDeadGroups(trial, player.Opposite())
- captured = append(captured, removeDeadGroups(trial, player)...)
- trial.FinishTurn(-1)
- pos := trompPositionHash(trial)
- if r.repeats(pos) {
- return false
- }
- b.StartPlay(m, captured, idx, prev)
- b.SetStoneIndex(idx, player)
- for _, cidx := range captured {
- b.SetStoneIndex(cidx, Empty)
- }
- b.FinishTurn(-1)
- r.record(pos)
- return true
-}
-
-// Score returns Tromp-Taylor area scores (komi to white).
-// All on-board stones alive; territory via empty-region flood.
-// Ceiling: no Benson pass-alive removal.
-// Upgrade: Benson pass-alive removal.
-func (r *trompRules) Score(b *Board) (black, white float64) {
- size := b.Size()
- n := size * size
- for i := 0; i < n; i++ {
- switch b.AtIndex(i) {
- case Black:
- black++
- case White:
- white++
- }
- }
- seen := make([]bool, n)
- for i := 0; i < n; i++ {
- if seen[i] || b.AtIndex(i) != Empty {
- continue
- }
- t, tb, tw := floodEmpty(b, i, seen)
- if tb && !tw {
- black += float64(t)
- } else if tw && !tb {
- white += float64(t)
- }
- }
- white += b.Komi()
- return black, white
-}
-
-func (r *trompRules) ensureStart(b *Board) {
- if len(r.seen) == 0 {
- r.record(trompPositionHash(b))
- }
-}
-
-func (r *trompRules) wouldBeLegal(b *Board, idx int) bool {
- player := b.Player()
- trial := b.Clone()
- trial.SetStoneIndex(idx, player)
- removeDeadGroups(trial, player.Opposite())
- removeDeadGroups(trial, player)
- trial.FinishTurn(-1)
- return !r.repeats(trompPositionHash(trial))
-}
-
-func (r *trompRules) repeats(h uint64) bool {
- _, ok := r.seen[h]
- return ok
-}
-
-func (r *trompRules) record(h uint64) {
- r.seen[h] = struct{}{}
-}
-
-func trompPositionHash(b *Board) uint64 {
- h := b.Hash()
- if b.Player() == White {
- h ^= 0x9e3779b97f4a7c15
- }
- return h
-}
diff --git a/cmd/gofer/tt.go b/cmd/gofer/tt.go
deleted file mode 100644
index 8bff6cc..0000000
--- a/cmd/gofer/tt.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package main
-
-// Entry is a transposition table record.
-type Entry struct {
- Depth int
- Value float64
-}
-
-// Table is a Zobrist-keyed transposition table.
-type Table struct {
- slots []Entry
- mask uint64
-}
-
-// NewTable creates a TT with the given slot count (power of two).
-func NewTable(size int) *Table {
- if size < 256 {
- size = 256
- }
- return &Table{
- slots: make([]Entry, size),
- mask: uint64(size - 1),
- }
-}
-
-// Get looks up hash.
-func (t *Table) Get(hash uint64) (Entry, bool) {
- e := t.slots[hash&t.mask]
- if e.Depth == 0 {
- return Entry{}, false
- }
- return e, true
-}
-
-// Store saves an entry (replace always).
-// Replace-always eviction; no depth preference.
-func (t *Table) Store(hash uint64, e Entry) {
- t.slots[hash&t.mask] = e
-}
diff --git a/cmd/gofer/zobrist.go b/cmd/gofer/zobrist.go
deleted file mode 100644
index f0729b0..0000000
--- a/cmd/gofer/zobrist.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package main
-
-import (
- "math/rand"
-)
-
-const maxPoints = 19 * 19
-
-var zobristTable [maxPoints][3]uint64
-
-func init() {
- rng := rand.New(rand.NewSource(0x60eef0bacafe))
- for i := 0; i < maxPoints; i++ {
- for c := Empty; c <= White; c++ {
- zobristTable[i][c] = rng.Uint64()
- }
- }
-}
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..d316020
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,98 @@
+# Architecture
+
+```
+ ┌──────────────── Go engine (bin/gofer) ────────────────┐
+ │ │
+ SGF ──► internal/sgf internal/game ◄── rules, scoring │
+ │ ▲ │
+ │ │ boards │
+ │ internal/nn ◄───┴──── internal/mcts ◄── gtp, play, │
+ │ features, batcher, PUCT search arena, │
+ │ ONNX Runtime backend selfplay │
+ └────────────────────────────────────┬───────────────────┘
+ ▲ gen-N.onnx │ .npz shards, arena JSON
+ │ ▼
+ ┌──────── training/ (PyTorch) ────────────────────────────┐
+ │ loop.py: selfplay → train → export → evaluate → repeat │
+ │ data.py model.py train.py export.py elo.py │
+ └─────────────────────────────────────────────────────────┘
+```
+
+The Go binary does everything that needs to be fast or that plays games;
+Python only trains networks. They communicate through files: ONNX models one
+way, NumPy shards and JSON match results the other.
+
+## Packages
+
+| Package | Responsibility |
+|---------|----------------|
+| `internal/game` | Board with incremental chains and exact liberties (bitsets), legality (suicide, simple ko, positional superko via per-chain Zobrist hashes), Tromp-Taylor area scoring and ownership. |
+| `internal/sgf` | SGF parsing (main line, setup stones) and writing. |
+| `internal/nn` | Feature encoding (schema 3), the `Evaluator` interface, `Net` (batches concurrent requests across worker backends, random symmetry, position cache), ONNX Runtime backend (CPU, CUDA, TensorRT), network-free `Uniform` and `Rollout` evaluators. |
+| `internal/mcts` | PUCT search with virtual-loss leaf batching, Dirichlet root noise, tree reuse, exact terminal scoring and the conservative pass rule. |
+| `internal/selfplay` | Concurrent self-play games with playout-cap randomization, resignation with false-positive tracking, `.npz` shard writer. |
+| `internal/arena` | Matches between any two players (networks, rollout/uniform baselines, external GTP engines such as GNU Go) with paired openings, dead-stone adjudication, Wilson intervals, Elo and SPRT. |
+| `internal/gtp` | GTP v2 server. |
+| `cmd/gofer` | CLI: `gtp`, `play`, `selfplay`, `arena`, `bench`. |
+
+## Search
+
+Each node stores `W`, the sum of backed-up values **from the perspective of
+the player who made the move into that node**, so a parent picks the child
+maximizing `Q + U` directly:
+
+```
+Q = W / N (unvisited: parent value − FPU reduction · sqrt(visited prior mass))
+U = c_puct · P · sqrt(N_parent) / (1 + N)
+```
+
+A network value `v` is for the side to move at the leaf, so the leaf receives
+`−v`, its parent `+v`, alternating to the root. Children are allocated once
+per expansion and never appended to, so node pointers stay valid.
+
+A search gathers up to `Batch` leaves per round, marking each path with a
+virtual loss so the next descent diverges, and evaluates them with a single
+`Evaluator.Eval` call. Many games run concurrently against one `nn.Net`, whose
+workers pull requests from a shared queue, so the network sees large batches
+(GPU) or all cores stay busy (CPU).
+
+Terminal positions (two passes, or the move cap) are scored exactly with area
+scoring. Under the conservative pass rule a player may only pass when the
+opponent just passed, which makes passing an exactly scored terminal move, or
+when every other move would fill one of its own eyes. Games therefore end
+with dead stones captured, and area scoring is exact.
+
+## Network inputs (schema 3)
+
+13 planes of size × size, from the side to move's point of view:
+
+| Plane | Meaning |
+|-------|---------|
+| 0, 1 | own stones, opponent stones |
+| 2 | all ones (lets zero-padded convolutions see the edge) |
+| 3, 4 | own chains with 1 / 2 liberties |
+| 5, 6 | opponent chains with 1 / 2 liberties |
+| 7 | simple-ko point |
+| 8–12 | the last five moves |
+
+4 globals: komi from the side to move's perspective / 15, opponent just
+passed, we passed before that, move number / board area.
+
+Outputs: policy logits over points + pass, value in [−1, 1] for the side to
+move, and (training only) ownership per point.
+
+## Model
+
+`training/model.py`: residual trunk (default 6 blocks × 64 channels) where
+every third block adds a global-pooling bias, a fully convolutional policy
+head with a pooled pass logit, and value/ownership heads on pooled features
+(KataGo-style). Unlike v2's flat fully connected heads it has no
+size-specific dense layers, so it has fewer parameters and generalizes
+across the board.
+
+## Training data
+
+`gofer selfplay` writes `.npz` shards with one row per full-search move:
+`spatial` (uint8 planes), `global`, `policy` (visit distribution), `value`
+(game result for the side to move), `q` (root search value), `ownership` and
+`ownership_weight` (0 for resigned games), `margin`.
diff --git a/docs/backlog-core-engine.md b/docs/archive/backlog-core-engine.md
similarity index 100%
rename from docs/backlog-core-engine.md
rename to docs/archive/backlog-core-engine.md
diff --git a/docs/backlog-ml-integration.md b/docs/archive/backlog-ml-integration.md
similarity index 100%
rename from docs/backlog-ml-integration.md
rename to docs/archive/backlog-ml-integration.md
diff --git a/docs/backlog-optimization.md b/docs/archive/backlog-optimization.md
similarity index 100%
rename from docs/backlog-optimization.md
rename to docs/archive/backlog-optimization.md
diff --git a/docs/backlog-search.md b/docs/archive/backlog-search.md
similarity index 100%
rename from docs/backlog-search.md
rename to docs/archive/backlog-search.md
diff --git a/docs/decisions/0001-inference-backend.md b/docs/archive/decisions/0001-inference-backend.md
similarity index 100%
rename from docs/decisions/0001-inference-backend.md
rename to docs/archive/decisions/0001-inference-backend.md
diff --git a/docs/decisions/0002-legal-moves-repr.md b/docs/archive/decisions/0002-legal-moves-repr.md
similarity index 100%
rename from docs/decisions/0002-legal-moves-repr.md
rename to docs/archive/decisions/0002-legal-moves-repr.md
diff --git a/docs/decisions/0003-iterative-training-loop.md b/docs/archive/decisions/0003-iterative-training-loop.md
similarity index 100%
rename from docs/decisions/0003-iterative-training-loop.md
rename to docs/archive/decisions/0003-iterative-training-loop.md
diff --git a/docs/decisions/0004-in-process-onnx-inference.md b/docs/archive/decisions/0004-in-process-onnx-inference.md
similarity index 100%
rename from docs/decisions/0004-in-process-onnx-inference.md
rename to docs/archive/decisions/0004-in-process-onnx-inference.md
diff --git a/docs/decisions/0005-net-size-ablation.md b/docs/archive/decisions/0005-net-size-ablation.md
similarity index 100%
rename from docs/decisions/0005-net-size-ablation.md
rename to docs/archive/decisions/0005-net-size-ablation.md
diff --git a/docs/failure-modes.md b/docs/archive/failure-modes.md
similarity index 100%
rename from docs/failure-modes.md
rename to docs/archive/failure-modes.md
diff --git a/docs/go-engineering-standard.md b/docs/archive/go-engineering-standard.md
similarity index 100%
rename from docs/go-engineering-standard.md
rename to docs/archive/go-engineering-standard.md
diff --git a/docs/implementation-blueprint.md b/docs/archive/implementation-blueprint.md
similarity index 100%
rename from docs/implementation-blueprint.md
rename to docs/archive/implementation-blueprint.md
diff --git a/docs/known-issues.md b/docs/archive/known-issues.md
similarity index 100%
rename from docs/known-issues.md
rename to docs/archive/known-issues.md
diff --git a/docs/model-input-schema.md b/docs/archive/model-input-schema.md
similarity index 100%
rename from docs/model-input-schema.md
rename to docs/archive/model-input-schema.md
diff --git a/docs/optimization-framework.md b/docs/archive/optimization-framework.md
similarity index 100%
rename from docs/optimization-framework.md
rename to docs/archive/optimization-framework.md
diff --git a/docs/optimization-scorecard.md b/docs/archive/optimization-scorecard.md
similarity index 100%
rename from docs/optimization-scorecard.md
rename to docs/archive/optimization-scorecard.md
diff --git a/docs/plans/ml-pipeline-v3-handoff-prompt.md b/docs/archive/plans/ml-pipeline-v3-handoff-prompt.md
similarity index 100%
rename from docs/plans/ml-pipeline-v3-handoff-prompt.md
rename to docs/archive/plans/ml-pipeline-v3-handoff-prompt.md
diff --git a/docs/plans/ml-pipeline-v3.md b/docs/archive/plans/ml-pipeline-v3.md
similarity index 100%
rename from docs/plans/ml-pipeline-v3.md
rename to docs/archive/plans/ml-pipeline-v3.md
diff --git a/docs/research-traceability.md b/docs/archive/research-traceability.md
similarity index 100%
rename from docs/research-traceability.md
rename to docs/archive/research-traceability.md
diff --git a/docs/research-understanding.md b/docs/archive/research-understanding.md
similarity index 100%
rename from docs/research-understanding.md
rename to docs/archive/research-understanding.md
diff --git a/docs/results/config.json b/docs/results/config.json
new file mode 100644
index 0000000..6b6316f
--- /dev/null
+++ b/docs/results/config.json
@@ -0,0 +1,37 @@
+{
+ "board_size": 9,
+ "komi": 7.5,
+ "blocks": 6,
+ "channels": 64,
+ "games_per_gen": 600,
+ "full_visits": 160,
+ "fast_visits": 32,
+ "full_prob": 0.25,
+ "temp_moves": 10,
+ "resign": -0.9,
+ "no_resign_prob": 0.1,
+ "window_min": 60000,
+ "window_max": 400000,
+ "window_growth": 25000,
+ "sample_reuse": 8.0,
+ "total_gens": 30,
+ "train": {
+ "batch_size": 256,
+ "lr": 0.002,
+ "weight_decay": 0.0001,
+ "value_weight": 1.0,
+ "ownership_weight": 0.15,
+ "q_weight": 0.0,
+ "grad_clip": 5.0,
+ "warmup_steps": 200
+ },
+ "eval_games": 40,
+ "eval_visits": 100,
+ "anchors": [
+ "rollout,visits=800",
+ "gnugo,level=1",
+ "gnugo,level=6"
+ ],
+ "anchor_every": 2,
+ "anchor_games": 40
+}
\ No newline at end of file
diff --git a/docs/results/elo.png b/docs/results/elo.png
new file mode 100644
index 0000000..e4e19e8
Binary files /dev/null and b/docs/results/elo.png differ
diff --git a/docs/results/ratings.json b/docs/results/ratings.json
new file mode 100644
index 0000000..0c655e2
--- /dev/null
+++ b/docs/results/ratings.json
@@ -0,0 +1,107 @@
+{
+ "gen-0000": {
+ "elo": 0.0,
+ "games": 40,
+ "stderr": 0.0
+ },
+ "gen-0001": {
+ "elo": 763.3940073805803,
+ "games": 160,
+ "stderr": 90.32946892037053
+ },
+ "gen-0002": {
+ "elo": 1176.4715669602217,
+ "games": 160,
+ "stderr": 73.81305814945618
+ },
+ "gen-0003": {
+ "elo": 1669.7666036027763,
+ "games": 80,
+ "stderr": 50.25000097480553
+ },
+ "gen-0004": {
+ "elo": 1742.6073799562243,
+ "games": 160,
+ "stderr": 34.796949159384184
+ },
+ "gen-0005": {
+ "elo": 1768.045872328535,
+ "games": 80,
+ "stderr": 39.425048334232315
+ },
+ "gen-0006": {
+ "elo": 1882.1079413365992,
+ "games": 160,
+ "stderr": 32.73492119876937
+ },
+ "gen-0007": {
+ "elo": 1975.2003295194406,
+ "games": 80,
+ "stderr": 39.17739092913291
+ },
+ "gen-0008": {
+ "elo": 2015.4621326430124,
+ "games": 160,
+ "stderr": 29.200905627421633
+ },
+ "gen-0009": {
+ "elo": 1974.9307000534707,
+ "games": 80,
+ "stderr": 40.3343592472965
+ },
+ "gen-0010": {
+ "elo": 2130.665929694131,
+ "games": 200,
+ "stderr": 27.97778478579719
+ },
+ "gen-0011": {
+ "elo": 2184.375438386014,
+ "games": 80,
+ "stderr": 38.82720475049615
+ },
+ "gen-0012": {
+ "elo": 2238.0849471208776,
+ "games": 200,
+ "stderr": 27.258795134701302
+ },
+ "gen-0013": {
+ "elo": 2297.0465557174043,
+ "games": 80,
+ "stderr": 39.13462544489769
+ },
+ "gen-0014": {
+ "elo": 2218.5553272711354,
+ "games": 200,
+ "stderr": 27.21196962621655
+ },
+ "gen-0015": {
+ "elo": 2242.6019976273933,
+ "games": 80,
+ "stderr": 38.68393778761934
+ },
+ "gen-0016": {
+ "elo": 2301.051741845453,
+ "games": 200,
+ "stderr": 27.627134856129075
+ },
+ "gen-0017": {
+ "elo": 2318.013281236098,
+ "games": 40,
+ "stderr": 54.32499504087376
+ },
+ "gnugo-l1": {
+ "elo": 2180.144358993228,
+ "games": 360,
+ "stderr": 23.26390961812325
+ },
+ "gnugo-l6": {
+ "elo": 2240.7264275458456,
+ "games": 360,
+ "stderr": 23.887733444293715
+ },
+ "rollout": {
+ "elo": 1448.0400098431633,
+ "games": 160,
+ "stderr": 123.69195602119223
+ }
+}
\ No newline at end of file
diff --git a/docs/results/summary.md b/docs/results/summary.md
new file mode 100644
index 0000000..40ea899
--- /dev/null
+++ b/docs/results/summary.md
@@ -0,0 +1,24 @@
+| Gen | Elo | Self-play games/s | Avg moves | Rows | Policy loss* | Value loss* | Anchors (W-L) |
+|----:|----:|------------------:|----------:|-----:|-------------:|------------:|:--------------|
+| 0 | +0 ± 0 | 0.74 | 106 | 15,850 | 4.413 | 1.004 | |
+| 1 | +763 ± 90 | 1.08 | 88 | 13,107 | 3.336 | 1.144 | gnugo-l1 0-40, gnugo-l6 0-40 |
+| 2 | +1176 ± 74 | 1.17 | 80 | 12,150 | 3.394 | 0.780 | gnugo-l1 0-40, gnugo-l6 0-40 |
+| 3 | +1670 ± 50 | 1.18 | 78 | 11,836 | 3.251 | 0.705 | |
+| 4 | +1743 ± 35 | 1.24 | 75 | 11,307 | 3.067 | 0.815 | gnugo-l1 4-36, gnugo-l6 3-37 |
+| 5 | +1768 ± 39 | 1.23 | 76 | 11,530 | 2.784 | 0.764 | |
+| 6 | +1882 ± 33 | 1.26 | 73 | 11,158 | 2.640 | 0.924 | gnugo-l1 1-39, gnugo-l6 6-34 |
+| 7 | +1975 ± 39 | 1.21 | 73 | 10,934 | 2.656 | 0.865 | |
+| 8 | +2015 ± 29 | 1.20 | 76 | 11,489 | 2.775 | 0.786 | gnugo-l1 8-32, gnugo-l6 8-32 |
+| 9 | +1975 ± 40 | 1.35 | 70 | 10,527 | 2.747 | 0.874 | |
+| 10 | +2131 ± 28 | 1.17 | 76 | 11,379 | 2.455 | 0.781 | gnugo-l1 19-21, gnugo-l6 12-28, rollout 40-0 |
+| 11 | +2184 ± 39 | 1.28 | 73 | 11,086 | 2.534 | 0.723 | |
+| 12 | +2238 ± 27 | 1.25 | 75 | 11,352 | 2.581 | 0.804 | gnugo-l1 26-14, gnugo-l6 20-20, rollout 40-0 |
+| 13 | +2297 ± 39 | 1.30 | 71 | 10,832 | 2.471 | 0.753 | |
+| 14 | +2219 ± 27 | 1.29 | 71 | 10,786 | 2.496 | 0.762 | gnugo-l1 24-16, gnugo-l6 17-23, rollout 40-0 |
+| 15 | +2243 ± 39 | 1.35 | 71 | 10,806 | 2.458 | 0.827 | |
+| 16 | +2301 ± 28 | 1.28 | 73 | 10,992 | 2.392 | 0.777 | gnugo-l1 26-14, gnugo-l6 23-17, rollout 40-0 |
+| 17 | +2318 ± 54 | 0.00 | 0 | 0 | nan | nan | |
+
+Anchors: gnugo-l1 +2180 ± 23, gnugo-l6 +2241 ± 24, rollout +1448 ± 124
+
+\* loss of that generation's network on the self-play data it generated, measured before training on it.
diff --git a/docs/training.md b/docs/training.md
new file mode 100644
index 0000000..e2898a2
--- /dev/null
+++ b/docs/training.md
@@ -0,0 +1,79 @@
+# Training
+
+## Setup
+
+```bash
+scripts/setup.sh # CPU machine: GNU Go, ONNX Runtime, .venv, bin/gofer
+scripts/setup.sh gpu # NVIDIA machine: GPU ONNX Runtime and CUDA PyTorch
+make test
+```
+
+## Run
+
+```bash
+.venv/bin/python -m training.loop --run runs/9x9 --gofer bin/gofer --hours 8
+```
+
+The run directory holds everything; rerun the same command to resume after an
+interruption (each phase records completion in `state.json` only after its
+outputs exist).
+
+```
+runs/9x9/
+ config.json frozen LoopConfig (edit before the first generation, or use --set)
+ state.json current generation, completed phases, all match results
+ log.txt human-readable log
+ metrics.jsonl per-generation self-play, training and evaluation metrics
+ models/ gen-NNNN.onnx (gen-0000 is the random initial network)
+ ckpt/ gen-NNNN.pt (model + optimizer state)
+ data/gen-NNNN/ self-play shards (.npz)
+ sgf/gen-NNNN/ every self-play game
+ eval/ arena results (JSON) and ratings.json
+```
+
+Each generation:
+
+1. **Self-play** `games_per_gen` games with the current network. Every move
+ gets either a full search (`full_visits`, Dirichlet noise, recorded as a
+ training row) with probability `full_prob`, or a cheap one (`fast_visits`,
+ not recorded): KataGo's playout-cap randomization.
+2. **Train** on a replay window of the newest rows (growing from `window_min`
+ by `window_growth` per generation to `window_max`), drawing
+ `sample_reuse` samples per new row, with a random board symmetry per
+ sample. The loss on the new data *before* training (logged as
+ "previous net on new data") is an honest held-out metric.
+3. **Export** the network to ONNX and verify it against PyTorch.
+4. **Evaluate**: new vs previous generation, and every `anchor_every`
+ generations against fixed anchors (GNU Go by default). All results feed a
+ Bradley-Terry fit in `eval/ratings.json` with `gen-0000` at 0 Elo.
+
+There is no gating: like AlphaZero and KataGo, the newest network always
+plays the next self-play. (`gofer arena -sprt 0,35` is available if you want
+a promotion gate.)
+
+Override settings when creating a run, e.g.
+`--set games_per_gen=1000 --set full_visits=400 --set train.lr=1e-3`.
+
+## Throughput
+
+`bin/gofer bench -model runs/9x9/models/gen-0000.onnx` measures playouts per
+second with 64 concurrent searches. On a 4-vCPU cloud VM (CPU inference):
+
+| Network | Parameters | Playouts/s | Network positions/s |
+|---------|-----------:|-----------:|--------------------:|
+| 4×48 | 185k | 13,100 | 12,000 |
+| 6×64 | 477k | 6,400 | 6,000 |
+| 10×96 | 1.7M | 2,100 | 1,800 |
+
+Search itself costs about 4 µs per playout (500k playouts/s on 4 cores with a
+free evaluator), so inference dominates and throughput scales with cores. On
+a GPU, use `-device cuda` (or `tensorrt`), and give the self-play enough
+concurrent games to fill batches: `gofer selfplay -device cuda -workers 2
+-batch 512 -parallel 1024`.
+
+## Choosing a machine
+
+Self-play inference is the bottleneck. With the 6×64 network on CPU, cost is
+roughly proportional to cores, so a 32-vCPU CPU-optimized VM is about 8× the
+4-vCPU numbers above. A GPU becomes worth it as the network grows (10×128 and
+up) or for 13×13/19×19, where CPU inference falls far behind.
diff --git a/docs/v2-audit.md b/docs/v2-audit.md
new file mode 100644
index 0000000..38f85cd
--- /dev/null
+++ b/docs/v2-audit.md
@@ -0,0 +1,49 @@
+# v2 audit: why training did not work
+
+Gofer v3 is a rewrite of the engine core and the training pipeline. This page
+records what was wrong with v2 (commit `6083eb3`) and how each problem was
+confirmed, so the reasons for the rewrite are reproducible rather than
+anecdotal. Each item was measured on a 4-vCPU machine with the v2 code.
+
+## Search (the root cause)
+
+| # | Defect | Evidence | Effect |
+|---|--------|----------|--------|
+| 1 | **Values were backed up with the wrong sign.** A leaf stored the network value from *its own* side-to-move perspective, and the parent maximized it, so every node chose the move best for the opponent. | Position with Black far ahead: every Black move showed Q = -1 after one visit; the search put 738 of 800 visits on pass. | The engine played the opponent's best moves; self-play visit counts (the policy targets) pointed at bad moves. |
+| 2 | **The root lost its `Expanded` flag on every fresh search.** `expandLocked` held a `*Node` into the arena slice, appended 82 children (capacity 64 → reallocation), then wrote `Expanded = true` into the discarded copy. The same happened to whichever node expanded when the arena crossed 128, 256, 512... | Fresh 400-playout search: 1 network evaluation, 0 child visits. Over GTP with 800 playouts it opened A9, B9, A8, C9, A7, which is just scan order. | No search on the first moves of every game; random nodes silently stopped growing. |
+| 3 | **Transposition table without key check.** `Get` returned whatever entry sat in `hash & mask`. Pass leaves stones unchanged and the hash ignored side to move, so pass collided with its parent. | Pass child received the root's own value (+0.875 in the position above). | Wrong values for collisions; pass looked as good as the current position. |
+| 4 | **Forced playouts flattened policy targets.** Every root child was forced to `2 + sqrt(prior·N)` visits — about 246 forced visits against a 200-playout budget — and the pruning step removed nothing. | Exported "full search" policy targets were near-uniform. | The policy head was trained toward uniform. |
+| 5 | **The network ran on one core.** One batching goroutine ran batches serially and ONNX Runtime was pinned to one thread. | Self-play used 1.1–1.25 of 4 cores regardless of parallelism (590 → 780 games/h from 1 to 16 parallel games). | A bigger machine would not have trained faster. |
+
+## Rules and data
+
+| # | Defect | Effect |
+|---|--------|--------|
+| 6 | Legal-move generation restored a full board copy and rescanned every opponent group per candidate (≈O(n³)); each playout cloned the full undo history. | ~0.5 ms of Go-side work per network evaluation, comparable to the network itself. |
+| 7 | Superko and Tromp-Taylor rules kept a global `seen` set inside the ruleset object, which search playouts wrote into. | Positions visited during search became "illegal" for the real game. |
+| 8 | Simple ko was set after *any* single-stone capture, even by a stone connected to a group. | Legal moves were forbidden. |
+| 9 | Self-play and arena games were cut off at 83 moves (board area + 2) and scored unfinished with area scoring and no dead-stone removal. | Noisy or wrong value and ownership targets. |
+| 10 | 30% of self-play games used a hand-written heuristic evaluator. | Training data mixed in a much weaker policy. |
+| 11 | No pass features: the network could not tell that the opponent had just passed. | Pass decisions were blind. |
+| 12 | No symmetry augmentation, a constant learning rate, JSONL float features parsed into Python objects. | 8× less effective data; slow loading. |
+| 13 | Arena gating (200 games at 400 playouts) cost about 5× the self-play that produced the cycle's data. | Most of each cycle's compute was spent on evaluation. |
+
+## Fixed in v3 along the way
+
+- ONNX export traced BatchNorm in training mode when the module wrapper was
+ not put in `eval()`; the exported network did not match PyTorch (max error
+ 0.86) and the export silently switched the model back to training mode. v3
+ verifies every export against PyTorch.
+- Random networks ended self-play games by passing: Black passed at some
+ point, White passed back and won on komi (Black won 0% of early games). v3
+ only searches pass when the opponent just passed or when every other move
+ fills an own eye, so games are played to a settled, exactly scored end.
+
+## Regression tests
+
+Each search defect has a test in `internal/mcts/search_test.go`
+(`TestFindsCapture`, `TestPassesToWinAfterOpponentPass`,
+`TestDoesNotPassWhenLosing`, `TestVisitAccountingAndReuse`,
+`TestConservativePass`). The board is differential-tested against a naive
+flood-fill reference on random games from 5×5 to 19×19, with and without
+superko (`internal/game/board_test.go`).
diff --git a/Go ML Paper.pdf b/docs/wu-2020-accelerating-self-play.pdf
similarity index 100%
rename from Go ML Paper.pdf
rename to docs/wu-2020-accelerating-self-play.pdf
diff --git a/internal/arena/arena_test.go b/internal/arena/arena_test.go
new file mode 100644
index 0000000..5bbb987
--- /dev/null
+++ b/internal/arena/arena_test.go
@@ -0,0 +1,70 @@
+package arena
+
+import (
+ "math"
+ "os"
+ "testing"
+
+ "github.com/DevomB/gofer/internal/mcts"
+ "github.com/DevomB/gofer/internal/nn"
+)
+
+func TestStrongerSearchWins(t *testing.T) {
+ cfg := Config{Size: 5, Komi: 0.5, Games: 20, Parallel: 4, OpeningMoves: 1, Seed: 1}
+ strong := func() (Player, error) {
+ return &MCTSPlayer{Label: "rollout-200", Eval: nn.NewRollout(2, 1), Config: mcts.DefaultConfig(), Visits: 200}, nil
+ }
+ weak := func() (Player, error) {
+ return &MCTSPlayer{Label: "uniform-2", Eval: nn.Uniform{}, Config: mcts.DefaultConfig(), Visits: 2}, nil
+ }
+ res, err := Run(cfg, strong, weak)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(res.Games) != 20 || res.Score < 0.8 {
+ t.Fatalf("expected the rollout player to dominate: %+v", res)
+ }
+ if res.ScoreLow > res.Score || res.ScoreHigh < res.Score || res.EloLow > res.Elo {
+ t.Fatalf("bad confidence interval %+v", res)
+ }
+}
+
+func TestSPRTStopsEarly(t *testing.T) {
+ r := &Result{}
+ for i := 0; i < 40; i++ {
+ r.Games = append(r.Games, Game{Winner: "A", AColor: "B", Color: "B"})
+ }
+ r.Games = append(r.Games, Game{Winner: "B", AColor: "B", Color: "W"})
+ r.summarize()
+ _, d := (&SPRT{Elo0: 0, Elo1: 35, Alpha: 0.05, Beta: 0.05}).test(r)
+ if d != "H1" {
+ t.Fatalf("40-1 should accept H1, got %q", d)
+ }
+}
+
+func TestWilson(t *testing.T) {
+ lo, hi := wilson(0.5, 100, 1.96)
+ if math.Abs(lo-0.404) > 0.002 || math.Abs(hi-0.596) > 0.002 {
+ t.Fatalf("wilson(0.5,100) = %v %v", lo, hi)
+ }
+}
+
+func TestGNUGo(t *testing.T) {
+ if _, err := os.Stat("/usr/games/gnugo"); err != nil {
+ t.Skip("gnugo not installed")
+ }
+ cfg := Config{Size: 9, Komi: 7.5, Games: 2, Parallel: 2, Seed: 1}
+ gnugo := func() (Player, error) {
+ return NewGTPPlayer("gnugo-l1", []string{"/usr/games/gnugo", "--mode", "gtp", "--level", "1"}), nil
+ }
+ weak := func() (Player, error) {
+ return &MCTSPlayer{Label: "uniform-1", Eval: nn.Uniform{}, Config: mcts.DefaultConfig(), Visits: 1}, nil
+ }
+ res, err := Run(cfg, gnugo, weak)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Wins != 2 {
+ t.Fatalf("gnugo should beat a 1-visit uniform player: %+v", res.Games)
+ }
+}
diff --git a/internal/arena/match.go b/internal/arena/match.go
new file mode 100644
index 0000000..f64b342
--- /dev/null
+++ b/internal/arena/match.go
@@ -0,0 +1,358 @@
+package arena
+
+import (
+ "fmt"
+ "math"
+ "math/rand"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "github.com/DevomB/gofer/internal/game"
+ "github.com/DevomB/gofer/internal/sgf"
+)
+
+// Config controls a match between players A and B.
+type Config struct {
+ Size, MaxMoves int
+ Komi float64
+ Superko bool
+ Games int // rounded up to an even number: each opening is played with both colors
+ Parallel int
+ OpeningMoves int // random opening plies shared by each pair of games
+ Seed int64
+ SGFDir string
+ SPRT *SPRT // optional early stop for gating
+ Log func(format string, args ...any)
+}
+
+// SPRT is a sequential probability ratio test on A's Elo advantage.
+type SPRT struct {
+ Elo0, Elo1 float64 // H0: elo <= Elo0, H1: elo >= Elo1
+ Alpha, Beta float64
+}
+
+// Game is the outcome of one game.
+type Game struct {
+ Index int `json:"index"`
+ AColor string `json:"a_color"`
+ Winner string `json:"winner"` // "A", "B" or "draw"
+ Color string `json:"winner_color"` // "B", "W" or "." for a draw
+ Margin float64 `json:"margin"` // black minus white, after dead-stone removal
+ Moves int `json:"moves"`
+ Reason string `json:"reason"` // score, resign, max_moves
+ Duration float64 `json:"seconds"`
+}
+
+// Result summarizes a match from A's point of view.
+type Result struct {
+ NameA string `json:"a"`
+ NameB string `json:"b"`
+ Games []Game `json:"games"`
+ Wins int `json:"wins"`
+ Losses int `json:"losses"`
+ Draws int `json:"draws"`
+ Score float64 `json:"score"` // (wins + draws/2) / games
+ ScoreLow float64 `json:"score_ci_low"`
+ ScoreHigh float64 `json:"score_ci_high"`
+ Elo float64 `json:"elo"`
+ EloLow float64 `json:"elo_ci_low"`
+ EloHigh float64 `json:"elo_ci_high"`
+ WinsAsBlack int `json:"wins_as_black"`
+ WinsAsWhite int `json:"wins_as_white"`
+ BlackWinRate float64 `json:"black_win_rate"`
+ LLR float64 `json:"llr,omitempty"`
+ SPRTDecision string `json:"sprt,omitempty"` // "H1" (A stronger), "H0", or "" (undecided)
+ ElapsedSecond float64 `json:"elapsed_seconds"`
+}
+
+// Run plays the match. newA/newB build a fresh player for each game.
+func Run(cfg Config, newA, newB func() (Player, error)) (*Result, error) {
+ if cfg.Games%2 == 1 {
+ cfg.Games++
+ }
+ if cfg.Parallel < 1 {
+ cfg.Parallel = 1
+ }
+ if cfg.MaxMoves <= 0 {
+ cfg.MaxMoves = 3 * cfg.Size * cfg.Size
+ }
+ if cfg.Log == nil {
+ cfg.Log = func(string, ...any) {}
+ }
+ if cfg.SGFDir != "" {
+ os.MkdirAll(cfg.SGFDir, 0o755)
+ }
+ start := time.Now()
+ res := &Result{}
+ var (
+ mu sync.Mutex
+ next int
+ stopped bool
+ errOut error
+ wg sync.WaitGroup
+ )
+ openings := make([][]game.Point, cfg.Games/2)
+ rng := rand.New(rand.NewSource(cfg.Seed))
+ for i := range openings {
+ openings[i] = randomOpening(cfg, rng)
+ }
+ for w := 0; w < cfg.Parallel; w++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for {
+ mu.Lock()
+ if next >= cfg.Games || stopped || errOut != nil {
+ mu.Unlock()
+ return
+ }
+ g := next
+ next++
+ mu.Unlock()
+
+ // External engines occasionally crash (GNU Go 3.8 has known
+ // assertion failures); replay the game with fresh players
+ // before giving up on the match.
+ var (
+ out Game
+ nameA, nameB string
+ err error
+ )
+ for attempt := 0; attempt < 3; attempt++ {
+ if out, nameA, nameB, err = playOne(cfg, g, openings[g/2], newA, newB); err == nil {
+ break
+ }
+ cfg.Log("arena: game %d attempt %d failed: %v", g, attempt+1, err)
+ }
+ mu.Lock()
+ if err != nil {
+ errOut = err
+ mu.Unlock()
+ return
+ }
+ res.NameA, res.NameB = nameA, nameB
+ res.Games = append(res.Games, out)
+ res.summarize()
+ if cfg.SPRT != nil {
+ res.LLR, res.SPRTDecision = cfg.SPRT.test(res)
+ if res.SPRTDecision != "" {
+ stopped = true
+ }
+ }
+ if len(res.Games)%10 == 0 || stopped {
+ cfg.Log("arena: %d/%d games %s %d-%d-%d vs %s score %.3f elo %+.0f [%+.0f, %+.0f]",
+ len(res.Games), cfg.Games, nameA, res.Wins, res.Losses, res.Draws, nameB, res.Score, res.Elo, res.EloLow, res.EloHigh)
+ }
+ mu.Unlock()
+ }
+ }()
+ }
+ wg.Wait()
+ res.ElapsedSecond = time.Since(start).Seconds()
+ return res, errOut
+}
+
+// randomOpening plays a few random legal moves away from the first line.
+func randomOpening(cfg Config, rng *rand.Rand) []game.Point {
+ b := game.NewBoard(game.Options{Size: cfg.Size, Komi: cfg.Komi, Superko: cfg.Superko})
+ var moves []game.Point
+ for len(moves) < cfg.OpeningMoves {
+ var cand []game.Point
+ for p := game.Point(0); int(p) < b.NumPoints(); p++ {
+ x, y := b.XY(p)
+ if x > 0 && y > 0 && x < cfg.Size-1 && y < cfg.Size-1 && b.IsLegal(p) {
+ cand = append(cand, p)
+ }
+ }
+ if len(cand) == 0 {
+ break
+ }
+ m := cand[rng.Intn(len(cand))]
+ b.Play(m)
+ moves = append(moves, m)
+ }
+ return moves
+}
+
+func playOne(cfg Config, idx int, opening []game.Point, newA, newB func() (Player, error)) (Game, string, string, error) {
+ t0 := time.Now()
+ a, err := newA()
+ if err != nil {
+ return Game{}, "", "", err
+ }
+ defer a.Close()
+ bp, err := newB()
+ if err != nil {
+ return Game{}, "", "", err
+ }
+ defer bp.Close()
+ aBlack := idx%2 == 0
+ black, white := a, bp
+ if !aBlack {
+ black, white = bp, a
+ }
+ b := game.NewBoard(game.Options{Size: cfg.Size, Komi: cfg.Komi, Superko: cfg.Superko})
+ for _, p := range []Player{black, white} {
+ if err := p.NewGame(b.Clone()); err != nil {
+ return Game{}, "", "", err
+ }
+ }
+ var record []sgf.Move
+ play := func(m game.Point) error {
+ c := b.ToPlay()
+ if err := b.Play(m); err != nil {
+ return err
+ }
+ record = append(record, sgf.Move{Color: c, Point: m})
+ for _, p := range []Player{black, white} {
+ if err := p.Observe(c, m, b); err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+ for _, m := range opening {
+ if err := play(m); err != nil {
+ return Game{}, "", "", err
+ }
+ }
+ out := Game{Index: idx, AColor: "B", Reason: "score"}
+ if !aBlack {
+ out.AColor = "W"
+ }
+ var winner game.Color
+ for {
+ if b.GameOver() {
+ break
+ }
+ if b.MoveNum() >= cfg.MaxMoves {
+ out.Reason = "max_moves"
+ break
+ }
+ mover := black
+ if b.ToPlay() == game.White {
+ mover = white
+ }
+ m, resign, err := mover.GenMove(b)
+ if err != nil {
+ return Game{}, "", "", err
+ }
+ if resign {
+ out.Reason = "resign"
+ winner = b.ToPlay().Opp()
+ break
+ }
+ if err := play(m); err != nil {
+ return Game{}, "", "", fmt.Errorf("arena: %s: %w", mover.Name(), err)
+ }
+ }
+ if out.Reason != "resign" {
+ final := b
+ // The first player able to judge dead stones (e.g. GNU Go) adjudicates.
+ for _, p := range []Player{black, white} {
+ if j, ok := p.(DeadStoneJudge); ok {
+ if dead, err := j.DeadStones(); err == nil {
+ final = b.WithoutStones(dead)
+ break
+ }
+ }
+ }
+ out.Margin = final.Margin()
+ winner = final.Winner()
+ }
+ out.Color = winner.String()
+ switch {
+ case winner == game.Empty:
+ out.Winner = "draw"
+ case (winner == game.Black) == aBlack:
+ out.Winner = "A"
+ default:
+ out.Winner = "B"
+ }
+ out.Moves = b.MoveNum()
+ out.Duration = time.Since(t0).Seconds()
+ if cfg.SGFDir != "" {
+ result := "0"
+ if winner != game.Empty {
+ if out.Reason == "resign" {
+ result = winner.String() + "+R"
+ } else {
+ result = fmt.Sprintf("%s+%g", winner, math.Abs(out.Margin))
+ }
+ }
+ rec := sgf.Record{Size: cfg.Size, Komi: cfg.Komi, Black: black.Name(), White: white.Name(), Result: result, Moves: record}
+ os.WriteFile(filepath.Join(cfg.SGFDir, fmt.Sprintf("game-%04d.sgf", idx)), []byte(sgf.Write(rec)), 0o644)
+ }
+ return out, a.Name(), bp.Name(), nil
+}
+
+func (r *Result) summarize() {
+ r.Wins, r.Losses, r.Draws, r.WinsAsBlack, r.WinsAsWhite = 0, 0, 0, 0, 0
+ blackWins := 0
+ for _, g := range r.Games {
+ switch g.Winner {
+ case "A":
+ r.Wins++
+ if g.AColor == "B" {
+ r.WinsAsBlack++
+ } else {
+ r.WinsAsWhite++
+ }
+ case "B":
+ r.Losses++
+ default:
+ r.Draws++
+ }
+ if g.Color == "B" {
+ blackWins++
+ }
+ }
+ n := float64(len(r.Games))
+ r.Score = (float64(r.Wins) + 0.5*float64(r.Draws)) / n
+ r.ScoreLow, r.ScoreHigh = wilson(r.Score, n, 1.96)
+ r.Elo, r.EloLow, r.EloHigh = elo(r.Score), elo(r.ScoreLow), elo(r.ScoreHigh)
+ r.BlackWinRate = float64(blackWins) / n
+}
+
+// wilson returns the Wilson score interval for proportion p over n trials.
+func wilson(p, n, z float64) (lo, hi float64) {
+ if n == 0 {
+ return 0, 1
+ }
+ den := 1 + z*z/n
+ c := (p + z*z/(2*n)) / den
+ h := z * math.Sqrt(p*(1-p)/n+z*z/(4*n*n)) / den
+ return math.Max(0, c-h), math.Min(1, c+h)
+}
+
+// elo converts an expected score to an Elo difference (clamped at +-800).
+func elo(s float64) float64 {
+ s = math.Min(math.Max(s, 0.01), 0.99)
+ return -400 * math.Log10(1/s-1)
+}
+
+func (t *SPRT) test(r *Result) (float64, string) {
+ n := float64(len(r.Games))
+ if n < 2 {
+ return 0, ""
+ }
+ s := r.Score
+ v := (float64(r.Wins)*(1-s)*(1-s) + float64(r.Losses)*s*s + float64(r.Draws)*(0.5-s)*(0.5-s)) / n
+ if v <= 0 {
+ v = 1e-3
+ }
+ s0 := 1 / (1 + math.Pow(10, -t.Elo0/400))
+ s1 := 1 / (1 + math.Pow(10, -t.Elo1/400))
+ llr := (s1 - s0) * (2*s - s0 - s1) * n / (2 * v)
+ lower := math.Log(t.Beta / (1 - t.Alpha))
+ upper := math.Log((1 - t.Beta) / t.Alpha)
+ switch {
+ case llr >= upper:
+ return llr, "H1"
+ case llr <= lower:
+ return llr, "H0"
+ }
+ return llr, ""
+}
diff --git a/internal/arena/players.go b/internal/arena/players.go
new file mode 100644
index 0000000..c3ba526
--- /dev/null
+++ b/internal/arena/players.go
@@ -0,0 +1,201 @@
+// Package arena plays matches between engines (Gofer networks, network-free
+// baselines, or external GTP programs such as GNU Go) and reports results
+// with confidence intervals, Elo estimates and an optional SPRT stop.
+package arena
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "os/exec"
+ "strings"
+
+ "github.com/DevomB/gofer/internal/game"
+ "github.com/DevomB/gofer/internal/mcts"
+ "github.com/DevomB/gofer/internal/nn"
+)
+
+// Player is one side of a game. A fresh Player is created for every game.
+type Player interface {
+ Name() string
+ // NewGame starts a game on the given empty board; opening moves arrive via Observe.
+ NewGame(b *game.Board) error
+ // GenMove chooses a move for the side to move in b; resign reports resignation.
+ GenMove(b *game.Board) (m game.Point, resign bool, err error)
+ // Observe is called after every move by either side with the new position.
+ Observe(c game.Color, m game.Point, after *game.Board) error
+ Close() error
+}
+
+// DeadStoneJudge is implemented by players that can mark dead stones at game end.
+type DeadStoneJudge interface {
+ DeadStones() ([]game.Point, error)
+}
+
+// MCTSPlayer searches with a network (or network-free) evaluator.
+type MCTSPlayer struct {
+ Label string
+ Eval nn.Evaluator
+ Config mcts.Config
+ Visits int
+ // ResignBelow resigns when the root value stays below it (0 = never).
+ ResignBelow float64
+
+ s *mcts.Search
+}
+
+func (p *MCTSPlayer) Name() string { return p.Label }
+
+func (p *MCTSPlayer) NewGame(b *game.Board) error {
+ p.s = mcts.New(p.Config, p.Eval)
+ p.s.Reset(b)
+ return nil
+}
+
+func (p *MCTSPlayer) GenMove(b *game.Board) (game.Point, bool, error) {
+ if p.s.Board().Key() != b.Key() {
+ p.s.Reset(b)
+ }
+ if err := p.s.Run(p.Visits); err != nil {
+ return game.Pass, false, err
+ }
+ if p.ResignBelow != 0 && p.s.RootValue() < p.ResignBelow {
+ return game.Pass, true, nil
+ }
+ return p.s.BestMove(), false, nil
+}
+
+func (p *MCTSPlayer) Observe(c game.Color, m game.Point, after *game.Board) error {
+ p.s.Advance(m, after)
+ return nil
+}
+
+func (p *MCTSPlayer) Close() error { return nil }
+
+// GTPPlayer drives an external engine over the Go Text Protocol.
+type GTPPlayer struct {
+ Label string
+ Command []string
+
+ cmd *exec.Cmd
+ in io.WriteCloser
+ out *bufio.Reader
+ size int
+ generated bool // the engine already knows the last move (it generated it)
+}
+
+// NewGTPPlayer prepares an engine; the process starts on NewGame.
+func NewGTPPlayer(label string, command []string) *GTPPlayer {
+ return >PPlayer{Label: label, Command: command}
+}
+
+func (p *GTPPlayer) Name() string { return p.Label }
+
+func (p *GTPPlayer) start() error {
+ p.cmd = exec.Command(p.Command[0], p.Command[1:]...)
+ in, err := p.cmd.StdinPipe()
+ if err != nil {
+ return err
+ }
+ out, err := p.cmd.StdoutPipe()
+ if err != nil {
+ return err
+ }
+ if err := p.cmd.Start(); err != nil {
+ return fmt.Errorf("arena: start %v: %w", p.Command, err)
+ }
+ p.in, p.out = in, bufio.NewReader(out)
+ return nil
+}
+
+// send issues one GTP command and returns the response payload.
+func (p *GTPPlayer) send(cmd string) (string, error) {
+ if _, err := fmt.Fprintln(p.in, cmd); err != nil {
+ return "", err
+ }
+ var lines []string
+ for {
+ line, err := p.out.ReadString('\n')
+ if err != nil {
+ return "", fmt.Errorf("arena: %s: %q: %w", p.Label, cmd, err)
+ }
+ line = strings.TrimRight(line, "\r\n")
+ if line == "" && len(lines) > 0 {
+ break
+ }
+ if line != "" {
+ lines = append(lines, line)
+ }
+ }
+ resp := strings.Join(lines, "\n")
+ if strings.HasPrefix(resp, "?") {
+ return "", fmt.Errorf("arena: %s rejected %q: %s", p.Label, cmd, resp)
+ }
+ return strings.TrimSpace(strings.TrimPrefix(resp, "=")), nil
+}
+
+func (p *GTPPlayer) NewGame(b *game.Board) error {
+ if p.cmd == nil {
+ if err := p.start(); err != nil {
+ return err
+ }
+ }
+ p.size = b.Size()
+ for _, c := range []string{
+ fmt.Sprintf("boardsize %d", b.Size()),
+ "clear_board",
+ fmt.Sprintf("komi %g", b.Komi()),
+ } {
+ if _, err := p.send(c); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (p *GTPPlayer) GenMove(b *game.Board) (game.Point, bool, error) {
+ resp, err := p.send("genmove " + b.ToPlay().String())
+ if err != nil {
+ return game.Pass, false, err
+ }
+ p.generated = true
+ if strings.EqualFold(resp, "resign") {
+ return game.Pass, true, nil
+ }
+ m, err := game.ParsePoint(resp, p.size)
+ return m, false, err
+}
+
+func (p *GTPPlayer) Observe(c game.Color, m game.Point, after *game.Board) error {
+ if p.generated {
+ p.generated = false
+ return nil
+ }
+ _, err := p.send(fmt.Sprintf("play %s %s", c, game.FormatPoint(m, p.size)))
+ return err
+}
+
+func (p *GTPPlayer) DeadStones() ([]game.Point, error) {
+ resp, err := p.send("final_status_list dead")
+ if err != nil {
+ return nil, err
+ }
+ var out []game.Point
+ for _, f := range strings.Fields(resp) {
+ pt, err := game.ParsePoint(f, p.size)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, pt)
+ }
+ return out, nil
+}
+
+func (p *GTPPlayer) Close() error {
+ if p.cmd == nil {
+ return nil
+ }
+ p.send("quit")
+ p.in.Close()
+ return p.cmd.Wait()
+}
diff --git a/internal/game/bitset.go b/internal/game/bitset.go
new file mode 100644
index 0000000..5f344da
--- /dev/null
+++ b/internal/game/bitset.go
@@ -0,0 +1,109 @@
+package game
+
+import (
+ "math/bits"
+ "math/rand"
+ "sync"
+)
+
+const bitsetWords = (maxPoints + 63) / 64
+
+// bitset is a fixed-size set of board points.
+type bitset [bitsetWords]uint64
+
+func (s *bitset) set(i int) { s[i>>6] |= 1 << (uint(i) & 63) }
+func (s *bitset) clear(i int) { s[i>>6] &^= 1 << (uint(i) & 63) }
+func (s *bitset) has(i int) bool { return s[i>>6]&(1<<(uint(i)&63)) != 0 }
+func (s *bitset) or(o *bitset) {
+ for i := range s {
+ s[i] |= o[i]
+ }
+}
+
+func (s *bitset) isZero() bool {
+ for _, w := range s {
+ if w != 0 {
+ return false
+ }
+ }
+ return true
+}
+
+func (s *bitset) count() int {
+ n := 0
+ for _, w := range s {
+ n += bits.OnesCount64(w)
+ }
+ return n
+}
+
+func (s *bitset) forEach(fn func(int)) {
+ for wi, w := range s {
+ for w != 0 {
+ t := bits.TrailingZeros64(w)
+ fn(wi<<6 | t)
+ w &= w - 1
+ }
+ }
+}
+
+// neighborTable holds precomputed orthogonal neighbors for one board size.
+type neighborTable struct {
+ start []int32 // start[p]..start[p+1] indexes into list
+ list []Point
+}
+
+func (t *neighborTable) of(p Point) []Point { return t.list[t.start[p]:t.start[p+1]] }
+
+var (
+ nbOnce [MaxSize + 1]sync.Once
+ nbTable [MaxSize + 1]*neighborTable
+)
+
+func neighbors(size int) *neighborTable {
+ nbOnce[size].Do(func() {
+ n := size * size
+ t := &neighborTable{start: make([]int32, n+1)}
+ for p := 0; p < n; p++ {
+ t.start[p] = int32(len(t.list))
+ x, y := p%size, p/size
+ if x > 0 {
+ t.list = append(t.list, Point(p-1))
+ }
+ if x+1 < size {
+ t.list = append(t.list, Point(p+1))
+ }
+ if y > 0 {
+ t.list = append(t.list, Point(p-size))
+ }
+ if y+1 < size {
+ t.list = append(t.list, Point(p+size))
+ }
+ }
+ t.start[n] = int32(len(t.list))
+ nbTable[size] = t
+ })
+ return nbTable[size]
+}
+
+var (
+ zobristStone [maxPoints][3]uint64
+ zobristKo [maxPoints]uint64
+ zobristLast [HistoryLen][maxPoints + 2]uint64 // index m+2 so noMove=-2 and Pass=-1 fit
+ zobristWhiteToPlay uint64
+)
+
+func init() {
+ rng := rand.New(rand.NewSource(0x60eef0bacafe))
+ for i := range zobristStone {
+ zobristStone[i][Black] = rng.Uint64()
+ zobristStone[i][White] = rng.Uint64()
+ zobristKo[i] = rng.Uint64()
+ }
+ for i := range zobristLast {
+ for j := range zobristLast[i] {
+ zobristLast[i][j] = rng.Uint64()
+ }
+ }
+ zobristWhiteToPlay = rng.Uint64()
+}
diff --git a/internal/game/board.go b/internal/game/board.go
new file mode 100644
index 0000000..90029ad
--- /dev/null
+++ b/internal/game/board.go
@@ -0,0 +1,580 @@
+// Package game implements Go rules: a board with incremental chain and
+// liberty tracking, legality (suicide, simple ko, optional positional
+// superko), and Tromp-Taylor area scoring.
+package game
+
+import (
+ "fmt"
+ "strings"
+)
+
+// Color is a stone color or Empty.
+type Color uint8
+
+const (
+ Empty Color = iota
+ Black
+ White
+)
+
+// Opp returns the opposing color (Empty stays Empty).
+func (c Color) Opp() Color {
+ switch c {
+ case Black:
+ return White
+ case White:
+ return Black
+ }
+ return Empty
+}
+
+func (c Color) String() string {
+ switch c {
+ case Black:
+ return "B"
+ case White:
+ return "W"
+ }
+ return "."
+}
+
+// Point is a linear board index y*size+x (y=0 is the top row). Pass is -1.
+type Point int16
+
+// Pass is the pass move.
+const Pass Point = -1
+
+// MaxSize is the largest supported board.
+const MaxSize = 19
+
+const maxPoints = MaxSize * MaxSize
+
+// HistoryLen is how many recent moves the board remembers for NN features.
+const HistoryLen = 5
+
+type chain struct {
+ stones bitset
+ libs bitset
+ hash uint64 // xor of the chain's stone Zobrist keys
+ size int16
+}
+
+// Board is a Go position plus the minimal history needed for rules and features.
+// Copy with Clone; the zero value is not usable.
+type Board struct {
+ size int
+ komi float64
+ cells []Color
+ chainOf []int16 // head point of the chain at each stone, -1 when empty
+ chains []chain // indexed by head point
+ nb *neighborTable
+
+ toPlay Color
+ ko Point // point forbidden by simple ko, or Pass
+ passes int // consecutive passes ending the game at 2
+ moveNum int
+ stones uint64 // Zobrist hash of stone placement only
+ last [HistoryLen]Point
+ capt [3]int // stones captured by color index
+
+ superko bool
+ seen []uint64 // stone hashes of every earlier position, for positional superko
+}
+
+// Options configures rules for a new board.
+type Options struct {
+ Size int
+ Komi float64
+ Superko bool // forbid recreating any earlier stone configuration
+}
+
+// NewBoard returns an empty board with Black to play.
+func NewBoard(o Options) *Board {
+ if o.Size < 2 || o.Size > MaxSize {
+ panic(fmt.Sprintf("game: board size %d out of range", o.Size))
+ }
+ n := o.Size * o.Size
+ b := &Board{
+ size: o.Size,
+ komi: o.Komi,
+ cells: make([]Color, n),
+ chainOf: make([]int16, n),
+ chains: make([]chain, n),
+ nb: neighbors(o.Size),
+ toPlay: Black,
+ ko: Pass,
+ superko: o.Superko,
+ }
+ for i := range b.chainOf {
+ b.chainOf[i] = -1
+ }
+ for i := range b.last {
+ b.last[i] = noMove
+ }
+ if b.superko {
+ b.seen = append(b.seen, b.stones)
+ }
+ return b
+}
+
+// noMove marks an empty history slot (distinct from Pass).
+const noMove Point = -2
+
+// Clone returns an independent deep copy.
+func (b *Board) Clone() *Board {
+ c := *b
+ c.cells = append([]Color(nil), b.cells...)
+ c.chainOf = append([]int16(nil), b.chainOf...)
+ c.chains = append([]chain(nil), b.chains...)
+ if b.superko {
+ c.seen = append(make([]uint64, 0, len(b.seen)+8), b.seen...)
+ }
+ return &c
+}
+
+// CopyFrom overwrites b with src without allocating when sizes match.
+func (b *Board) CopyFrom(src *Board) {
+ if b.size != src.size || b.cells == nil {
+ *b = *src.Clone()
+ return
+ }
+ cells, chainOf, chains, seen := b.cells, b.chainOf, b.chains, b.seen
+ *b = *src
+ b.cells = append(cells[:0], src.cells...)
+ b.chainOf = append(chainOf[:0], src.chainOf...)
+ b.chains = append(chains[:0], src.chains...)
+ if src.superko {
+ b.seen = append(seen[:0], src.seen...)
+ }
+}
+
+func (b *Board) Size() int { return b.size }
+func (b *Board) Komi() float64 { return b.komi }
+func (b *Board) ToPlay() Color { return b.toPlay }
+func (b *Board) Ko() Point { return b.ko }
+func (b *Board) Passes() int { return b.passes }
+func (b *Board) MoveNum() int { return b.moveNum }
+func (b *Board) Superko() bool { return b.superko }
+func (b *Board) At(p Point) Color { return b.cells[p] }
+func (b *Board) NumPoints() int { return b.size * b.size }
+
+// GameOver reports whether both players passed consecutively.
+func (b *Board) GameOver() bool { return b.passes >= 2 }
+
+// Captures returns the number of stones captured by color c.
+func (b *Board) Captures(c Color) int { return b.capt[c] }
+
+// SetToPlay overrides the side to move (SGF/GTP setup).
+func (b *Board) SetToPlay(c Color) { b.toPlay = c }
+
+// SetKomi changes komi.
+func (b *Board) SetKomi(k float64) { b.komi = k }
+
+// LastMove returns the move played i plies ago (i=0 is the most recent), and
+// false when the game is shorter than that.
+func (b *Board) LastMove(i int) (Point, bool) {
+ m := b.last[i]
+ return m, m != noMove
+}
+
+// StoneHash is the Zobrist hash of stone placement only (used for superko).
+func (b *Board) StoneHash() uint64 { return b.stones }
+
+// Key hashes everything the NN sees: stones, side to move, ko, and recent moves.
+func (b *Board) Key() uint64 {
+ h := b.stones
+ if b.toPlay == White {
+ h ^= zobristWhiteToPlay
+ }
+ if b.ko != Pass {
+ h ^= zobristKo[b.ko]
+ }
+ for i, m := range b.last {
+ h ^= zobristLast[i][int(m)+2]
+ }
+ return h
+}
+
+// Libs returns the liberty count of the chain at p (0 for empty points).
+func (b *Board) Libs(p Point) int {
+ h := b.chainOf[p]
+ if h < 0 {
+ return 0
+ }
+ return b.chains[h].libs.count()
+}
+
+// Neighbors returns the on-board neighbors of p.
+func (b *Board) Neighbors(p Point) []Point {
+ return b.nb.of(p)
+}
+
+// XY converts a point to coordinates.
+func (b *Board) XY(p Point) (x, y int) { return int(p) % b.size, int(p) / b.size }
+
+// P converts coordinates to a point.
+func (b *Board) P(x, y int) Point { return Point(y*b.size + x) }
+
+// IsLegal reports whether the side to move may play m.
+func (b *Board) IsLegal(m Point) bool {
+ if m == Pass {
+ return true
+ }
+ if !b.isLegalSimple(m) {
+ return false
+ }
+ if b.superko {
+ return !b.repeatsPosition(m)
+ }
+ return true
+}
+
+// isLegalSimple checks occupancy, simple ko and suicide using exact liberties.
+func (b *Board) isLegalSimple(m Point) bool {
+ if m < 0 || int(m) >= len(b.cells) || b.cells[m] != Empty || m == b.ko {
+ return false
+ }
+ me := b.toPlay
+ for _, q := range b.nb.of(m) {
+ c := b.cells[q]
+ if c == Empty {
+ return true
+ }
+ libs := b.chains[b.chainOf[q]].libs.count()
+ if c == me && libs > 1 {
+ return true
+ }
+ if c != me && libs == 1 {
+ return true // captures
+ }
+ }
+ return false
+}
+
+// repeatsPosition reports whether playing m (already known to be legal under
+// simple rules) recreates an earlier stone configuration. The resulting hash
+// is derived from per-chain hashes, so no trial move is played.
+func (b *Board) repeatsPosition(m Point) bool {
+ me, opp := b.toPlay, b.toPlay.Opp()
+ h := b.stones ^ zobristStone[m][me]
+ var done [4]int16
+ nd := 0
+outer:
+ for _, q := range b.nb.of(m) {
+ if b.cells[q] != opp {
+ continue
+ }
+ hq := b.chainOf[q]
+ for _, d := range done[:nd] {
+ if d == hq {
+ continue outer
+ }
+ }
+ done[nd] = hq
+ nd++
+ if b.chains[hq].libs.count() == 1 {
+ h ^= b.chains[hq].hash
+ }
+ }
+ for _, s := range b.seen {
+ if s == h {
+ return true
+ }
+ }
+ return false
+}
+
+// Play applies a move for the side to move. It returns an error (and leaves
+// the board unchanged) if the move is illegal.
+func (b *Board) Play(m Point) error {
+ if !b.IsLegal(m) {
+ return fmt.Errorf("illegal move %s for %s", b.FormatPoint(m), b.toPlay)
+ }
+ b.play(m)
+ if b.superko && m != Pass {
+ b.seen = append(b.seen, b.stones)
+ }
+ return nil
+}
+
+// PlayUnchecked applies a move the caller has already verified with IsLegal.
+func (b *Board) PlayUnchecked(m Point) {
+ b.play(m)
+ if b.superko && m != Pass {
+ b.seen = append(b.seen, b.stones)
+ }
+}
+
+func (b *Board) pushHistory(m Point) {
+ copy(b.last[1:], b.last[:HistoryLen-1])
+ b.last[0] = m
+ b.moveNum++
+ b.toPlay = b.toPlay.Opp()
+}
+
+func (b *Board) play(m Point) {
+ if m == Pass {
+ b.ko = Pass
+ b.passes++
+ b.pushHistory(Pass)
+ return
+ }
+ me, opp := b.toPlay, b.toPlay.Opp()
+ b.passes = 0
+ b.cells[m] = me
+ b.stones ^= zobristStone[m][me]
+
+ // The new stone starts as its own chain with its empty neighbors as liberties.
+ head := m
+ ch := chain{size: 1, hash: zobristStone[m][me]}
+ ch.stones.set(int(m))
+ nbrs := b.nb.of(m)
+ for _, q := range nbrs {
+ if b.cells[q] == Empty {
+ ch.libs.set(int(q))
+ }
+ }
+ b.chains[head] = ch
+ b.chainOf[m] = int16(head)
+
+ // m is no longer a liberty of any adjacent chain; merge friendly chains.
+ for _, q := range nbrs {
+ if b.cells[q] == Empty {
+ continue
+ }
+ hq := Point(b.chainOf[q])
+ b.chains[hq].libs.clear(int(m))
+ if b.cells[q] == me && hq != head {
+ head = b.merge(head, hq)
+ }
+ }
+
+ // Capture adjacent enemy chains left without liberties.
+ captured, lastCapt := 0, Pass
+ for _, q := range nbrs {
+ if b.cells[q] != opp {
+ continue
+ }
+ hq := Point(b.chainOf[q])
+ if b.chains[hq].libs.isZero() {
+ lastCapt = q
+ captured += b.remove(hq)
+ }
+ }
+ b.capt[me] += captured
+
+ b.ko = Pass
+ own := &b.chains[head]
+ if captured == 1 && own.size == 1 && own.libs.count() == 1 {
+ b.ko = lastCapt
+ }
+ b.pushHistory(m)
+}
+
+// merge joins chains a and b and returns the surviving head.
+func (b *Board) merge(a, c Point) Point {
+ ca, cc := &b.chains[a], &b.chains[c]
+ if ca.size < cc.size {
+ a, c = c, a
+ ca, cc = cc, ca
+ }
+ cc.stones.forEach(func(s int) { b.chainOf[s] = int16(a) })
+ ca.stones.or(&cc.stones)
+ ca.libs.or(&cc.libs)
+ ca.hash ^= cc.hash
+ ca.size += cc.size
+ return a
+}
+
+// remove takes a chain off the board and returns the stone count.
+func (b *Board) remove(h Point) int {
+ ch := b.chains[h]
+ color := b.cells[h]
+ ch.stones.forEach(func(s int) {
+ b.cells[s] = Empty
+ b.chainOf[s] = -1
+ b.stones ^= zobristStone[s][color]
+ })
+ // Freed points become liberties of neighboring chains.
+ ch.stones.forEach(func(s int) {
+ for _, q := range b.nb.of(Point(s)) {
+ if hq := b.chainOf[q]; hq >= 0 {
+ b.chains[hq].libs.set(s)
+ }
+ }
+ })
+ return int(ch.size)
+}
+
+// LegalMoves appends every legal move (including Pass, last) to dst.
+func (b *Board) LegalMoves(dst []Point) []Point {
+ for p := Point(0); int(p) < len(b.cells); p++ {
+ if b.IsLegal(p) {
+ dst = append(dst, p)
+ }
+ }
+ return append(dst, Pass)
+}
+
+// FormatPoint renders a point in GTP notation (A1 bottom-left, no I column).
+func (b *Board) FormatPoint(p Point) string { return FormatPoint(p, b.size) }
+
+// FormatPoint renders a point in GTP notation for the given board size.
+func FormatPoint(p Point, size int) string {
+ if p == Pass {
+ return "pass"
+ }
+ x, y := int(p)%size, int(p)/size
+ col := 'A' + rune(x)
+ if col >= 'I' {
+ col++
+ }
+ return fmt.Sprintf("%c%d", col, size-y)
+}
+
+// ParsePoint parses GTP notation ("D4", "pass").
+func ParsePoint(s string, size int) (Point, error) {
+ s = strings.ToUpper(strings.TrimSpace(s))
+ if s == "PASS" {
+ return Pass, nil
+ }
+ if len(s) < 2 || s[0] < 'A' || s[0] > 'Z' || s[0] == 'I' {
+ return Pass, fmt.Errorf("invalid vertex %q", s)
+ }
+ x := int(s[0] - 'A')
+ if s[0] > 'I' {
+ x--
+ }
+ row := 0
+ for _, r := range s[1:] {
+ if r < '0' || r > '9' {
+ return Pass, fmt.Errorf("invalid vertex %q", s)
+ }
+ row = row*10 + int(r-'0')
+ }
+ if x >= size || row < 1 || row > size {
+ return Pass, fmt.Errorf("vertex %q off %dx%d board", s, size, size)
+ }
+ return Point((size-row)*size + x), nil
+}
+
+// String renders the board as text (X black, O white, last move in parens).
+func (b *Board) String() string {
+ var sb strings.Builder
+ sb.WriteString(" ")
+ for x := 0; x < b.size; x++ {
+ col := 'A' + rune(x)
+ if col >= 'I' {
+ col++
+ }
+ sb.WriteRune(col)
+ sb.WriteByte(' ')
+ }
+ sb.WriteByte('\n')
+ last, _ := b.LastMove(0)
+ for y := 0; y < b.size; y++ {
+ fmt.Fprintf(&sb, "%2d ", b.size-y)
+ for x := 0; x < b.size; x++ {
+ p := b.P(x, y)
+ ch := byte('.')
+ switch b.cells[p] {
+ case Black:
+ ch = 'X'
+ case White:
+ ch = 'O'
+ }
+ sb.WriteByte(ch)
+ if p == last {
+ sb.WriteByte(')')
+ } else {
+ sb.WriteByte(' ')
+ }
+ }
+ fmt.Fprintf(&sb, "%2d\n", b.size-y)
+ }
+ return strings.TrimRight(sb.String(), "\n")
+}
+
+// Setup places stones for an SGF/GTP setup position (AB/AW) and rebuilds
+// chain state. It fails if any resulting chain has no liberties.
+func (b *Board) Setup(black, white []Point) error {
+ for _, p := range black {
+ b.cells[p] = Black
+ }
+ for _, p := range white {
+ b.cells[p] = White
+ }
+ b.rebuild()
+ for p, c := range b.cells {
+ if c != Empty && b.Libs(Point(p)) == 0 {
+ return fmt.Errorf("setup leaves %s without liberties", b.FormatPoint(Point(p)))
+ }
+ }
+ if b.superko {
+ b.seen = append(b.seen[:0], b.stones)
+ }
+ return nil
+}
+
+// rebuild recomputes chains, liberties and the stone hash from cells.
+func (b *Board) rebuild() {
+ b.stones = 0
+ for i := range b.chainOf {
+ b.chainOf[i] = -1
+ }
+ for p, c := range b.cells {
+ if c == Empty {
+ continue
+ }
+ b.stones ^= zobristStone[p][c]
+ if b.chainOf[p] >= 0 {
+ continue
+ }
+ var ch chain
+ stack := []Point{Point(p)}
+ b.chainOf[p] = int16(p)
+ for len(stack) > 0 {
+ q := stack[len(stack)-1]
+ stack = stack[:len(stack)-1]
+ ch.stones.set(int(q))
+ ch.hash ^= zobristStone[q][c]
+ ch.size++
+ for _, r := range b.nb.of(q) {
+ switch b.cells[r] {
+ case Empty:
+ ch.libs.set(int(r))
+ case c:
+ if b.chainOf[r] < 0 {
+ b.chainOf[r] = int16(p)
+ stack = append(stack, r)
+ }
+ }
+ }
+ }
+ b.chains[p] = ch
+ }
+ b.ko = Pass
+}
+
+// WithoutStones returns a copy with the given stones removed (for scoring
+// after dead-stone agreement). Move history and side to move are kept.
+func (b *Board) WithoutStones(pts []Point) *Board {
+ c := b.Clone()
+ for _, p := range pts {
+ c.cells[p] = Empty
+ }
+ c.rebuild()
+ return c
+}
+
+// FillsOwnEye reports whether the side to move playing p would fill a
+// single-point eye of its own: every neighbor is a friendly stone not in atari.
+func (b *Board) FillsOwnEye(p Point) bool {
+ me := b.toPlay
+ for _, q := range b.nb.of(p) {
+ if b.cells[q] != me || b.chains[b.chainOf[q]].libs.count() == 1 {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/game/board_test.go b/internal/game/board_test.go
new file mode 100644
index 0000000..7fb0efc
--- /dev/null
+++ b/internal/game/board_test.go
@@ -0,0 +1,327 @@
+package game
+
+import (
+ "math/rand"
+ "testing"
+)
+
+// refBoard is a deliberately naive flood-fill implementation used as an oracle.
+type refBoard struct {
+ size int
+ cells []Color
+ ko int
+ turn Color
+ seen map[string]bool
+}
+
+func newRef(size int) *refBoard {
+ r := &refBoard{size: size, cells: make([]Color, size*size), ko: -1, turn: Black, seen: map[string]bool{}}
+ r.seen[r.key()] = true
+ return r
+}
+
+func (r *refBoard) key() string { return string(colorsToBytes(r.cells)) }
+
+func colorsToBytes(c []Color) []byte {
+ out := make([]byte, len(c))
+ for i, v := range c {
+ out[i] = byte(v)
+ }
+ return out
+}
+
+func (r *refBoard) nbrs(p int) []int {
+ x, y := p%r.size, p/r.size
+ var out []int
+ if x > 0 {
+ out = append(out, p-1)
+ }
+ if x+1 < r.size {
+ out = append(out, p+1)
+ }
+ if y > 0 {
+ out = append(out, p-r.size)
+ }
+ if y+1 < r.size {
+ out = append(out, p+r.size)
+ }
+ return out
+}
+
+func (r *refBoard) group(cells []Color, p int) (stones []int, libs map[int]bool) {
+ c := cells[p]
+ libs = map[int]bool{}
+ seen := map[int]bool{p: true}
+ stack := []int{p}
+ for len(stack) > 0 {
+ q := stack[len(stack)-1]
+ stack = stack[:len(stack)-1]
+ stones = append(stones, q)
+ for _, n := range r.nbrs(q) {
+ switch {
+ case cells[n] == Empty:
+ libs[n] = true
+ case cells[n] == c && !seen[n]:
+ seen[n] = true
+ stack = append(stack, n)
+ }
+ }
+ }
+ return stones, libs
+}
+
+// try returns the resulting cells, captured stones, ko point and legality (ignoring superko).
+func (r *refBoard) try(p int) ([]Color, int, int, bool) {
+ if r.cells[p] != Empty || p == r.ko {
+ return nil, 0, -1, false
+ }
+ cells := append([]Color(nil), r.cells...)
+ cells[p] = r.turn
+ captured, lastCapt := 0, -1
+ for _, n := range r.nbrs(p) {
+ if cells[n] == r.turn.Opp() {
+ stones, libs := r.group(cells, n)
+ if len(libs) == 0 {
+ for _, s := range stones {
+ cells[s] = Empty
+ }
+ captured += len(stones)
+ lastCapt = n
+ }
+ }
+ }
+ stones, libs := r.group(cells, p)
+ if len(libs) == 0 {
+ return nil, 0, -1, false
+ }
+ ko := -1
+ if captured == 1 && len(stones) == 1 && len(libs) == 1 {
+ ko = lastCapt
+ }
+ return cells, captured, ko, true
+}
+
+func (r *refBoard) legal(p int, superko bool) bool {
+ cells, _, _, ok := r.try(p)
+ if !ok {
+ return false
+ }
+ return !superko || !r.seen[string(colorsToBytes(cells))]
+}
+
+func (r *refBoard) play(p int) {
+ if p < 0 {
+ r.ko = -1
+ } else {
+ cells, _, ko, _ := r.try(p)
+ r.cells, r.ko = cells, ko
+ r.seen[r.key()] = true
+ }
+ r.turn = r.turn.Opp()
+}
+
+func TestBoardMatchesReference(t *testing.T) {
+ for _, size := range []int{5, 9, 13, 19} {
+ for _, superko := range []bool{false, true} {
+ rng := rand.New(rand.NewSource(int64(size)*7 + 1))
+ games := 60
+ if size >= 13 {
+ games = 6
+ }
+ if testing.Short() {
+ games = 5
+ }
+ for g := 0; g < games; g++ {
+ b := NewBoard(Options{Size: size, Komi: 7, Superko: superko})
+ ref := newRef(size)
+ for move := 0; move < size*size*3; move++ {
+ var legal []Point
+ for p := 0; p < size*size; p++ {
+ got, want := b.IsLegal(Point(p)), ref.legal(p, superko)
+ if got != want {
+ t.Fatalf("size %d game %d move %d: IsLegal(%s)=%v want %v\n%s",
+ size, g, move, b.FormatPoint(Point(p)), got, want, b)
+ }
+ if got {
+ legal = append(legal, Point(p))
+ }
+ }
+ m := Pass
+ if len(legal) > 0 && rng.Intn(20) > 0 {
+ m = legal[rng.Intn(len(legal))]
+ }
+ if err := b.Play(m); err != nil {
+ t.Fatal(err)
+ }
+ ref.play(int(m))
+ for p := range ref.cells {
+ if b.At(Point(p)) != ref.cells[p] {
+ t.Fatalf("size %d game %d move %d: cell %d = %v want %v", size, g, move, p, b.At(Point(p)), ref.cells[p])
+ }
+ }
+ if int(b.Ko()) != ref.ko && !(b.Ko() == Pass && ref.ko == -1) {
+ t.Fatalf("ko %d want %d", b.Ko(), ref.ko)
+ }
+ for p := 0; p < size*size; p++ {
+ if b.At(Point(p)) == Empty {
+ continue
+ }
+ _, libs := ref.group(ref.cells, p)
+ if b.Libs(Point(p)) != len(libs) {
+ t.Fatalf("libs at %d = %d want %d", p, b.Libs(Point(p)), len(libs))
+ }
+ }
+ if b.GameOver() {
+ break
+ }
+ }
+ }
+ }
+ }
+}
+
+func setup(t *testing.T, size int, rows ...string) *Board {
+ t.Helper()
+ b := NewBoard(Options{Size: size, Komi: 0})
+ for y, row := range rows {
+ for x, ch := range row {
+ p := b.P(x, y)
+ switch ch {
+ case 'X':
+ b.placeForTest(p, Black)
+ case 'O':
+ b.placeForTest(p, White)
+ }
+ }
+ }
+ return b
+}
+
+// placeForTest drops a stone without captures by playing it as the right color.
+func (b *Board) placeForTest(p Point, c Color) {
+ b.toPlay = c
+ b.play(p)
+ b.moveNum, b.passes, b.ko = 0, 0, Pass
+ for i := range b.last {
+ b.last[i] = noMove
+ }
+}
+
+func TestSimpleKo(t *testing.T) {
+ b := setup(t, 5,
+ ".XO..",
+ "X.XO.",
+ ".XO..",
+ ".....",
+ ".....")
+ b.SetToPlay(White)
+ if err := b.Play(b.P(1, 1)); err != nil { // O captures X at C4
+ t.Fatal(err)
+ }
+ if b.Ko() != b.P(2, 1) {
+ t.Fatalf("ko = %s, want C4", b.FormatPoint(b.Ko()))
+ }
+ if b.IsLegal(b.P(2, 1)) {
+ t.Fatal("immediate ko recapture must be illegal")
+ }
+ b.Play(Pass)
+ b.Play(Pass) // white passes back; ko lifts after any move
+ if !b.IsLegal(b.P(2, 1)) {
+ t.Fatal("ko recapture should be legal after intervening moves")
+ }
+}
+
+func TestSnapbackIsNotKo(t *testing.T) {
+ // Capturing one stone with a stone that is part of a larger group is not ko.
+ b := setup(t, 5,
+ "OXX..",
+ ".O...",
+ ".....",
+ ".....",
+ ".....")
+ b.SetToPlay(Black)
+ if err := b.Play(b.P(0, 1)); err != nil { // X at A4 captures O at A5
+ t.Fatal(err)
+ }
+ if b.Ko() != Pass {
+ t.Fatalf("ko set to %s after capture by a connected stone", b.FormatPoint(b.Ko()))
+ }
+}
+
+func TestSuicideIllegal(t *testing.T) {
+ b := setup(t, 5,
+ ".X...",
+ "X....",
+ ".....",
+ ".....",
+ ".....")
+ b.SetToPlay(White)
+ if b.IsLegal(b.P(0, 0)) {
+ t.Fatal("suicide must be illegal")
+ }
+}
+
+func TestScoreAndOwnership(t *testing.T) {
+ b := setup(t, 5,
+ ".X.O.",
+ ".X.O.",
+ "XX.OO",
+ ".....",
+ ".....")
+ b.SetKomi(0.5)
+ bl, wl := b.Score()
+ // Black: 4 stones + 2 territory (A5, A4); white: 4 stones + 2 territory (E5, E4) + komi.
+ if bl != 6 || wl != 6.5 {
+ t.Fatalf("score = %v,%v want 6,6.5", bl, wl)
+ }
+ if b.Winner() != White {
+ t.Fatal("white should win")
+ }
+}
+
+func TestKeyDistinguishesSideToMove(t *testing.T) {
+ b := NewBoard(Options{Size: 9})
+ k0 := b.Key()
+ b.Play(Pass)
+ if b.Key() == k0 {
+ t.Fatal("pass must change the NN cache key")
+ }
+ if b.StoneHash() != NewBoard(Options{Size: 9}).StoneHash() {
+ t.Fatal("pass must not change the stone hash")
+ }
+}
+
+func TestParseFormatPoint(t *testing.T) {
+ for _, size := range []int{9, 19} {
+ for p := Point(0); int(p) < size*size; p++ {
+ s := FormatPoint(p, size)
+ q, err := ParsePoint(s, size)
+ if err != nil || q != p {
+ t.Fatalf("round trip %d -> %s -> %d (%v)", p, s, q, err)
+ }
+ }
+ }
+ if _, err := ParsePoint("I5", 9); err == nil {
+ t.Fatal("column I does not exist")
+ }
+}
+
+func BenchmarkRandomGame9x9(b *testing.B) {
+ rng := rand.New(rand.NewSource(1))
+ buf := make([]Point, 0, 82)
+ for i := 0; i < b.N; i++ {
+ bd := NewBoard(Options{Size: 9, Komi: 7})
+ for move := 0; move < 200 && !bd.GameOver(); move++ {
+ buf = bd.LegalMoves(buf[:0])
+ bd.PlayUnchecked(buf[rng.Intn(len(buf))])
+ }
+ }
+}
+
+func BenchmarkClone9x9(b *testing.B) {
+ bd := NewBoard(Options{Size: 9, Komi: 7})
+ dst := bd.Clone()
+ for i := 0; i < b.N; i++ {
+ dst.CopyFrom(bd)
+ }
+}
diff --git a/internal/game/score.go b/internal/game/score.go
new file mode 100644
index 0000000..72c9b18
--- /dev/null
+++ b/internal/game/score.go
@@ -0,0 +1,85 @@
+package game
+
+// Score returns Tromp-Taylor area scores: stones on the board plus empty
+// regions that reach only one color. Komi is added to White. Stones are all
+// treated as alive, so games must be played out (both sides capture dead
+// stones before passing) for the result to be meaningful.
+func (b *Board) Score() (black, white float64) {
+ own := b.Ownership()
+ for _, o := range own {
+ switch o {
+ case Black:
+ black++
+ case White:
+ white++
+ }
+ }
+ return black, white + b.komi
+}
+
+// Margin returns black's area score minus white's (komi included).
+func (b *Board) Margin() float64 {
+ bl, wl := b.Score()
+ return bl - wl
+}
+
+// Winner returns the winning color under area scoring, or Empty for a draw.
+func (b *Board) Winner() Color {
+ switch m := b.Margin(); {
+ case m > 0:
+ return Black
+ case m < 0:
+ return White
+ }
+ return Empty
+}
+
+// Ownership returns, for every point, the color that owns it under area
+// scoring (a stone, or territory bordered by one color) or Empty (dame/seki).
+func (b *Board) Ownership() []Color {
+ n := len(b.cells)
+ own := make([]Color, n)
+ seen := make([]bool, n)
+ stack := make([]Point, 0, n)
+ region := make([]Point, 0, n)
+ for p := 0; p < n; p++ {
+ if b.cells[p] != Empty {
+ own[p] = b.cells[p]
+ continue
+ }
+ if seen[p] {
+ continue
+ }
+ region = region[:0]
+ stack = append(stack[:0], Point(p))
+ seen[p] = true
+ touch := Empty
+ mixed := false
+ for len(stack) > 0 {
+ q := stack[len(stack)-1]
+ stack = stack[:len(stack)-1]
+ region = append(region, q)
+ for _, r := range b.nb.of(q) {
+ switch c := b.cells[r]; c {
+ case Empty:
+ if !seen[r] {
+ seen[r] = true
+ stack = append(stack, r)
+ }
+ default:
+ if touch == Empty {
+ touch = c
+ } else if touch != c {
+ mixed = true
+ }
+ }
+ }
+ }
+ if !mixed && touch != Empty {
+ for _, q := range region {
+ own[q] = touch
+ }
+ }
+ }
+ return own
+}
diff --git a/internal/gtp/gtp.go b/internal/gtp/gtp.go
new file mode 100644
index 0000000..ed03234
--- /dev/null
+++ b/internal/gtp/gtp.go
@@ -0,0 +1,463 @@
+// Package gtp serves the Go Text Protocol (version 2) so Gofer can be used
+// from Sabaki, Lizzie, GoGui, KGS clients or tournament managers.
+package gtp
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/DevomB/gofer/internal/game"
+ "github.com/DevomB/gofer/internal/mcts"
+ "github.com/DevomB/gofer/internal/nn"
+)
+
+// Engine is a GTP-controlled player.
+type Engine struct {
+ Name, Version string
+ Eval nn.Evaluator
+ Search mcts.Config
+ Visits int
+ Superko bool
+ ResignBelow float64 // resign when the root value is below this (0 = never)
+ Log io.Writer
+
+ size int
+ komi float64
+ board *game.Board
+ moves []move
+ s *mcts.Search
+ clock [3]clock // per color, from time_settings / time_left
+}
+
+// clock is a player's remaining time as reported by the controller.
+type clock struct {
+ set bool
+ main time.Duration // main time left, or byo-yomi period left when stones > 0
+ stones int // stones to play in the current byo-yomi period (0: main time)
+ byo time.Duration // byo-yomi period length
+}
+
+// budget returns how long to think for the next move (0: no time control).
+func (c clock) budget(movesLeft int) time.Duration {
+ if !c.set {
+ return 0
+ }
+ if c.stones > 0 {
+ return c.main * 9 / (10 * time.Duration(c.stones))
+ }
+ t := c.main / time.Duration(max(movesLeft, 8))
+ if c.byo > 0 {
+ t += c.byo * 8 / 10
+ }
+ return t
+}
+
+type move struct {
+ c game.Color
+ p game.Point
+}
+
+// NewEngine returns an engine with a 9x9 board.
+func NewEngine(e Engine) *Engine {
+ if e.Name == "" {
+ e.Name = "Gofer"
+ }
+ if e.Log == nil {
+ e.Log = io.Discard
+ }
+ e.size, e.komi = 9, 7.5
+ e.reset()
+ return &e
+}
+
+func (e *Engine) reset() {
+ e.board = game.NewBoard(game.Options{Size: e.size, Komi: e.komi, Superko: e.Superko})
+ e.moves = nil
+ e.s = mcts.New(e.Search, e.Eval)
+ e.s.Reset(e.board)
+}
+
+// replay rebuilds the board from the move list (after undo or komi change).
+func (e *Engine) replay() error {
+ moves := e.moves
+ e.reset()
+ for _, m := range moves {
+ if err := e.apply(m.c, m.p); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (e *Engine) apply(c game.Color, p game.Point) error {
+ e.board.SetToPlay(c)
+ if err := e.board.Play(p); err != nil {
+ return err
+ }
+ e.moves = append(e.moves, move{c, p})
+ e.s.Advance(p, e.board)
+ return nil
+}
+
+var commands = []string{
+ "protocol_version", "name", "version", "known_command", "list_commands", "quit",
+ "boardsize", "clear_board", "komi", "play", "genmove", "undo", "showboard",
+ "final_score", "time_settings", "time_left", "gofer-analyze", "lz-analyze",
+}
+
+// maxAnalysisVisits bounds tree growth during open-ended analysis.
+const maxAnalysisVisits = 2_000_000
+
+// Serve runs the GTP loop until "quit" or EOF.
+func (e *Engine) Serve(r io.Reader, w io.Writer) error {
+ lines := make(chan string)
+ scanErr := make(chan error, 1)
+ go func() {
+ sc := bufio.NewScanner(r)
+ for sc.Scan() {
+ lines <- sc.Text()
+ }
+ scanErr <- sc.Err()
+ close(lines)
+ }()
+ out := bufio.NewWriter(w)
+ defer out.Flush()
+ var pending *string
+ for {
+ var raw string
+ if pending != nil {
+ raw, pending = *pending, nil
+ } else {
+ l, ok := <-lines
+ if !ok {
+ return <-scanErr
+ }
+ raw = l
+ }
+ line := strings.TrimSpace(strings.SplitN(raw, "#", 2)[0])
+ if line == "" {
+ continue
+ }
+ f := strings.Fields(line)
+ id := ""
+ if _, err := strconv.Atoi(f[0]); err == nil {
+ id, f = f[0], f[1:]
+ if len(f) == 0 {
+ continue
+ }
+ }
+ cmd := strings.ToLower(f[0])
+ if cmd == "lz-analyze" {
+ next, err := e.lzAnalyze(id, f[1:], out, lines)
+ if err != nil {
+ fmt.Fprintf(out, "?%s %s\n\n", id, err)
+ out.Flush()
+ }
+ if next == nil {
+ return <-scanErr // input closed during analysis
+ }
+ if *next != "" {
+ pending = next
+ }
+ continue
+ }
+ resp, err := e.handle(cmd, f[1:])
+ if err != nil {
+ fmt.Fprintf(out, "?%s %s\n\n", id, err)
+ } else {
+ fmt.Fprintf(out, "=%s %s\n\n", id, resp)
+ }
+ out.Flush()
+ if cmd == "quit" {
+ return nil
+ }
+ }
+}
+
+// lzAnalyze implements Leela Zero's streaming analysis (used by Sabaki and
+// Lizzie): "lz-analyze [color] [interval-centiseconds]". It prints "info"
+// lines until the next command arrives, which it returns (nil on EOF).
+func (e *Engine) lzAnalyze(id string, args []string, out *bufio.Writer, lines <-chan string) (*string, error) {
+ interval := 100 * time.Millisecond
+ for _, a := range args {
+ if c, err := parseColor(a); err == nil {
+ if e.board.ToPlay() != c {
+ e.board.SetToPlay(c)
+ e.s.Reset(e.board)
+ }
+ continue
+ }
+ cs, err := strconv.Atoi(a)
+ if err != nil {
+ return new(string), fmt.Errorf("syntax error")
+ }
+ if cs > 0 {
+ interval = time.Duration(cs) * 10 * time.Millisecond
+ }
+ }
+ if e.board.GameOver() {
+ e.s.Reset(e.board)
+ }
+ fmt.Fprintf(out, "=%s\n", id)
+ out.Flush()
+ last := time.Now()
+ for {
+ select {
+ case l, ok := <-lines:
+ fmt.Fprint(out, "\n")
+ out.Flush()
+ if !ok {
+ return nil, nil
+ }
+ return &l, nil
+ default:
+ }
+ if e.s.Visits() < maxAnalysisVisits {
+ if err := e.s.Run(32); err != nil {
+ return new(string), err
+ }
+ } else {
+ time.Sleep(interval)
+ }
+ if time.Since(last) >= interval {
+ fmt.Fprintln(out, e.lzInfo())
+ out.Flush()
+ last = time.Now()
+ }
+ }
+}
+
+func (e *Engine) lzInfo() string {
+ var sb strings.Builder
+ for i, c := range e.s.Children() {
+ if c.Visits == 0 {
+ break
+ }
+ if i > 0 {
+ sb.WriteByte(' ')
+ }
+ fmt.Fprintf(&sb, "info move %s visits %d winrate %d prior %d order %d pv %s",
+ game.FormatPoint(c.Move, e.size), c.Visits, int(5000*(1+c.Q)), int(10000*c.Prior), i,
+ formatPV(e.childPV(c.Move), e.size))
+ }
+ return sb.String()
+}
+
+// childPV is the principal variation starting with root move m.
+func (e *Engine) childPV(m game.Point) []game.Point {
+ pv := e.s.PVFrom(m, 10)
+ if len(pv) == 0 {
+ return []game.Point{m}
+ }
+ return pv
+}
+
+func (e *Engine) handle(cmd string, args []string) (string, error) {
+ switch cmd {
+ case "protocol_version":
+ return "2", nil
+ case "name":
+ return e.Name, nil
+ case "version":
+ return e.Version, nil
+ case "known_command":
+ if len(args) == 1 {
+ for _, c := range commands {
+ if c == args[0] {
+ return "true", nil
+ }
+ }
+ }
+ return "false", nil
+ case "list_commands":
+ return strings.Join(commands, "\n"), nil
+ case "quit":
+ return "", nil
+ case "boardsize":
+ if len(args) != 1 {
+ return "", fmt.Errorf("syntax error")
+ }
+ n, err := strconv.Atoi(args[0])
+ if err != nil || n < 2 || n > game.MaxSize {
+ return "", fmt.Errorf("unacceptable size")
+ }
+ e.size = n
+ e.reset()
+ return "", nil
+ case "clear_board":
+ e.reset()
+ return "", nil
+ case "komi":
+ if len(args) != 1 {
+ return "", fmt.Errorf("syntax error")
+ }
+ k, err := strconv.ParseFloat(args[0], 64)
+ if err != nil {
+ return "", fmt.Errorf("syntax error")
+ }
+ e.komi = k
+ return "", e.replay()
+ case "play":
+ if len(args) != 2 {
+ return "", fmt.Errorf("syntax error")
+ }
+ c, err := parseColor(args[0])
+ if err != nil {
+ return "", err
+ }
+ p, err := game.ParsePoint(args[1], e.size)
+ if err != nil {
+ return "", err
+ }
+ if err := e.apply(c, p); err != nil {
+ return "", fmt.Errorf("illegal move")
+ }
+ return "", nil
+ case "genmove":
+ if len(args) != 1 {
+ return "", fmt.Errorf("syntax error")
+ }
+ c, err := parseColor(args[0])
+ if err != nil {
+ return "", err
+ }
+ return e.genmove(c)
+ case "undo":
+ if len(e.moves) == 0 {
+ return "", fmt.Errorf("cannot undo")
+ }
+ e.moves = e.moves[:len(e.moves)-1]
+ return "", e.replay()
+ case "showboard":
+ return "\n" + e.board.String(), nil
+ case "final_score":
+ m := e.board.Margin()
+ switch {
+ case m > 0:
+ return fmt.Sprintf("B+%g", m), nil
+ case m < 0:
+ return fmt.Sprintf("W+%g", -m), nil
+ }
+ return "0", nil
+ case "time_settings":
+ if len(args) != 3 {
+ return "", fmt.Errorf("syntax error")
+ }
+ mainT, err1 := strconv.Atoi(args[0])
+ byo, err2 := strconv.Atoi(args[1])
+ stones, err3 := strconv.Atoi(args[2])
+ if err1 != nil || err2 != nil || err3 != nil {
+ return "", fmt.Errorf("syntax error")
+ }
+ for _, c := range []game.Color{game.Black, game.White} {
+ e.clock[c] = clock{set: mainT > 0 || byo > 0, main: time.Duration(mainT) * time.Second, byo: time.Duration(byo) * time.Second}
+ if mainT == 0 && stones > 0 {
+ e.clock[c].main, e.clock[c].stones = e.clock[c].byo, stones
+ }
+ }
+ return "", nil
+ case "time_left":
+ if len(args) != 3 {
+ return "", fmt.Errorf("syntax error")
+ }
+ c, err := parseColor(args[0])
+ if err != nil {
+ return "", err
+ }
+ secs, err1 := strconv.Atoi(args[1])
+ stones, err2 := strconv.Atoi(args[2])
+ if err1 != nil || err2 != nil {
+ return "", fmt.Errorf("syntax error")
+ }
+ e.clock[c].set = true
+ e.clock[c].main, e.clock[c].stones = time.Duration(secs)*time.Second, stones
+ return "", nil
+ case "gofer-analyze":
+ return e.analyze(args)
+ }
+ return "", fmt.Errorf("unknown command")
+}
+
+func (e *Engine) genmove(c game.Color) (string, error) {
+ if e.board.ToPlay() != c {
+ e.board.SetToPlay(c)
+ e.s.Reset(e.board)
+ }
+ if e.board.GameOver() {
+ e.s.Reset(e.board)
+ }
+ movesLeft := (e.board.NumPoints() - e.board.MoveNum()/2) / 2
+ if budget := e.clock[c].budget(movesLeft); budget > 0 {
+ // Under time control, search until the budget is spent (at least one
+ // batch, at most maxAnalysisVisits).
+ deadline := time.Now().Add(budget)
+ for done := 0; done == 0 || (time.Now().Before(deadline) && e.s.Visits() < maxAnalysisVisits); done++ {
+ if err := e.s.Run(64); err != nil {
+ return "", err
+ }
+ }
+ } else if err := e.s.Run(e.Visits); err != nil {
+ return "", err
+ }
+ v := e.s.RootValue()
+ m := e.s.BestMove()
+ fmt.Fprintf(e.Log, "genmove %s: %s value %+.3f visits %d pv %s\n",
+ c, e.board.FormatPoint(m), v, e.s.Visits(), formatPV(e.s.PV(8), e.size))
+ if e.ResignBelow != 0 && v < e.ResignBelow {
+ return "resign", nil
+ }
+ if err := e.apply(c, m); err != nil {
+ return "", err
+ }
+ return game.FormatPoint(m, e.size), nil
+}
+
+// analyze runs a search and reports the top moves: gofer-analyze [visits].
+func (e *Engine) analyze(args []string) (string, error) {
+ visits := e.Visits
+ if len(args) > 0 {
+ v, err := strconv.Atoi(args[0])
+ if err != nil {
+ return "", fmt.Errorf("syntax error")
+ }
+ visits = v
+ }
+ if err := e.s.Run(visits); err != nil {
+ return "", err
+ }
+ ch := e.s.Children()
+ sort.SliceStable(ch, func(i, j int) bool { return ch[i].Visits > ch[j].Visits })
+ var sb strings.Builder
+ fmt.Fprintf(&sb, "value %+.3f winrate %.1f%% visits %d", e.s.RootValue(), 50*(1+e.s.RootValue()), e.s.Visits())
+ for i, c := range ch {
+ if i == 8 || c.Visits == 0 {
+ break
+ }
+ fmt.Fprintf(&sb, "\n%-5s visits %5d winrate %5.1f%% prior %5.1f%%", game.FormatPoint(c.Move, e.size), c.Visits, 50*(1+c.Q), 100*c.Prior)
+ }
+ fmt.Fprintf(&sb, "\npv %s", formatPV(e.s.PV(12), e.size))
+ return sb.String(), nil
+}
+
+func formatPV(pv []game.Point, size int) string {
+ parts := make([]string, len(pv))
+ for i, p := range pv {
+ parts[i] = game.FormatPoint(p, size)
+ }
+ return strings.Join(parts, " ")
+}
+
+func parseColor(s string) (game.Color, error) {
+ switch strings.ToLower(s) {
+ case "b", "black":
+ return game.Black, nil
+ case "w", "white":
+ return game.White, nil
+ }
+ return game.Empty, fmt.Errorf("invalid color")
+}
diff --git a/internal/gtp/gtp_test.go b/internal/gtp/gtp_test.go
new file mode 100644
index 0000000..87a4e03
--- /dev/null
+++ b/internal/gtp/gtp_test.go
@@ -0,0 +1,112 @@
+package gtp
+
+import (
+ "io"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/DevomB/gofer/internal/mcts"
+ "github.com/DevomB/gofer/internal/nn"
+)
+
+func run(t *testing.T, e *Engine, script string) []string {
+ t.Helper()
+ var out strings.Builder
+ if err := e.Serve(strings.NewReader(script), &out); err != nil {
+ t.Fatal(err)
+ }
+ var resp []string
+ for _, block := range strings.Split(strings.TrimSpace(out.String()), "\n\n") {
+ resp = append(resp, block)
+ }
+ return resp
+}
+
+func TestGTPSession(t *testing.T) {
+ e := NewEngine(Engine{Eval: nn.Uniform{}, Search: mcts.DefaultConfig(), Visits: 50, Superko: true})
+ resp := run(t, e, strings.Join([]string{
+ "1 protocol_version",
+ "2 boardsize 9",
+ "3 clear_board",
+ "4 komi 7.5",
+ "5 play B E5",
+ "6 play W E5",
+ "7 genmove w",
+ "8 undo",
+ "9 undo",
+ "10 play b D4",
+ "11 final_score",
+ "12 bogus",
+ "13 quit",
+ }, "\n"))
+ want := []string{"=1 2", "=2", "=3", "=4", "=5", "?6 illegal move", "", "=8", "=9", "=10", "=11 B+73.5", "?12 unknown command", "=13"}
+ if len(resp) != len(want) {
+ t.Fatalf("got %d responses: %q", len(resp), resp)
+ }
+ for i, w := range want {
+ if w == "" {
+ if !strings.HasPrefix(resp[i], "=7 ") {
+ t.Fatalf("genmove response %q", resp[i])
+ }
+ continue
+ }
+ if strings.TrimSpace(resp[i]) != w {
+ t.Fatalf("response %d = %q want %q", i, resp[i], w)
+ }
+ }
+}
+
+func TestLzAnalyzeStreamsUntilNextCommand(t *testing.T) {
+ e := NewEngine(Engine{Eval: nn.Uniform{}, Search: mcts.DefaultConfig(), Visits: 50})
+ pr, pw := io.Pipe()
+ var out safeBuffer
+ done := make(chan error, 1)
+ go func() { done <- e.Serve(pr, &out) }()
+ io.WriteString(pw, "1 lz-analyze b 5\n")
+ time.Sleep(300 * time.Millisecond)
+ io.WriteString(pw, "2 name\n3 quit\n")
+ if err := <-done; err != nil {
+ t.Fatal(err)
+ }
+ s := out.String()
+ if !strings.HasPrefix(s, "=1\ninfo move ") || !strings.Contains(s, " pv ") || !strings.Contains(s, "\n\n=2 Gofer\n\n=3") {
+ t.Fatalf("unexpected lz-analyze transcript:\n%s", s)
+ }
+}
+
+type safeBuffer struct {
+ mu sync.Mutex
+ b strings.Builder
+}
+
+func (s *safeBuffer) Write(p []byte) (int, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.b.Write(p)
+}
+
+func (s *safeBuffer) String() string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.b.String()
+}
+
+func TestTimeControlBudget(t *testing.T) {
+ if b := (clock{}).budget(40); b != 0 {
+ t.Fatalf("no clock should mean no budget, got %v", b)
+ }
+ if b := (clock{set: true, main: 60 * time.Second}).budget(30); b != 2*time.Second {
+ t.Fatalf("60s for 30 moves: got %v", b)
+ }
+ if b := (clock{set: true, main: 10 * time.Second, stones: 5}).budget(30); b != 1800*time.Millisecond {
+ t.Fatalf("byo-yomi 10s/5 stones: got %v", b)
+ }
+ e := NewEngine(Engine{Eval: nn.Uniform{}, Search: mcts.DefaultConfig(), Visits: 10})
+ start := time.Now()
+ resp := run(t, e, "time_settings 0 1 1\ngenmove b\nquit\n")
+ if el := time.Since(start); el < 500*time.Millisecond || el > 3*time.Second || !strings.HasPrefix(resp[1], "= ") {
+ t.Fatalf("1s byo-yomi genmove took %v: %q", el, resp)
+ }
+}
diff --git a/internal/mcts/search.go b/internal/mcts/search.go
new file mode 100644
index 0000000..d3b491c
--- /dev/null
+++ b/internal/mcts/search.go
@@ -0,0 +1,517 @@
+// Package mcts implements PUCT Monte Carlo tree search over a neural
+// network evaluator (AlphaZero/KataGo style).
+//
+// Sign convention: every node stores W, the sum of backed-up values from the
+// perspective of the player who made the move leading to that node. A parent
+// therefore maximizes its children's Q = W/N directly. A network value v is
+// from the side to move at the evaluated position, so the leaf receives -v,
+// its parent +v, and so on up the path.
+package mcts
+
+import (
+ "errors"
+ "math"
+ "math/rand"
+ "sort"
+
+ "github.com/DevomB/gofer/internal/game"
+ "github.com/DevomB/gofer/internal/nn"
+)
+
+// Config controls the search.
+type Config struct {
+ CPuct float64 // exploration constant
+ FPUReduction float64 // unvisited child value = parent value - reduction*sqrt(visited prior mass)
+ RootFPUReduction float64
+ RootNoise bool // mix Dirichlet noise into root priors (self-play only)
+ NoiseAlpha float64 // 0 = 10.83/legal moves (AlphaZero's 0.03 scaled to the board)
+ NoiseFrac float64
+ Batch int // leaves gathered (with virtual loss) per evaluator call
+ MaxMoves int // positions at this move number are scored as final; 0 = 3*points
+ // ConservativePass only searches Pass when the opponent just passed (so
+ // passing ends the game and is scored exactly) or when every other legal
+ // move would fill one of our own eyes. Games are then played out until
+ // dead stones are captured, which makes area scoring exact, and an
+ // untrained network cannot end games by passing at random.
+ ConservativePass bool
+ Seed int64
+}
+
+// DefaultConfig returns tuned defaults for 9x9.
+func DefaultConfig() Config {
+ return Config{
+ CPuct: 1.4,
+ FPUReduction: 0.25,
+ RootFPUReduction: 0.1,
+ NoiseFrac: 0.25,
+ Batch: 8,
+ ConservativePass: true,
+ Seed: 1,
+ }
+}
+
+type node struct {
+ move game.Point
+ prior float32
+ n int32 // completed visits
+ vl int32 // virtual losses in flight
+ w float64 // value sum for the player who played move
+ children []node // allocated once at expansion; never appended to, so pointers stay valid
+ expanded bool
+ terminal bool
+ pending bool // awaiting evaluation in the current batch
+}
+
+// Search owns a tree for one game. It is not safe for concurrent use; run
+// one Search per goroutine and share the evaluator.
+type Search struct {
+ cfg Config
+ eval nn.Evaluator
+ rng *rand.Rand
+ root *node
+ board *game.Board // position at the root
+
+ // scratch
+ leafBoards []*game.Board
+ leafPaths [][]*node
+ outs []nn.Output
+ legal []game.Point
+}
+
+// New returns a search over eval with the given configuration.
+func New(cfg Config, eval nn.Evaluator) *Search {
+ if cfg.Batch < 1 {
+ cfg.Batch = 1
+ }
+ return &Search{cfg: cfg, eval: eval, rng: rand.New(rand.NewSource(cfg.Seed))}
+}
+
+// Config returns the active configuration.
+func (s *Search) Config() Config { return s.cfg }
+
+// SetRootNoise toggles Dirichlet noise for subsequent searches.
+func (s *Search) SetRootNoise(on bool) { s.cfg.RootNoise = on }
+
+// Reset discards the tree and sets a new root position.
+func (s *Search) Reset(b *game.Board) {
+ s.board = b.Clone()
+ s.root = &node{move: game.Pass}
+}
+
+// Advance moves the root to the child reached by m (keeping its subtree) and
+// syncs to board b, which must be the position after m. Falls back to Reset.
+func (s *Search) Advance(m game.Point, b *game.Board) {
+ if s.root != nil && s.root.expanded {
+ for i := range s.root.children {
+ c := &s.root.children[i]
+ if c.move == m {
+ s.root = c
+ s.board = b.Clone()
+ return
+ }
+ }
+ }
+ s.Reset(b)
+}
+
+// Board returns the root position (do not modify).
+func (s *Search) Board() *game.Board { return s.board }
+
+func (s *Search) maxMoves() int {
+ if s.cfg.MaxMoves > 0 {
+ return s.cfg.MaxMoves
+ }
+ return 3 * s.board.NumPoints()
+}
+
+// Run performs visits playouts from the root.
+func (s *Search) Run(visits int) error {
+ if s.root == nil {
+ return errors.New("mcts: Run before Reset")
+ }
+ if s.isTerminal(s.board) {
+ return nil
+ }
+ if !s.root.expanded {
+ out := make([]nn.Output, 1)
+ if err := s.eval.Eval([]*game.Board{s.board}, out); err != nil {
+ return err
+ }
+ s.expand(s.root, s.board, out[0].Policy)
+ s.backup([]*node{s.root}, float64(out[0].Value))
+ visits--
+ }
+ if s.cfg.RootNoise {
+ s.applyNoise()
+ }
+ for len(s.leafBoards) < s.cfg.Batch {
+ s.leafBoards = append(s.leafBoards, s.board.Clone())
+ s.leafPaths = append(s.leafPaths, nil)
+ }
+ if len(s.outs) < s.cfg.Batch {
+ s.outs = make([]nn.Output, s.cfg.Batch)
+ }
+ for done := 0; done < visits; {
+ k := 0
+ for k < s.cfg.Batch && done+k < visits {
+ b := s.leafBoards[k]
+ path := s.descend(b, s.leafPaths[k][:0])
+ s.leafPaths[k] = path
+ leaf := path[len(path)-1]
+ if leaf.terminal {
+ s.backup(path, terminalValue(b))
+ done++
+ continue
+ }
+ if leaf.pending {
+ s.revert(path)
+ break
+ }
+ leaf.pending = true
+ k++
+ }
+ if k == 0 {
+ continue
+ }
+ if err := s.eval.Eval(s.leafBoards[:k], s.outs[:k]); err != nil {
+ for i := 0; i < k; i++ {
+ s.leafPaths[i][len(s.leafPaths[i])-1].pending = false
+ s.revert(s.leafPaths[i])
+ }
+ return err
+ }
+ for i := 0; i < k; i++ {
+ path := s.leafPaths[i]
+ leaf := path[len(path)-1]
+ leaf.pending = false
+ s.expand(leaf, s.leafBoards[i], s.outs[i].Policy)
+ s.backup(path, float64(s.outs[i].Value))
+ }
+ done += k
+ }
+ return nil
+}
+
+func (s *Search) isTerminal(b *game.Board) bool {
+ return b.GameOver() || b.MoveNum() >= s.maxMoves()
+}
+
+// terminalValue scores a finished game for the side to move.
+func terminalValue(b *game.Board) float64 {
+ switch b.Winner() {
+ case b.ToPlay():
+ return 1
+ case game.Empty:
+ return 0
+ }
+ return -1
+}
+
+// descend walks from the root to a leaf, applying virtual loss, and leaves b
+// at the leaf position.
+func (s *Search) descend(b *game.Board, path []*node) []*node {
+ b.CopyFrom(s.board)
+ nd := s.root
+ path = append(path, nd)
+ for nd.expanded && !nd.terminal {
+ nd = s.selectChild(nd, nd == s.root)
+ b.PlayUnchecked(nd.move)
+ nd.vl++
+ path = append(path, nd)
+ if !nd.expanded && s.isTerminal(b) {
+ nd.terminal = true
+ nd.expanded = true
+ }
+ }
+ return path
+}
+
+func (s *Search) selectChild(nd *node, isRoot bool) *node {
+ parentN := float64(nd.n + nd.vl)
+ sqrtN := math.Sqrt(math.Max(parentN, 1))
+ parentQ := 0.0
+ if nd.n > 0 {
+ parentQ = -nd.w / float64(nd.n) // nd.w is from the opponent's view
+ }
+ visitedPrior := 0.0
+ for i := range nd.children {
+ if c := &nd.children[i]; c.n+c.vl > 0 {
+ visitedPrior += float64(c.prior)
+ }
+ }
+ red := s.cfg.FPUReduction
+ if isRoot {
+ red = s.cfg.RootFPUReduction
+ }
+ fpu := parentQ - red*math.Sqrt(visitedPrior)
+ best, bestScore := 0, math.Inf(-1)
+ for i := range nd.children {
+ c := &nd.children[i]
+ nv := float64(c.n + c.vl)
+ q := fpu
+ if nv > 0 {
+ q = (c.w - float64(c.vl)) / nv // in-flight visits count as losses
+ }
+ score := q + s.cfg.CPuct*float64(c.prior)*sqrtN/(1+nv)
+ if score > bestScore {
+ best, bestScore = i, score
+ }
+ }
+ return &nd.children[best]
+}
+
+func (s *Search) expand(nd *node, b *game.Board, policy []float32) {
+ if nd.expanded {
+ return
+ }
+ s.legal = b.LegalMoves(s.legal[:0])
+ if s.cfg.ConservativePass {
+ s.legal = filterMoves(b, s.legal)
+ }
+ n := b.NumPoints()
+ children := make([]node, len(s.legal))
+ sum := float32(0)
+ for i, m := range s.legal {
+ idx := int(m)
+ if m == game.Pass {
+ idx = n
+ }
+ children[i] = node{move: m, prior: policy[idx]}
+ sum += policy[idx]
+ }
+ for i := range children {
+ if sum > 0 {
+ children[i].prior /= sum
+ } else {
+ children[i].prior = 1 / float32(len(children))
+ }
+ }
+ nd.children = children
+ nd.expanded = true
+}
+
+// filterMoves applies ConservativePass: drop own-eye fills, and drop Pass
+// unless the opponent just passed or nothing else is left.
+func filterMoves(b *game.Board, legal []game.Point) []game.Point {
+ last, ok := b.LastMove(0)
+ oppPassed := ok && last == game.Pass
+ out := legal[:0]
+ for _, m := range legal {
+ if m == game.Pass || b.FillsOwnEye(m) {
+ continue
+ }
+ out = append(out, m)
+ }
+ if len(out) == 0 || oppPassed {
+ out = append(out, game.Pass)
+ }
+ return out
+}
+
+func (s *Search) backup(path []*node, v float64) {
+ x := -v
+ for i := len(path) - 1; i >= 0; i-- {
+ nd := path[i]
+ nd.n++
+ nd.w += x
+ if i > 0 {
+ nd.vl--
+ }
+ x = -x
+ }
+}
+
+func (s *Search) revert(path []*node) {
+ for _, nd := range path[1:] {
+ nd.vl--
+ }
+}
+
+func (s *Search) applyNoise() {
+ ch := s.root.children
+ if len(ch) < 2 {
+ return
+ }
+ alpha := s.cfg.NoiseAlpha
+ if alpha <= 0 {
+ alpha = 10.83 / float64(len(ch))
+ }
+ noise := make([]float64, len(ch))
+ sum := 0.0
+ for i := range noise {
+ noise[i] = gamma(alpha, s.rng)
+ sum += noise[i]
+ }
+ if sum == 0 {
+ return
+ }
+ f := s.cfg.NoiseFrac
+ for i := range ch {
+ ch[i].prior = float32((1-f)*float64(ch[i].prior) + f*noise[i]/sum)
+ }
+}
+
+// gamma samples Gamma(shape, 1) (Marsaglia-Tsang; boosted for shape < 1).
+func gamma(shape float64, rng *rand.Rand) float64 {
+ if shape < 1 {
+ return gamma(shape+1, rng) * math.Pow(rng.Float64(), 1/shape)
+ }
+ d := shape - 1.0/3
+ c := 1 / math.Sqrt(9*d)
+ for {
+ x := rng.NormFloat64()
+ v := 1 + c*x
+ if v <= 0 {
+ continue
+ }
+ v = v * v * v
+ u := rng.Float64()
+ if u < 1-0.0331*x*x*x*x || math.Log(u) < 0.5*x*x+d*(1-v+math.Log(v)) {
+ return d * v
+ }
+ }
+}
+
+// Child summarizes one root move.
+type Child struct {
+ Move game.Point
+ Visits int
+ Q float64 // mean value for the root player
+ Prior float32
+}
+
+// Children returns root moves sorted by visits (then Q), best first.
+func (s *Search) Children() []Child {
+ if s.root == nil {
+ return nil
+ }
+ out := make([]Child, 0, len(s.root.children))
+ for i := range s.root.children {
+ c := &s.root.children[i]
+ q := 0.0
+ if c.n > 0 {
+ q = c.w / float64(c.n)
+ }
+ out = append(out, Child{Move: c.move, Visits: int(c.n), Q: q, Prior: c.prior})
+ }
+ sort.SliceStable(out, func(i, j int) bool {
+ if out[i].Visits != out[j].Visits {
+ return out[i].Visits > out[j].Visits
+ }
+ return out[i].Q > out[j].Q
+ })
+ return out
+}
+
+// RootValue is the search's value estimate for the side to move at the root.
+func (s *Search) RootValue() float64 {
+ if s.root == nil || s.root.n == 0 {
+ return 0
+ }
+ return -s.root.w / float64(s.root.n)
+}
+
+// Visits returns the root visit count.
+func (s *Search) Visits() int {
+ if s.root == nil {
+ return 0
+ }
+ return int(s.root.n)
+}
+
+// BestMove returns the most visited root move (Pass if the game is over).
+func (s *Search) BestMove() game.Point {
+ ch := s.Children()
+ if len(ch) == 0 {
+ return game.Pass
+ }
+ return ch[0].Move
+}
+
+// SampleMove picks a root move with probability proportional to visits^(1/temp).
+func (s *Search) SampleMove(temp float64) game.Point {
+ ch := s.Children()
+ if len(ch) == 0 {
+ return game.Pass
+ }
+ if temp <= 0 {
+ return ch[0].Move
+ }
+ weights := make([]float64, len(ch))
+ sum := 0.0
+ for i, c := range ch {
+ if c.Visits > 0 {
+ weights[i] = math.Pow(float64(c.Visits), 1/temp)
+ sum += weights[i]
+ }
+ }
+ if sum == 0 {
+ return ch[0].Move
+ }
+ r := s.rng.Float64() * sum
+ for i, w := range weights {
+ r -= w
+ if r <= 0 {
+ return ch[i].Move
+ }
+ }
+ return ch[0].Move
+}
+
+// PolicyTarget returns root visit counts normalized over points+pass.
+func (s *Search) PolicyTarget() []float32 {
+ n := s.board.NumPoints()
+ pi := make([]float32, n+1)
+ total := 0
+ for i := range s.root.children {
+ total += int(s.root.children[i].n)
+ }
+ if total == 0 {
+ return pi
+ }
+ for i := range s.root.children {
+ c := &s.root.children[i]
+ idx := int(c.move)
+ if c.move == game.Pass {
+ idx = n
+ }
+ pi[idx] = float32(c.n) / float32(total)
+ }
+ return pi
+}
+
+// PV returns the principal variation (most visited line) up to maxLen moves.
+func (s *Search) PV(maxLen int) []game.Point {
+ return s.pvFrom(s.root, nil, maxLen)
+}
+
+// PVFrom returns the most visited line that starts with root move m.
+func (s *Search) PVFrom(m game.Point, maxLen int) []game.Point {
+ if s.root == nil {
+ return nil
+ }
+ for i := range s.root.children {
+ if c := &s.root.children[i]; c.move == m {
+ return s.pvFrom(c, []game.Point{m}, maxLen)
+ }
+ }
+ return nil
+}
+
+func (s *Search) pvFrom(nd *node, pv []game.Point, maxLen int) []game.Point {
+ for nd != nil && nd.expanded && len(pv) < maxLen {
+ var best *node
+ for i := range nd.children {
+ c := &nd.children[i]
+ if c.n > 0 && (best == nil || c.n > best.n) {
+ best = c
+ }
+ }
+ if best == nil {
+ break
+ }
+ pv = append(pv, best.move)
+ nd = best
+ }
+ return pv
+}
diff --git a/internal/mcts/search_test.go b/internal/mcts/search_test.go
new file mode 100644
index 0000000..660d652
--- /dev/null
+++ b/internal/mcts/search_test.go
@@ -0,0 +1,206 @@
+package mcts
+
+import (
+ "math"
+ "testing"
+
+ "github.com/DevomB/gofer/internal/game"
+ "github.com/DevomB/gofer/internal/nn"
+)
+
+// material is a toy evaluator: flat policy, value = tanh(stone difference / 4)
+// for the side to move. It is enough for search to find captures.
+type material struct{}
+
+func (material) Eval(boards []*game.Board, out []nn.Output) error {
+ for i, b := range boards {
+ nn.Uniform{}.Eval(boards[i:i+1], out[i:i+1])
+ d := 0
+ for p := game.Point(0); int(p) < b.NumPoints(); p++ {
+ switch b.At(p) {
+ case b.ToPlay():
+ d++
+ case b.ToPlay().Opp():
+ d--
+ }
+ }
+ out[i].Value = float32(math.Tanh(float64(d) / 4))
+ }
+ return nil
+}
+
+func place(b *game.Board, c game.Color, pts ...game.Point) {
+ for _, p := range pts {
+ b.SetToPlay(c)
+ if err := b.Play(p); err != nil {
+ panic(err)
+ }
+ }
+}
+
+func captureBoard() (*game.Board, game.Point) {
+ b := game.NewBoard(game.Options{Size: 9, Komi: 0.5})
+ row := func(y int, xs ...int) []game.Point {
+ var out []game.Point
+ for _, x := range xs {
+ out = append(out, b.P(x, y))
+ }
+ return out
+ }
+ place(b, game.White, row(4, 1, 2, 3, 4, 5, 6)...)
+ place(b, game.Black, row(3, 1, 2, 3, 4, 5, 6)...)
+ place(b, game.Black, row(5, 1, 2, 3, 4, 5, 6)...)
+ place(b, game.Black, b.P(0, 4))
+ b.SetToPlay(game.Black)
+ return b, b.P(7, 4)
+}
+
+func TestFindsCapture(t *testing.T) {
+ for _, batch := range []int{1, 8} {
+ b, capture := captureBoard()
+ cfg := DefaultConfig()
+ cfg.Batch = batch
+ s := New(cfg, material{})
+ s.Reset(b)
+ if err := s.Run(400); err != nil {
+ t.Fatal(err)
+ }
+ if got := s.BestMove(); got != capture {
+ t.Fatalf("batch %d: best move %s, want capture %s; children %+v",
+ batch, b.FormatPoint(got), b.FormatPoint(capture), s.Children()[:3])
+ }
+ if s.RootValue() < 0.5 {
+ t.Fatalf("root value %.2f should be clearly winning for black", s.RootValue())
+ }
+ }
+}
+
+func TestPassesToWinAfterOpponentPass(t *testing.T) {
+ b := game.NewBoard(game.Options{Size: 5, Komi: 0.5})
+ // Black wall on column B, white on D: black area 10, white 10 + 0.5 komi... give
+ // black one extra stone so black leads by area.
+ place(b, game.Black, b.P(1, 0), b.P(1, 1), b.P(1, 2), b.P(1, 3), b.P(1, 4), b.P(2, 2))
+ place(b, game.White, b.P(3, 0), b.P(3, 1), b.P(3, 2), b.P(3, 3), b.P(3, 4))
+ b.SetToPlay(game.White)
+ b.Play(game.Pass)
+ if b.Margin() <= 0 {
+ t.Fatalf("setup should have black ahead, margin %v", b.Margin())
+ }
+ s := New(DefaultConfig(), nn.Uniform{})
+ s.Reset(b)
+ s.Run(300)
+ if got := s.BestMove(); got != game.Pass {
+ t.Fatalf("black should pass to end a won game, played %s", b.FormatPoint(got))
+ }
+}
+
+func TestDoesNotPassWhenLosing(t *testing.T) {
+ b := game.NewBoard(game.Options{Size: 5, Komi: 7.5})
+ b.Play(game.Pass) // black passes; white passing now would win for white
+ b.SetToPlay(game.Black)
+ b.Play(game.Pass)
+ if !b.GameOver() {
+ t.Fatal("two passes should end the game")
+ }
+ b2 := game.NewBoard(game.Options{Size: 5, Komi: 7.5})
+ b2.SetToPlay(game.White)
+ b2.Play(game.Pass) // white passed; black to move is losing on komi if it passes
+ s := New(DefaultConfig(), nn.Uniform{})
+ s.Reset(b2)
+ s.Run(300)
+ for _, c := range s.Children() {
+ if c.Move == game.Pass && c.Q > -0.99 {
+ t.Fatalf("pass should be a certain loss for black, Q=%v", c.Q)
+ }
+ }
+ if s.BestMove() == game.Pass {
+ t.Fatal("black must not pass into a lost game")
+ }
+}
+
+func TestVisitAccountingAndReuse(t *testing.T) {
+ b, _ := captureBoard()
+ cfg := DefaultConfig()
+ cfg.Batch = 8
+ s := New(cfg, material{})
+ s.Reset(b)
+ s.Run(200)
+ if s.Visits() != 200 {
+ t.Fatalf("root visits %d want 200", s.Visits())
+ }
+ sum := 0
+ for _, c := range s.Children() {
+ sum += c.Visits
+ }
+ if sum != 199 {
+ t.Fatalf("child visits %d want 199 (root expansion is one visit)", sum)
+ }
+ m := s.BestMove()
+ kept := s.Children()[0].Visits
+ nb := b.Clone()
+ nb.Play(m)
+ s.Advance(m, nb)
+ if s.Visits() != kept {
+ t.Fatalf("reused root has %d visits, want %d", s.Visits(), kept)
+ }
+ s.Run(50)
+ if s.Visits() != kept+50 {
+ t.Fatalf("visits after reuse %d want %d", s.Visits(), kept+50)
+ }
+ pi := s.PolicyTarget()
+ total := float32(0)
+ for _, p := range pi {
+ total += p
+ }
+ if math.Abs(float64(total-1)) > 1e-4 {
+ t.Fatalf("policy target sums to %v", total)
+ }
+}
+
+func TestRootNoiseKeepsPriorsNormalized(t *testing.T) {
+ b := game.NewBoard(game.Options{Size: 9, Komi: 7})
+ cfg := DefaultConfig()
+ cfg.RootNoise = true
+ s := New(cfg, nn.Uniform{})
+ s.Reset(b)
+ s.Run(10)
+ sum := float32(0)
+ for _, c := range s.Children() {
+ sum += c.Prior
+ }
+ if math.Abs(float64(sum-1)) > 1e-4 {
+ t.Fatalf("noisy priors sum to %v", sum)
+ }
+}
+
+func BenchmarkSearch200(b *testing.B) {
+ bd := game.NewBoard(game.Options{Size: 9, Komi: 7})
+ cfg := DefaultConfig()
+ for i := 0; i < b.N; i++ {
+ s := New(cfg, nn.Uniform{})
+ s.Reset(bd)
+ s.Run(200)
+ }
+}
+
+func TestConservativePass(t *testing.T) {
+ b := game.NewBoard(game.Options{Size: 9, Komi: 7.5})
+ s := New(DefaultConfig(), nn.Uniform{})
+ s.Reset(b)
+ s.Run(50)
+ for _, c := range s.Children() {
+ if c.Move == game.Pass {
+ t.Fatal("pass must not be searched on an open board")
+ }
+ }
+ b.Play(game.Pass)
+ s.Reset(b)
+ s.Run(50)
+ found := false
+ for _, c := range s.Children() {
+ found = found || c.Move == game.Pass
+ }
+ if !found {
+ t.Fatal("pass must be searchable after the opponent passed")
+ }
+}
diff --git a/internal/nn/batcher.go b/internal/nn/batcher.go
new file mode 100644
index 0000000..daee786
--- /dev/null
+++ b/internal/nn/batcher.go
@@ -0,0 +1,256 @@
+package nn
+
+import (
+ "errors"
+ "math/rand"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/DevomB/gofer/internal/game"
+)
+
+// NetConfig controls a batched network evaluator.
+type NetConfig struct {
+ Size int // board size the network was trained for
+ Workers int // backends running batches concurrently (CPU: ~cores)
+ MaxBatch int // largest batch handed to a backend
+ Wait time.Duration // how long a worker waits to fill a batch
+ RandomSymmetry bool // evaluate each position under a random dihedral symmetry
+ CacheEntries int // position cache size; 0 disables caching
+ Seed int64
+}
+
+// Net batches concurrent Eval calls onto one or more backends.
+type Net struct {
+ cfg NetConfig
+ points int
+ reqs chan *request
+ cache *cache
+ wg sync.WaitGroup
+ closed atomic.Bool
+ stop chan struct{}
+ bks []Backend
+ seed atomic.Int64
+
+ Stats Stats
+}
+
+// Stats counts evaluator activity.
+type Stats struct {
+ Positions atomic.Int64 // positions sent to the network
+ Batches atomic.Int64
+ CacheHits atomic.Int64
+ NetNanos atomic.Int64 // time spent inside Backend.Run
+}
+
+type request struct {
+ key uint64
+ sym int
+ spatial []float32
+ global []float32
+ out *Output
+ group *callGroup
+}
+
+type callGroup struct {
+ wg sync.WaitGroup
+ mu sync.Mutex
+ err error
+}
+
+func (g *callGroup) fail(err error) {
+ g.mu.Lock()
+ if g.err == nil {
+ g.err = err
+ }
+ g.mu.Unlock()
+}
+
+// NewNet starts cfg.Workers workers, one per backend from newBackend.
+func NewNet(cfg NetConfig, newBackend func() (Backend, error)) (*Net, error) {
+ if cfg.Workers < 1 {
+ cfg.Workers = 1
+ }
+ if cfg.MaxBatch < 1 {
+ cfg.MaxBatch = 64
+ }
+ if cfg.Wait <= 0 {
+ cfg.Wait = time.Millisecond
+ }
+ n := &Net{
+ cfg: cfg,
+ points: cfg.Size * cfg.Size,
+ reqs: make(chan *request, cfg.MaxBatch*cfg.Workers*4),
+ stop: make(chan struct{}),
+ }
+ n.seed.Store(cfg.Seed)
+ if cfg.CacheEntries > 0 {
+ n.cache = newCache(cfg.CacheEntries)
+ }
+ for i := 0; i < cfg.Workers; i++ {
+ bk, err := newBackend()
+ if err != nil {
+ n.Close()
+ return nil, err
+ }
+ n.bks = append(n.bks, bk)
+ n.wg.Add(1)
+ go n.worker(bk)
+ }
+ return n, nil
+}
+
+// Eval implements Evaluator.
+func (n *Net) Eval(boards []*game.Board, out []Output) error {
+ if n.closed.Load() {
+ return errors.New("nn: evaluator closed")
+ }
+ g := &callGroup{}
+ rng := rand.New(rand.NewSource(n.seed.Add(1)))
+ for i, b := range boards {
+ if b.Size() != n.cfg.Size {
+ return errors.New("nn: board size does not match the network")
+ }
+ key := b.Key()
+ if n.cache != nil {
+ if o, ok := n.cache.get(key); ok {
+ out[i] = o
+ n.Stats.CacheHits.Add(1)
+ continue
+ }
+ }
+ sym := 0
+ if n.cfg.RandomSymmetry {
+ sym = rng.Intn(8)
+ }
+ r := &request{
+ key: key,
+ sym: sym,
+ spatial: make([]float32, NumPlanes*n.points),
+ global: make([]float32, NumGlobals),
+ out: &out[i],
+ group: g,
+ }
+ Encode(b, sym, r.spatial, r.global)
+ g.wg.Add(1)
+ n.reqs <- r
+ }
+ g.wg.Wait()
+ return g.err
+}
+
+func (n *Net) worker(bk Backend) {
+ defer n.wg.Done()
+ batch := make([]*request, 0, n.cfg.MaxBatch)
+ spatial := make([]float32, 0, n.cfg.MaxBatch*NumPlanes*n.points)
+ global := make([]float32, 0, n.cfg.MaxBatch*NumGlobals)
+ for {
+ batch = batch[:0]
+ select {
+ case <-n.stop:
+ return
+ case r := <-n.reqs:
+ batch = append(batch, r)
+ }
+ deadline := time.NewTimer(n.cfg.Wait)
+ fill:
+ for len(batch) < n.cfg.MaxBatch {
+ select {
+ case r := <-n.reqs:
+ batch = append(batch, r)
+ case <-deadline.C:
+ break fill
+ }
+ }
+ deadline.Stop()
+
+ spatial, global = spatial[:0], global[:0]
+ for _, r := range batch {
+ spatial = append(spatial, r.spatial...)
+ global = append(global, r.global...)
+ }
+ t0 := time.Now()
+ logits, values, err := bk.Run(spatial, global, len(batch))
+ n.Stats.NetNanos.Add(int64(time.Since(t0)))
+ n.Stats.Batches.Add(1)
+ n.Stats.Positions.Add(int64(len(batch)))
+ for i, r := range batch {
+ if err != nil {
+ r.group.fail(err)
+ } else {
+ *r.out = n.decode(r, logits[i*(n.points+1):(i+1)*(n.points+1)], values[i])
+ if n.cache != nil {
+ n.cache.put(r.key, *r.out)
+ }
+ }
+ r.group.wg.Done()
+ }
+ }
+}
+
+// decode undoes the input symmetry on the policy.
+func (n *Net) decode(r *request, logits []float32, value float32) Output {
+ probs := append([]float32(nil), logits...)
+ Softmax(probs)
+ policy := make([]float32, n.points+1)
+ t := symmetry(n.cfg.Size, r.sym)
+ for p := 0; p < n.points; p++ {
+ policy[p] = probs[t[p]]
+ }
+ policy[n.points] = probs[n.points]
+ return Output{Policy: policy, Value: value}
+}
+
+// Close stops the workers and releases backends.
+func (n *Net) Close() error {
+ if n.closed.Swap(true) {
+ return nil
+ }
+ close(n.stop)
+ n.wg.Wait()
+ var err error
+ for _, bk := range n.bks {
+ if e := bk.Close(); e != nil && err == nil {
+ err = e
+ }
+ }
+ return err
+}
+
+// cache is a sharded position cache that drops a shard when it fills up.
+type cache struct {
+ shards [64]cacheShard
+ per int
+}
+
+type cacheShard struct {
+ mu sync.Mutex
+ m map[uint64]Output
+}
+
+func newCache(entries int) *cache {
+ c := &cache{per: entries/64 + 1}
+ for i := range c.shards {
+ c.shards[i].m = make(map[uint64]Output)
+ }
+ return c
+}
+
+func (c *cache) get(k uint64) (Output, bool) {
+ s := &c.shards[k&63]
+ s.mu.Lock()
+ o, ok := s.m[k]
+ s.mu.Unlock()
+ return o, ok
+}
+
+func (c *cache) put(k uint64, o Output) {
+ s := &c.shards[k&63]
+ s.mu.Lock()
+ if len(s.m) >= c.per {
+ clear(s.m)
+ }
+ s.m[k] = o
+ s.mu.Unlock()
+}
diff --git a/internal/nn/eval.go b/internal/nn/eval.go
new file mode 100644
index 0000000..5ffcce4
--- /dev/null
+++ b/internal/nn/eval.go
@@ -0,0 +1,133 @@
+package nn
+
+import (
+ "math"
+ "math/rand"
+ "sync"
+
+ "github.com/DevomB/gofer/internal/game"
+)
+
+// Output is a network evaluation of one position.
+type Output struct {
+ Policy []float32 // move probabilities over n points + pass (index n), sums to 1
+ Value float32 // expected result in [-1, 1] for the side to move
+}
+
+// Evaluator scores positions. Implementations must be safe for concurrent use.
+type Evaluator interface {
+ // Eval evaluates every board; out has len(boards) entries.
+ Eval(boards []*game.Board, out []Output) error
+}
+
+// Backend runs the network on an encoded batch. spatial is
+// n*NumPlanes*points floats, global is n*NumGlobals. It returns policy
+// logits (n*(points+1)) and values (n).
+type Backend interface {
+ Run(spatial, global []float32, n int) (logits, values []float32, err error)
+ Close() error
+}
+
+// Softmax converts logits to probabilities in place.
+func Softmax(x []float32) {
+ max := x[0]
+ for _, v := range x[1:] {
+ if v > max {
+ max = v
+ }
+ }
+ var sum float64
+ for i, v := range x {
+ e := math.Exp(float64(v - max))
+ x[i] = float32(e)
+ sum += e
+ }
+ inv := float32(1 / sum)
+ for i := range x {
+ x[i] *= inv
+ }
+}
+
+// Uniform is a network-free evaluator: flat policy, value 0.
+type Uniform struct{}
+
+func (Uniform) Eval(boards []*game.Board, out []Output) error {
+ for i, b := range boards {
+ p := make([]float32, b.NumPoints()+1)
+ for j := range p {
+ p[j] = 1 / float32(len(p))
+ }
+ out[i] = Output{Policy: p}
+ }
+ return nil
+}
+
+// Rollout is the classic network-free baseline: flat policy and a value
+// from random playouts that never fill their own single-point eyes.
+type Rollout struct {
+ Playouts int
+ mu sync.Mutex
+ rng *rand.Rand
+}
+
+// NewRollout returns a rollout evaluator using k playouts per position.
+func NewRollout(k int, seed int64) *Rollout {
+ return &Rollout{Playouts: k, rng: rand.New(rand.NewSource(seed))}
+}
+
+func (r *Rollout) Eval(boards []*game.Board, out []Output) error {
+ r.mu.Lock()
+ seed := r.rng.Int63()
+ r.mu.Unlock()
+ rng := rand.New(rand.NewSource(seed))
+ for i, b := range boards {
+ Uniform{}.Eval(boards[i:i+1], out[i:i+1])
+ me := b.ToPlay()
+ var sum float32
+ k := r.Playouts
+ if k < 1 {
+ k = 1
+ }
+ for j := 0; j < k; j++ {
+ switch randomPlayout(b, rng) {
+ case me:
+ sum++
+ case me.Opp():
+ sum--
+ }
+ }
+ out[i].Value = sum / float32(k)
+ }
+ return nil
+}
+
+func randomPlayout(start *game.Board, rng *rand.Rand) game.Color {
+ b := start.Clone()
+ cand := make([]game.Point, 0, b.NumPoints())
+ limit := b.MoveNum() + 3*b.NumPoints()
+ for !b.GameOver() && b.MoveNum() < limit {
+ cand = cand[:0]
+ for p := game.Point(0); int(p) < b.NumPoints(); p++ {
+ if b.At(p) == game.Empty && !isOwnEye(b, p) && b.IsLegal(p) {
+ cand = append(cand, p)
+ }
+ }
+ if len(cand) == 0 {
+ b.PlayUnchecked(game.Pass)
+ continue
+ }
+ b.PlayUnchecked(cand[rng.Intn(len(cand))])
+ }
+ return b.Winner()
+}
+
+// isOwnEye reports a point whose neighbors are all the mover's stones, none in atari.
+func isOwnEye(b *game.Board, p game.Point) bool {
+ me := b.ToPlay()
+ for _, q := range b.Neighbors(p) {
+ if b.At(q) != me || b.Libs(q) == 1 {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/nn/features.go b/internal/nn/features.go
new file mode 100644
index 0000000..1891479
--- /dev/null
+++ b/internal/nn/features.go
@@ -0,0 +1,125 @@
+// Package nn encodes positions for the network, defines the evaluator
+// interface the search uses, and batches evaluation requests across goroutines.
+package nn
+
+import "github.com/DevomB/gofer/internal/game"
+
+// Feature layout (schema 3). Keep in sync with training/gofer/features.py.
+const (
+ SchemaVersion = 3
+
+ PlaneOwn = 0
+ PlaneOpp = 1
+ PlaneOnBoard = 2 // all ones: lets convolutions see the edge through zero padding
+ PlaneOwnLib1 = 3
+ PlaneOwnLib2 = 4
+ PlaneOppLib1 = 5
+ PlaneOppLib2 = 6
+ PlaneKo = 7
+ PlaneHistory = 8 // 8..12: last 5 moves, most recent first
+ NumPlanes = PlaneHistory + game.HistoryLen
+ GlobalKomi = 0 // komi from the side to move's perspective / 15
+ GlobalPass1 = 1 // opponent's last move was a pass
+ GlobalPass2 = 2 // our previous move was a pass
+ GlobalProgress = 3 // move number / board area (capped at 2)
+ NumGlobals = 4
+)
+
+// Encode writes the features of b into spatial (NumPlanes*n) and global
+// (NumGlobals), viewed through symmetry sym. Both slices must be zeroed.
+func Encode(b *game.Board, sym int, spatial, global []float32) {
+ n := b.NumPoints()
+ t := symmetry(b.Size(), sym)
+ me, opp := b.ToPlay(), b.ToPlay().Opp()
+ for p := 0; p < n; p++ {
+ q := int(t[p])
+ spatial[PlaneOnBoard*n+q] = 1
+ c := b.At(game.Point(p))
+ if c == game.Empty {
+ continue
+ }
+ libs := b.Libs(game.Point(p))
+ if c == me {
+ spatial[PlaneOwn*n+q] = 1
+ if libs == 1 {
+ spatial[PlaneOwnLib1*n+q] = 1
+ } else if libs == 2 {
+ spatial[PlaneOwnLib2*n+q] = 1
+ }
+ } else if c == opp {
+ spatial[PlaneOpp*n+q] = 1
+ if libs == 1 {
+ spatial[PlaneOppLib1*n+q] = 1
+ } else if libs == 2 {
+ spatial[PlaneOppLib2*n+q] = 1
+ }
+ }
+ }
+ if ko := b.Ko(); ko != game.Pass {
+ spatial[PlaneKo*n+int(t[ko])] = 1
+ }
+ for i := 0; i < game.HistoryLen; i++ {
+ if m, ok := b.LastMove(i); ok && m != game.Pass {
+ spatial[(PlaneHistory+i)*n+int(t[m])] = 1
+ }
+ }
+ komi := b.Komi()
+ if me == game.Black {
+ komi = -komi
+ }
+ global[GlobalKomi] = float32(komi / 15)
+ if m, ok := b.LastMove(0); ok && m == game.Pass {
+ global[GlobalPass1] = 1
+ }
+ if m, ok := b.LastMove(1); ok && m == game.Pass {
+ global[GlobalPass2] = 1
+ }
+ progress := float32(b.MoveNum()) / float32(n)
+ if progress > 2 {
+ progress = 2
+ }
+ global[GlobalProgress] = progress
+}
+
+// EncodeBytes writes identity-symmetry spatial features as 0/1 bytes
+// (the on-disk training format).
+func EncodeBytes(b *game.Board, spatial []uint8, global []float32) {
+ n := b.NumPoints()
+ tmp := make([]float32, NumPlanes*n)
+ Encode(b, 0, tmp, global)
+ for i, v := range tmp {
+ spatial[i] = uint8(v)
+ }
+}
+
+// symTables[size][s][p] maps point p to its image under dihedral symmetry s.
+var symTables [game.MaxSize + 1]*[8][]game.Point
+
+func init() {
+ for size := 2; size <= game.MaxSize; size++ {
+ var tabs [8][]game.Point
+ n := size * size
+ for s := 0; s < 8; s++ {
+ tabs[s] = make([]game.Point, n)
+ for p := 0; p < n; p++ {
+ x, y := p%size, p/size
+ if s&4 != 0 {
+ x, y = y, x
+ }
+ if s&1 != 0 {
+ x = size - 1 - x
+ }
+ if s&2 != 0 {
+ y = size - 1 - y
+ }
+ tabs[s][p] = game.Point(y*size + x)
+ }
+ }
+ symTables[size] = &tabs
+ }
+}
+
+func symmetry(size, s int) []game.Point { return symTables[size][s] }
+
+// Symmetry exposes the point mapping for symmetry s (used by tests and tools).
+func Symmetry(size, s int) []game.Point { return symmetry(size, s) }
diff --git a/internal/nn/nn_test.go b/internal/nn/nn_test.go
new file mode 100644
index 0000000..96e8935
--- /dev/null
+++ b/internal/nn/nn_test.go
@@ -0,0 +1,134 @@
+package nn
+
+import (
+ "math"
+ "sync"
+ "testing"
+
+ "github.com/DevomB/gofer/internal/game"
+)
+
+func TestSymmetriesArePermutations(t *testing.T) {
+ for _, size := range []int{5, 9, 19} {
+ for s := 0; s < 8; s++ {
+ seen := map[game.Point]bool{}
+ for _, q := range Symmetry(size, s) {
+ seen[q] = true
+ }
+ if len(seen) != size*size {
+ t.Fatalf("size %d sym %d is not a permutation", size, s)
+ }
+ }
+ }
+}
+
+// fakeBackend puts logit 10 on every own stone, so decoded policy must peak
+// on own stones in the original orientation whatever symmetry was used.
+type fakeBackend struct {
+ points int
+ mu sync.Mutex
+ calls int
+}
+
+func (f *fakeBackend) Run(spatial, global []float32, n int) ([]float32, []float32, error) {
+ f.mu.Lock()
+ f.calls++
+ f.mu.Unlock()
+ per := NumPlanes * f.points
+ logits := make([]float32, n*(f.points+1))
+ values := make([]float32, n)
+ for i := 0; i < n; i++ {
+ for p := 0; p < f.points; p++ {
+ logits[i*(f.points+1)+p] = 10 * spatial[i*per+PlaneOwn*f.points+p]
+ }
+ values[i] = global[i*NumGlobals+GlobalKomi]
+ }
+ return logits, values, nil
+}
+
+func (f *fakeBackend) Close() error { return nil }
+
+func TestNetUndoesSymmetry(t *testing.T) {
+ fb := &fakeBackend{points: 81}
+ net, err := NewNet(NetConfig{Size: 9, Workers: 2, MaxBatch: 8, RandomSymmetry: true, Seed: 1},
+ func() (Backend, error) { return fb, nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer net.Close()
+ b := game.NewBoard(game.Options{Size: 9, Komi: 7.5})
+ b.Play(b.P(2, 3)) // black
+ b.Play(b.P(6, 6)) // white
+ b.Play(b.P(1, 7)) // black; white to move: own stone is (6,6)
+ var wg sync.WaitGroup
+ for g := 0; g < 16; g++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ out := make([]Output, 4)
+ if err := net.Eval([]*game.Board{b, b, b, b}, out); err != nil {
+ t.Error(err)
+ return
+ }
+ for _, o := range out {
+ best := 0
+ for p := range o.Policy {
+ if o.Policy[p] > o.Policy[best] {
+ best = p
+ }
+ }
+ if best != int(b.P(6, 6)) {
+ t.Errorf("policy peak at %d, want own stone %d", best, b.P(6, 6))
+ }
+ if math.Abs(float64(o.Value)-7.5/15) > 1e-6 {
+ t.Errorf("value %v: komi should be +7.5/15 for white to move", o.Value)
+ }
+ }
+ }()
+ }
+ wg.Wait()
+}
+
+func TestCacheHits(t *testing.T) {
+ fb := &fakeBackend{points: 81}
+ net, _ := NewNet(NetConfig{Size: 9, Workers: 1, CacheEntries: 1000}, func() (Backend, error) { return fb, nil })
+ defer net.Close()
+ b := game.NewBoard(game.Options{Size: 9})
+ out := make([]Output, 1)
+ for i := 0; i < 5; i++ {
+ net.Eval([]*game.Board{b}, out)
+ }
+ if fb.calls != 1 || net.Stats.CacheHits.Load() != 4 {
+ t.Fatalf("calls=%d hits=%d", fb.calls, net.Stats.CacheHits.Load())
+ }
+}
+
+func TestEncodeLiberties(t *testing.T) {
+ b := game.NewBoard(game.Options{Size: 9})
+ b.Play(b.P(0, 0)) // black corner stone: 2 libs
+ b.Play(b.P(1, 0)) // white: black now in atari
+ sp := make([]float32, NumPlanes*81)
+ gl := make([]float32, NumGlobals)
+ Encode(b, 0, sp, gl) // black to move
+ if sp[PlaneOwnLib1*81+0] != 1 || sp[PlaneOpp*81+1] != 1 || sp[(PlaneHistory)*81+1] != 1 || sp[(PlaneHistory+1)*81+0] != 1 {
+ t.Fatal("liberty/history planes wrong")
+ }
+ if gl[GlobalKomi] != 0 {
+ t.Fatal("zero komi expected")
+ }
+}
+
+func TestRolloutPrefersWinningSide(t *testing.T) {
+ b := game.NewBoard(game.Options{Size: 5, Komi: 0.5})
+ // Black owns everything but a corner: black to move should evaluate positive.
+ for _, p := range []game.Point{6, 7, 8, 11, 13, 16, 17, 18} {
+ b.SetToPlay(game.Black)
+ b.Play(p)
+ }
+ b.SetToPlay(game.Black)
+ out := make([]Output, 1)
+ NewRollout(64, 1).Eval([]*game.Board{b}, out)
+ if out[0].Value < 0.5 {
+ t.Fatalf("rollout value %v, expected black clearly winning", out[0].Value)
+ }
+}
diff --git a/internal/nn/ort.go b/internal/nn/ort.go
new file mode 100644
index 0000000..104e372
--- /dev/null
+++ b/internal/nn/ort.go
@@ -0,0 +1,154 @@
+//go:build onnx
+
+package nn
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+
+ ort "github.com/yalue/onnxruntime_go"
+)
+
+// ORTAvailable reports whether this binary was built with ONNX Runtime.
+const ORTAvailable = true
+
+var (
+ ortOnce sync.Once
+ ortErr error
+)
+
+func initORT() error {
+ ortOnce.Do(func() {
+ lib := findORTLibrary()
+ if lib == "" {
+ ortErr = fmt.Errorf("nn: ONNX Runtime not found; run scripts/install-ort.sh or set ONNXRUNTIME_SHARED_LIBRARY_PATH")
+ return
+ }
+ ort.SetSharedLibraryPath(lib)
+ ortErr = ort.InitializeEnvironment()
+ })
+ return ortErr
+}
+
+// findORTLibrary checks $ONNXRUNTIME_SHARED_LIBRARY_PATH, then
+// third_party/onnxruntime/lib next to the working directory or the binary.
+func findORTLibrary() string {
+ if p := os.Getenv("ONNXRUNTIME_SHARED_LIBRARY_PATH"); p != "" {
+ return p
+ }
+ roots := []string{"."}
+ if exe, err := os.Executable(); err == nil {
+ roots = append(roots, filepath.Dir(exe), filepath.Join(filepath.Dir(exe), ".."))
+ }
+ for _, r := range roots {
+ for _, pat := range []string{"libonnxruntime.so.*", "libonnxruntime.*.dylib"} {
+ m, _ := filepath.Glob(filepath.Join(r, "third_party", "onnxruntime", "lib", pat))
+ if len(m) > 0 {
+ return m[len(m)-1]
+ }
+ }
+ }
+ return ""
+}
+
+// ORTOptions selects the execution provider for an ONNX model.
+type ORTOptions struct {
+ Device string // "cpu", "cuda" or "tensorrt"
+ DeviceID int
+ CPUThreads int // intra-op threads per session (CPU)
+}
+
+type ortBackend struct {
+ sess *ort.DynamicAdvancedSession
+ size int
+ points int
+}
+
+// NewORTBackend loads an exported Gofer model (inputs spatial/global,
+// outputs policy/value) for a board of the given size.
+func NewORTBackend(path string, size int, o ORTOptions) (Backend, error) {
+ if err := initORT(); err != nil {
+ return nil, err
+ }
+ opts, err := ort.NewSessionOptions()
+ if err != nil {
+ return nil, err
+ }
+ defer opts.Destroy()
+ threads := o.CPUThreads
+ if threads < 1 {
+ threads = 1
+ }
+ if err := opts.SetIntraOpNumThreads(threads); err != nil {
+ return nil, err
+ }
+ if err := opts.SetInterOpNumThreads(1); err != nil {
+ return nil, err
+ }
+ switch o.Device {
+ case "", "cpu":
+ case "cuda":
+ cuda, err := ort.NewCUDAProviderOptions()
+ if err != nil {
+ return nil, fmt.Errorf("nn: CUDA provider unavailable (GPU build of ONNX Runtime required): %w", err)
+ }
+ defer cuda.Destroy()
+ if err := cuda.Update(map[string]string{"device_id": fmt.Sprint(o.DeviceID)}); err != nil {
+ return nil, err
+ }
+ if err := opts.AppendExecutionProviderCUDA(cuda); err != nil {
+ return nil, err
+ }
+ case "tensorrt":
+ trt, err := ort.NewTensorRTProviderOptions()
+ if err != nil {
+ return nil, fmt.Errorf("nn: TensorRT provider unavailable: %w", err)
+ }
+ defer trt.Destroy()
+ if err := trt.Update(map[string]string{"device_id": fmt.Sprint(o.DeviceID), "trt_fp16_enable": "1"}); err != nil {
+ return nil, err
+ }
+ if err := opts.AppendExecutionProviderTensorRT(trt); err != nil {
+ return nil, err
+ }
+ default:
+ return nil, fmt.Errorf("nn: unknown device %q", o.Device)
+ }
+ sess, err := ort.NewDynamicAdvancedSession(path, []string{"spatial", "global"}, []string{"policy", "value"}, opts)
+ if err != nil {
+ return nil, fmt.Errorf("nn: load %s: %w", path, err)
+ }
+ return &ortBackend{sess: sess, size: size, points: size * size}, nil
+}
+
+func (b *ortBackend) Run(spatial, global []float32, n int) ([]float32, []float32, error) {
+ side := int64(b.size)
+ sp, err := ort.NewTensor(ort.NewShape(int64(n), NumPlanes, side, side), spatial)
+ if err != nil {
+ return nil, nil, err
+ }
+ defer sp.Destroy()
+ gl, err := ort.NewTensor(ort.NewShape(int64(n), NumGlobals), global)
+ if err != nil {
+ return nil, nil, err
+ }
+ defer gl.Destroy()
+ pol, err := ort.NewEmptyTensor[float32](ort.NewShape(int64(n), int64(b.points+1)))
+ if err != nil {
+ return nil, nil, err
+ }
+ defer pol.Destroy()
+ val, err := ort.NewEmptyTensor[float32](ort.NewShape(int64(n)))
+ if err != nil {
+ return nil, nil, err
+ }
+ defer val.Destroy()
+ if err := b.sess.Run([]ort.Value{sp, gl}, []ort.Value{pol, val}); err != nil {
+ return nil, nil, err
+ }
+ return append([]float32(nil), pol.GetData()...), append([]float32(nil), val.GetData()...), nil
+}
+
+func (b *ortBackend) Close() error { return b.sess.Destroy() }
diff --git a/internal/nn/ort_stub.go b/internal/nn/ort_stub.go
new file mode 100644
index 0000000..e5790e6
--- /dev/null
+++ b/internal/nn/ort_stub.go
@@ -0,0 +1,20 @@
+//go:build !onnx
+
+package nn
+
+import "errors"
+
+// ORTAvailable reports whether this binary was built with ONNX Runtime.
+const ORTAvailable = false
+
+// ORTOptions selects the execution provider for an ONNX model.
+type ORTOptions struct {
+ Device string
+ DeviceID int
+ CPUThreads int
+}
+
+// NewORTBackend is unavailable without the onnx build tag.
+func NewORTBackend(path string, size int, o ORTOptions) (Backend, error) {
+ return nil, errors.New("nn: built without ONNX Runtime; rebuild with `make build` (CGO_ENABLED=1, -tags onnx)")
+}
diff --git a/internal/selfplay/npz.go b/internal/selfplay/npz.go
new file mode 100644
index 0000000..b413549
--- /dev/null
+++ b/internal/selfplay/npz.go
@@ -0,0 +1,88 @@
+package selfplay
+
+import (
+ "archive/zip"
+ "bytes"
+ "encoding/binary"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+)
+
+// npyArray is one array destined for an .npz file.
+type npyArray struct {
+ name string
+ descr string // numpy dtype string, e.g. "|u1", "= cfg.Games {
+ return
+ }
+ res, err := playGame(cfg, eval, g)
+ mu.Lock()
+ if err != nil {
+ if firstErr == nil {
+ firstErr = err
+ }
+ mu.Unlock()
+ return
+ }
+ stats.Games++
+ stats.Moves += len(res.moves)
+ stats.Rows += len(res.rows)
+ if res.winner == game.Black {
+ stats.BlackWins++
+ }
+ if res.resigned {
+ stats.Resigned++
+ }
+ if res.trigger != game.Empty {
+ stats.NoResignWithTrigger++
+ if res.trigger == res.winner {
+ stats.FalseResigns++
+ }
+ }
+ if cfg.SGFDir != "" {
+ writeSGF(cfg, g, res)
+ }
+ pending = append(pending, res)
+ if err := flush(false); err != nil && firstErr == nil {
+ firstErr = err
+ }
+ if stats.Games%50 == 0 {
+ el := time.Since(start).Seconds()
+ cfg.Log("selfplay: %d/%d games, %.1f games/s, %d rows, black wins %.1f%%",
+ stats.Games, cfg.Games, float64(stats.Games)/el, stats.Rows, 100*float64(stats.BlackWins)/float64(stats.Games))
+ }
+ mu.Unlock()
+ }
+ }()
+ }
+ wg.Wait()
+ if firstErr != nil {
+ return stats, firstErr
+ }
+ if err := flush(true); err != nil {
+ return stats, err
+ }
+ stats.Elapsed = time.Since(start)
+ return stats, nil
+}
+
+func playGame(cfg Config, eval nn.Evaluator, idx int) (gameResult, error) {
+ rng := rand.New(rand.NewSource(cfg.Seed*1_000_003 + int64(idx)))
+ b := game.NewBoard(game.Options{Size: cfg.Size, Komi: cfg.Komi, Superko: cfg.Superko})
+ sc := cfg.Search
+ sc.Seed = rng.Int63()
+ s := mcts.New(sc, eval)
+ s.Reset(b)
+ allowResign := cfg.ResignThreshold != 0 && rng.Float64() >= cfg.NoResignProb
+ maxMoves := sc.MaxMoves
+ if maxMoves <= 0 {
+ maxMoves = 3 * b.NumPoints()
+ }
+ n := b.NumPoints()
+ var res gameResult
+ for !b.GameOver() && b.MoveNum() < maxMoves {
+ full := cfg.FastVisits <= 0 || rng.Float64() < cfg.FullProb
+ visits := cfg.FastVisits
+ if full {
+ visits = cfg.FullVisits
+ }
+ s.SetRootNoise(full)
+ if err := s.Run(visits); err != nil {
+ return res, err
+ }
+ me := b.ToPlay()
+ if full {
+ r := row{
+ spatial: make([]uint8, nn.NumPlanes*n),
+ global: make([]float32, nn.NumGlobals),
+ policy: s.PolicyTarget(),
+ q: float32(s.RootValue()),
+ toPlay: me,
+ }
+ nn.EncodeBytes(b, r.spatial, r.global)
+ res.rows = append(res.rows, r)
+ if cfg.ResignThreshold != 0 && s.RootValue() < cfg.ResignThreshold {
+ if allowResign {
+ res.resigned = true
+ res.winner = me.Opp()
+ break
+ }
+ if res.trigger == game.Empty {
+ res.trigger = me
+ }
+ }
+ }
+ m := s.BestMove()
+ if b.MoveNum() < cfg.TempMoves {
+ m = s.SampleMove(cfg.Temperature)
+ }
+ if err := b.Play(m); err != nil {
+ return res, fmt.Errorf("selfplay: search chose %w", err)
+ }
+ res.moves = append(res.moves, sgf.Move{Color: me, Point: m})
+ s.Advance(m, b)
+ }
+ if !res.resigned {
+ res.winner = b.Winner()
+ res.margin = b.Margin()
+ res.ownership = b.Ownership()
+ }
+ return res, nil
+}
+
+func writeShard(path string, size int, games []gameResult) error {
+ n := size * size
+ var rows int
+ for _, g := range games {
+ rows += len(g.rows)
+ }
+ spatial := make([]uint8, 0, rows*nn.NumPlanes*n)
+ global := make([]float32, 0, rows*nn.NumGlobals)
+ policy := make([]float32, 0, rows*(n+1))
+ value := make([]float32, 0, rows)
+ q := make([]float32, 0, rows)
+ own := make([]int8, 0, rows*n)
+ ownW := make([]float32, 0, rows)
+ margin := make([]float32, 0, rows)
+ for _, g := range games {
+ for _, r := range g.rows {
+ spatial = append(spatial, r.spatial...)
+ global = append(global, r.global...)
+ policy = append(policy, r.policy...)
+ q = append(q, r.q)
+ sign := float32(1)
+ if r.toPlay == game.White {
+ sign = -1
+ }
+ switch g.winner {
+ case r.toPlay:
+ value = append(value, 1)
+ case game.Empty:
+ value = append(value, 0)
+ default:
+ value = append(value, -1)
+ }
+ if g.ownership == nil {
+ own = append(own, make([]int8, n)...)
+ ownW = append(ownW, 0)
+ margin = append(margin, 0)
+ continue
+ }
+ for _, c := range g.ownership {
+ switch c {
+ case r.toPlay:
+ own = append(own, 1)
+ case r.toPlay.Opp():
+ own = append(own, -1)
+ default:
+ own = append(own, 0)
+ }
+ }
+ ownW = append(ownW, 1)
+ margin = append(margin, sign*float32(g.margin))
+ }
+ }
+ return writeNPZ(path, []npyArray{
+ {"spatial", "|u1", []int{rows, nn.NumPlanes, size, size}, spatial},
+ {"global", " game.MaxSize {
+ return nil, fmt.Errorf("sgf: bad SZ[%s]", v)
+ }
+ g.Size = n
+ }
+ if v := root.Get("KM"); v != "" {
+ k, err := strconv.ParseFloat(v, 64)
+ if err != nil {
+ return nil, fmt.Errorf("sgf: bad KM[%s]", v)
+ }
+ g.Komi = k
+ }
+ g.Result = root.Get("RE")
+ }
+ return g, nil
+}
+
+// Get returns the first value of a property, or "".
+func (n *Node) Get(key string) string {
+ if v := n.Props[key]; len(v) > 0 {
+ return v[0]
+ }
+ return ""
+}
+
+// Coord decodes an SGF coordinate ("aa" = top-left). An empty value or "tt"
+// on boards up to 19x19 is a pass.
+func Coord(s string, size int) (game.Point, error) {
+ if s == "" || (s == "tt" && size <= 19) {
+ return game.Pass, nil
+ }
+ if len(s) != 2 {
+ return game.Pass, fmt.Errorf("sgf: coord %q", s)
+ }
+ x, y := int(s[0]-'a'), int(s[1]-'a')
+ if x < 0 || y < 0 || x >= size || y >= size {
+ return game.Pass, fmt.Errorf("sgf: coord %q off %dx%d board", s, size, size)
+ }
+ return game.Point(y*size + x), nil
+}
+
+func coordString(p game.Point, size int) string {
+ if p == game.Pass {
+ return ""
+ }
+ return string([]byte{'a' + byte(int(p)%size), 'a' + byte(int(p)/size)})
+}
+
+// MainLine returns moves along the first variation.
+func (g *Game) MainLine() ([]Move, error) {
+ var out []Move
+ for n := g.Root; n != nil; {
+ for _, tag := range []string{"B", "W"} {
+ c := game.Black
+ if tag == "W" {
+ c = game.White
+ }
+ for _, v := range n.Props[tag] {
+ p, err := Coord(v, g.Size)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, Move{Color: c, Point: p})
+ }
+ }
+ if len(n.Children) == 0 {
+ break
+ }
+ n = n.Children[0]
+ }
+ return out, nil
+}
+
+// Board builds the initial position (setup stones and side to move).
+func (g *Game) Board(superko bool) (*game.Board, error) {
+ b := game.NewBoard(game.Options{Size: g.Size, Komi: g.Komi, Superko: superko})
+ if g.Root == nil {
+ return b, nil
+ }
+ var black, white []game.Point
+ for tag, dst := range map[string]*[]game.Point{"AB": &black, "AW": &white} {
+ for _, v := range g.Root.Props[tag] {
+ p, err := Coord(v, g.Size)
+ if err != nil || p == game.Pass {
+ return nil, fmt.Errorf("sgf: bad %s[%s]", tag, v)
+ }
+ *dst = append(*dst, p)
+ }
+ }
+ if len(black)+len(white) > 0 {
+ if err := b.Setup(black, white); err != nil {
+ return nil, err
+ }
+ }
+ if g.Root.Get("PL") == "W" || g.Root.Get("PL") == "2" {
+ b.SetToPlay(game.White)
+ }
+ return b, nil
+}
+
+// Replay plays the main line and returns the final board.
+func (g *Game) Replay() (*game.Board, []Move, error) {
+ b, err := g.Board(true)
+ if err != nil {
+ return nil, nil, err
+ }
+ moves, err := g.MainLine()
+ if err != nil {
+ return nil, nil, err
+ }
+ for i, m := range moves {
+ if b.ToPlay() != m.Color {
+ b.SetToPlay(m.Color) // SGF allows consecutive moves by one color
+ }
+ if err := b.Play(m.Point); err != nil {
+ return nil, nil, fmt.Errorf("sgf: move %d: %w", i+1, err)
+ }
+ }
+ return b, moves, nil
+}
+
+// Record describes a finished game for export.
+type Record struct {
+ Size int
+ Komi float64
+ Black string
+ White string
+ Result string
+ Moves []Move
+ MoveComment map[int]string // optional per-move comments, keyed by move index
+}
+
+// Write renders a record as SGF.
+func Write(r Record) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "(;FF[4]GM[1]SZ[%d]KM[%s]RU[Chinese]", r.Size, strconv.FormatFloat(r.Komi, 'f', -1, 64))
+ if r.Black != "" {
+ fmt.Fprintf(&b, "PB[%s]", escape(r.Black))
+ }
+ if r.White != "" {
+ fmt.Fprintf(&b, "PW[%s]", escape(r.White))
+ }
+ if r.Result != "" {
+ fmt.Fprintf(&b, "RE[%s]", escape(r.Result))
+ }
+ for i, m := range r.Moves {
+ fmt.Fprintf(&b, ";%s[%s]", m.Color, coordString(m.Point, r.Size))
+ if c, ok := r.MoveComment[i]; ok {
+ fmt.Fprintf(&b, "C[%s]", escape(c))
+ }
+ }
+ b.WriteString(")\n")
+ return b.String()
+}
+
+func escape(s string) string {
+ return strings.NewReplacer(`\`, `\\`, `]`, `\]`).Replace(s)
+}
+
+type parser struct {
+ s string
+ i int
+}
+
+func (p *parser) eof() bool { return p.i >= len(p.s) }
+
+func (p *parser) peek() byte {
+ if p.eof() {
+ return 0
+ }
+ return p.s[p.i]
+}
+
+func (p *parser) skipWS() {
+ for !p.eof() && strings.IndexByte(" \t\r\n", p.s[p.i]) >= 0 {
+ p.i++
+ }
+}
+
+// sequence parses "(" node* subtree* ")" and returns its first node.
+func (p *parser) sequence() (*Node, error) {
+ p.i++ // '('
+ var first, cur *Node
+ for {
+ p.skipWS()
+ if p.eof() {
+ return nil, fmt.Errorf("sgf: unclosed '('")
+ }
+ switch p.peek() {
+ case ';':
+ n, err := p.node()
+ if err != nil {
+ return nil, err
+ }
+ if first == nil {
+ first = n
+ } else {
+ cur.Children = append(cur.Children, n)
+ }
+ cur = n
+ case '(':
+ child, err := p.sequence()
+ if err != nil {
+ return nil, err
+ }
+ switch {
+ case cur != nil:
+ cur.Children = append(cur.Children, child)
+ case first == nil:
+ first = child
+ }
+ case ')':
+ p.i++
+ return first, nil
+ default:
+ return nil, fmt.Errorf("sgf: unexpected %q at offset %d", p.peek(), p.i)
+ }
+ }
+}
+
+func (p *parser) node() (*Node, error) {
+ p.i++ // ';'
+ n := &Node{Props: map[string][]string{}}
+ for {
+ p.skipWS()
+ c := p.peek()
+ if c < 'A' || c > 'Z' {
+ return n, nil
+ }
+ start := p.i
+ for !p.eof() && p.s[p.i] >= 'A' && p.s[p.i] <= 'Z' {
+ p.i++
+ }
+ key := p.s[start:p.i]
+ p.skipWS()
+ if p.peek() != '[' {
+ return nil, fmt.Errorf("sgf: property %s without value", key)
+ }
+ for p.skipWS(); p.peek() == '['; p.skipWS() {
+ v, err := p.value()
+ if err != nil {
+ return nil, err
+ }
+ n.Props[key] = append(n.Props[key], v)
+ }
+ }
+}
+
+func (p *parser) value() (string, error) {
+ p.i++ // '['
+ var b strings.Builder
+ for !p.eof() {
+ c := p.s[p.i]
+ switch {
+ case c == ']':
+ p.i++
+ return b.String(), nil
+ case c == '\\' && p.i+1 < len(p.s):
+ b.WriteByte(p.s[p.i+1])
+ p.i += 2
+ default:
+ b.WriteByte(c)
+ p.i++
+ }
+ }
+ return "", fmt.Errorf("sgf: unclosed '['")
+}
diff --git a/internal/sgf/sgf_test.go b/internal/sgf/sgf_test.go
new file mode 100644
index 0000000..66e48ad
--- /dev/null
+++ b/internal/sgf/sgf_test.go
@@ -0,0 +1,58 @@
+package sgf
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/DevomB/gofer/internal/game"
+)
+
+func TestReplayTestdata(t *testing.T) {
+ files, _ := filepath.Glob("testdata/*.sgf")
+ if len(files) == 0 {
+ t.Fatal("no testdata")
+ }
+ for _, f := range files {
+ data, err := os.ReadFile(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ g, err := Parse(string(data))
+ if err != nil {
+ t.Fatalf("%s: %v", f, err)
+ }
+ if _, _, err := g.Replay(); err != nil {
+ t.Fatalf("%s: %v", f, err)
+ }
+ }
+}
+
+func TestRoundTrip(t *testing.T) {
+ rec := Record{Size: 9, Komi: 7, Black: "a]b", Result: "B+R", Moves: []Move{
+ {game.Black, game.Point(40)}, {game.White, game.Pass}, {game.Black, game.Point(0)},
+ }}
+ g, err := Parse(Write(rec))
+ if err != nil {
+ t.Fatal(err)
+ }
+ moves, err := g.MainLine()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if g.Size != 9 || g.Komi != 7 || g.Result != "B+R" || g.Root.Get("PB") != "a]b" || len(moves) != 3 {
+ t.Fatalf("round trip lost data: %+v %v", g, moves)
+ }
+ for i := range moves {
+ if moves[i] != rec.Moves[i] {
+ t.Fatalf("move %d = %+v want %+v", i, moves[i], rec.Moves[i])
+ }
+ }
+}
+
+func TestTTIsPass(t *testing.T) {
+ p, err := Coord("tt", 9)
+ if err != nil || p != game.Pass {
+ t.Fatalf("tt on 9x9 should be pass, got %v %v", p, err)
+ }
+}
diff --git a/cmd/gofer/testdata/capture.sgf b/internal/sgf/testdata/capture.sgf
similarity index 100%
rename from cmd/gofer/testdata/capture.sgf
rename to internal/sgf/testdata/capture.sgf
diff --git a/cmd/gofer/testdata/ko.sgf b/internal/sgf/testdata/ko.sgf
similarity index 100%
rename from cmd/gofer/testdata/ko.sgf
rename to internal/sgf/testdata/ko.sgf
diff --git a/cmd/gofer/testdata/open.sgf b/internal/sgf/testdata/open.sgf
similarity index 100%
rename from cmd/gofer/testdata/open.sgf
rename to internal/sgf/testdata/open.sgf
diff --git a/cmd/gofer/testdata/pass.sgf b/internal/sgf/testdata/pass.sgf
similarity index 100%
rename from cmd/gofer/testdata/pass.sgf
rename to internal/sgf/testdata/pass.sgf
diff --git a/cmd/gofer/testdata/setup.sgf b/internal/sgf/testdata/setup.sgf
similarity index 100%
rename from cmd/gofer/testdata/setup.sgf
rename to internal/sgf/testdata/setup.sgf
diff --git a/cmd/gofer/testdata/simple.sgf b/internal/sgf/testdata/simple.sgf
similarity index 100%
rename from cmd/gofer/testdata/simple.sgf
rename to internal/sgf/testdata/simple.sgf
diff --git a/models/README.md b/models/README.md
index 8d86d80..4aa02d1 100644
--- a/models/README.md
+++ b/models/README.md
@@ -1,27 +1,25 @@
-# Bootstrap ONNX model
+# Models
-9×9 ResNet-small for Gofer. Production artifacts (v3):
+## gofer-9x9.onnx
-| File | Role |
-|------|------|
-| `gofer-9x9-best.onnx` | Arena-verified deployed model |
-| `gofer-9x9-candidate.onnx` | Pre-arena export this cycle |
-| `gofer-9x9-bootstrap.onnx` | CLI compat alias → `best.onnx` |
+| | |
+|--|--|
+| Board | 9×9, komi 7.5, area scoring, positional superko |
+| Architecture | 6 residual blocks × 64 channels, global-pooling blocks, KataGo-style heads (`training/model.py`) |
+| Parameters | 476,932 |
+| Inputs | `spatial` [N, 13, 9, 9], `global` [N, 4] (feature schema 3, see [docs/architecture.md](../docs/architecture.md)) |
+| Outputs | `policy` [N, 82] logits (81 points + pass), `value` [N] in [−1, 1] for the side to move |
+| Training | Generation 17 of a from-scratch self-play run: 10,200 games, 197k training positions, 3.6 h wall time on a 4-vCPU cloud VM, no human data |
-State on disk: `training/state/best.pt` (matches deployed ONNX weights).
+Strength is reported in the [README](../README.md#results) and the full run
+summary is in [docs/results](../docs/results/).
-Regenerate fixture:
+Reproduce:
```bash
-pip install -r training/requirements.txt
-python training/export_onnx.py --out models/gofer-9x9-bootstrap.onnx
+.venv/bin/python -m training.loop --run runs/9x9 --gofer bin/gofer --gens 17
+cp runs/9x9/models/gen-0017.onnx models/gofer-9x9.onnx
```
-Trained export (resume from state):
-
-```bash
-python training/train_bootstrap.py --data training/data/replay.jsonl --resume training/state/best.pt --out-dir training/state
-python training/export_onnx.py --checkpoint training/state/best.pt --out models/gofer-9x9-candidate.onnx
-```
-
-Input shapes: `spatial_input [N,8,9,9]`, `global_input [N,4]`. See [docs/model-input-schema.md](../docs/model-input-schema.md).
+Self-play is stochastic, so a rerun produces a network of similar but not
+identical strength.
diff --git a/models/gofer-9x9-bootstrap.onnx b/models/gofer-9x9-bootstrap.onnx
deleted file mode 100644
index c051e01..0000000
Binary files a/models/gofer-9x9-bootstrap.onnx and /dev/null differ
diff --git a/models/gofer-9x9.onnx b/models/gofer-9x9.onnx
new file mode 100644
index 0000000..24a7c04
Binary files /dev/null and b/models/gofer-9x9.onnx differ
diff --git a/scripts/aws-run-arena.sh b/scripts/aws-run-arena.sh
deleted file mode 100644
index a0ccbce..0000000
--- a/scripts/aws-run-arena.sh
+++ /dev/null
@@ -1,159 +0,0 @@
-#!/usr/bin/env bash
-# Launch full Gofer v2.5 pipeline on an existing Lightsail instance.
-# Usage:
-# bash scripts/aws-run-arena.sh # start (uses default instance)
-# bash scripts/aws-run-arena.sh IP status # tail log
-# bash scripts/aws-run-arena.sh IP fetch # download arena JSON
-# bash scripts/aws-run-arena.sh IP destroy # delete Lightsail instance
-# bash scripts/aws-run-arena.sh IP inprocess-cycle # one in-process ORT cycle (no sidecar)
-set -euo pipefail
-
-# shellcheck disable=SC1091
-source "$(cd "$(dirname "$0")" && pwd)/common.sh"
-# shellcheck disable=SC1091
-source "$ROOT/scripts/gating.env"
-KEY="${GOFER_LIGHTSAIL_KEY:-$ROOT/.tectonix/gofer-v25-run.pem}"
-INSTANCE="${1:-}"
-CMD="${2:-start}"
-REGION="${AWS_REGION:-us-east-1}"
-NAME="${GOFER_INSTANCE_NAME:-gofer-v25-arena}"
-
-if [[ -z "$INSTANCE" ]]; then
- INSTANCE="$(aws lightsail get-instance --instance-name "$NAME" --region "$REGION" \
- --query 'instance.publicIpAddress' --output text 2>/dev/null || true)"
-fi
-if [[ -z "$INSTANCE" || "$INSTANCE" == "None" ]]; then
- echo "No instance IP. Create one or pass IP as first argument."
- exit 1
-fi
-if [[ ! -f "$KEY" ]]; then
- echo "SSH key not found: $KEY"
- exit 1
-fi
-
-SSH=(ssh -i "$KEY" -o StrictHostKeyChecking=no -o ConnectTimeout=30 "ubuntu@${INSTANCE}")
-SCP=(scp -i "$KEY" -o StrictHostKeyChecking=no)
-
-case "$CMD" in
-stop-loop)
- "${SSH[@]}" 'cd ~/Gofer 2>/dev/null || exit 0; \
- test -f week.pid && kill $(cat week.pid) 2>/dev/null || true; \
- pkill -f inference_server.py 2>/dev/null || true; \
- pkill -f train-loop-v3.sh 2>/dev/null || true; \
- pkill -f weekly-train-loop.sh 2>/dev/null || true; \
- echo stopped'
- exit 0
- ;;
-week)
- echo "DEPRECATED: use start-v3. Forwarding..."
- ;&
-inprocess-cycle|start-v3-inprocess)
- echo "==> one in-process ORT cycle on $INSTANCE (no sidecar)"
- "${SSH[@]}" "cd ~/Gofer && git pull --ff-only && chmod +x scripts/lightsail-inprocess-cycle.sh scripts/train-loop-v3.sh scripts/parity-onnx.sh && \
- nohup bash scripts/lightsail-inprocess-cycle.sh > train-inprocess-cycle.log 2>&1 & echo \$! > inprocess.pid && echo inprocess_pid=\$(cat inprocess.pid)"
- echo "poll: bash scripts/aws-run-arena.sh $INSTANCE inprocess-status"
- echo "fetch: bash scripts/aws-run-arena.sh $INSTANCE inprocess-fetch"
- exit 0
- ;;
-inprocess-status)
- "${SSH[@]}" 'tail -40 ~/Gofer/train-inprocess-cycle.log 2>/dev/null || echo no log; \
- echo "=== rss ===" && tail -15 ~/Gofer/.tectonix/reports/inprocess-rss.log 2>/dev/null || true; \
- ps aux | grep -E "[g]ofer|[t]rain-loop" | head -5 || true'
- exit 0
- ;;
-inprocess-fetch)
- mkdir -p "$ROOT/.tectonix/reports"
- "${SCP[@]}" "ubuntu@${INSTANCE}:~/Gofer/train-inprocess-cycle.log" \
- "$ROOT/.tectonix/reports/train-inprocess-cycle.log" 2>/dev/null || true
- "${SCP[@]}" "ubuntu@${INSTANCE}:~/Gofer/.tectonix/reports/inprocess-rss.log" \
- "$ROOT/.tectonix/reports/inprocess-rss.log" 2>/dev/null || true
- "${SCP[@]}" "ubuntu@${INSTANCE}:~/Gofer/.tectonix/reports/arena-cycle-"*.json \
- "$ROOT/.tectonix/reports/" 2>/dev/null || true
- echo "fetched inprocess logs to .tectonix/reports/"
- exit 0
- ;;
-start-v3)
- echo "==> start train-loop-v3 on $INSTANCE"
- "${SSH[@]}" "cd ~/Gofer && git pull --ff-only && chmod +x scripts/train-loop-v3.sh && \
- (test -f week.pid && kill \$(cat week.pid) 2>/dev/null || true); \
- pkill -f inference_server.py 2>/dev/null || true; \
- nohup env SEED_FROM_CYCLE2=${SEED_FROM_CYCLE2:-0} WEEK_DAYS=${WEEK_DAYS:-14} \
- PROMOTE_WIN=${PROMOTE_WIN:-0.55} NEW_SELFPLAY_PER_CYCLE=${NEW_SELFPLAY_PER_CYCLE:-200} \
- bash scripts/train-loop-v3.sh > train-v3.log 2>&1 & \
- echo \$! > week.pid && echo v3_loop_pid=\$(cat week.pid)"
- echo "poll: bash scripts/aws-run-arena.sh $INSTANCE week-status"
- exit 0
- ;;
-fetch-all)
- mkdir -p "$ROOT/.tectonix/reports/training-history"
- "${SCP[@]}" "ubuntu@${INSTANCE}:~/Gofer/.tectonix/reports/arena-cycle-*.json" \
- "$ROOT/.tectonix/reports/" 2>/dev/null || true
- "${SCP[@]}" -r "ubuntu@${INSTANCE}:~/Gofer/.tectonix/reports/training-history/" \
- "$ROOT/.tectonix/reports/" 2>/dev/null || true
- "${SCP[@]}" "ubuntu@${INSTANCE}:~/Gofer/train-v3.log" "$ROOT/.tectonix/reports/train-v3.log" 2>/dev/null || true
- "${SCP[@]}" "ubuntu@${INSTANCE}:~/Gofer/training/state/manifest.json" \
- "$ROOT/.tectonix/reports/manifest.json" 2>/dev/null || true
- echo "fetched reports to .tectonix/reports/"
- exit 0
- ;;
-seed-status)
- "${SSH[@]}" 'cd ~/Gofer && echo "=== manifest ===" && cat training/state/manifest.json 2>/dev/null || echo no manifest; \
- echo "=== artifacts ===" && ls -la training/state/best.pt models/gofer-9x9-best.onnx 2>/dev/null || true'
- exit 0
- ;;
-status)
- "${SSH[@]}" 'tail -40 ~/Gofer/run.log 2>/dev/null || echo no log yet; ps aux | grep -E "remote-arena|inference_server|gofer" | grep -v grep | head -5 || true'
- exit 0
- ;;
-fetch)
- mkdir -p "$ROOT/.tectonix/reports"
- "${SCP[@]}" "ubuntu@${INSTANCE}:~/Gofer/.tectonix/reports/arena-9x9-onnx-v25.json" \
- "$ROOT/.tectonix/reports/arena-9x9-onnx-v25.json" 2>/dev/null || echo "report not ready yet"
- "${SCP[@]}" "ubuntu@${INSTANCE}:~/Gofer/run.log" "$ROOT/.tectonix/reports/lightsail-run.log" 2>/dev/null || true
- exit 0
- ;;
-wait)
- echo "==> waiting for arena JSON on $INSTANCE (poll 60s)..."
- while true; do
- if "${SSH[@]}" 'test -f ~/Gofer/.tectonix/reports/arena-9x9-onnx-v25.json'; then
- bash "$ROOT/scripts/aws-run-arena.sh" "$INSTANCE" fetch
- grep -E 'win_rate_challenger|wilson_ci' "$ROOT/.tectonix/reports/arena-9x9-onnx-v25.json" || true
- exit 0
- fi
- "${SSH[@]}" 'tail -2 ~/Gofer/run.log 2>/dev/null' || true
- sleep 60
- done
- ;;
-destroy)
- aws lightsail delete-instance --instance-name "$NAME" --region "$REGION"
- echo "deleted $NAME"
- exit 0
- ;;
-week-status)
- "${SSH[@]}" 'tail -30 ~/Gofer/train-v3.log 2>/dev/null || tail -30 ~/Gofer/week.log 2>/dev/null || tail -30 ~/Gofer/run.log 2>/dev/null || echo no log'
- exit 0
- ;;
-start)
- ;;
-*)
- echo "unknown command: $CMD"
- exit 1
- ;;
-esac
-
-echo "==> instance $INSTANCE ($NAME)"
-"${SSH[@]}" "export DEBIAN_FRONTEND=noninteractive; command -v git >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y git; }"
-
-echo "==> clone / update repo"
-"${SSH[@]}" 'if [[ -d Gofer ]]; then cd Gofer && git pull --ff-only; else git clone https://github.com/DevomB/Gofer.git && cd Gofer; fi'
-
-if "${SSH[@]}" 'test -f ~/Gofer/run.pid && kill -0 "$(cat ~/Gofer/run.pid)" 2>/dev/null'; then
- echo "pipeline already running pid=$(${SSH[@]} 'cat ~/Gofer/run.pid')"
- exit 0
-fi
-
-echo "==> start pipeline in background (log: ~/Gofer/run.log)"
-"${SSH[@]}" 'cd ~/Gofer && chmod +x scripts/remote-arena-gate.sh && nohup bash scripts/remote-arena-gate.sh > run.log 2>&1 & echo $! > run.pid && echo started pid=$(cat run.pid)'
-echo "poll: bash scripts/aws-run-arena.sh $INSTANCE status"
-echo "fetch: bash scripts/aws-run-arena.sh $INSTANCE fetch"
-echo "wait: bash scripts/aws-run-arena.sh $INSTANCE wait"
diff --git a/scripts/bench-arena-identical.sh b/scripts/bench-arena-identical.sh
deleted file mode 100644
index b2fc0a3..0000000
--- a/scripts/bench-arena-identical.sh
+++ /dev/null
@@ -1,55 +0,0 @@
-#!/usr/bin/env bash
-# Equal-strength ONNX arena probe (same weights, two sidecars).
-set -euo pipefail
-ROOT="$(cd "$(dirname "$0")/.." && pwd)"
-cd "$ROOT"
-export PYTHONPATH="${ROOT}:${PYTHONPATH:-}"
-CHAMP=8080
-CHAL=8081
-REPORT_DIR=".tectonix/reports/arena-bench"
-json="${REPORT_DIR}/identical-onnx.json"
-mkdir -p "$REPORT_DIR"
-
-pkill -f inference_server.py 2>/dev/null || true
-sleep 1
-python training/inference_server.py --model models/gofer-9x9-best.onnx --port "$CHAMP" &
-python training/inference_server.py --model models/gofer-9x9-candidate.onnx --port "$CHAL" &
-for _ in $(seq 1 30); do
- curl -sf "http://127.0.0.1:${CHAMP}/health" >/dev/null && curl -sf "http://127.0.0.1:${CHAL}/health" >/dev/null && break
- sleep 1
-done
-
-t0=$(date +%s.%N)
-bin/gofer -arena -games 200 -size 9 -playouts 400 -black-eval onnx -white-eval onnx2 \
- -onnx-url "http://127.0.0.1:${CHAMP}" -onnx-url-2 "http://127.0.0.1:${CHAL}" \
- -arena-parallel 8 -arena-opening-moves 8 -eval-timeout 2s -arena-enhanced none \
- -seed 99 -json "$json"
-t1=$(date +%s.%N)
-pkill -f inference_server.py 2>/dev/null || true
-
-python3 - "$json" "$t0" "$t1" <<'PY'
-import json, sys
-from pathlib import Path
-d = json.loads(Path(sys.argv[1]).read_text())
-sec = round(float(sys.argv[3]) - float(sys.argv[2]), 1)
-n = d["game_count"]
-bw, ww, dr = d["wins_black"], d["wins_white"], d["draws"]
-rate, lo, hi = d["win_rate_challenger"], d["wilson_ci_low"], d["wilson_ci_high"]
-dec = "promote" if rate >= 0.55 and lo > 0.5 else "reject"
-onnx_w = onnx2_w = 0
-for g in d["games"]:
- if g["black_wins"]:
- if g["black_eval"] == "onnx":
- onnx_w += 1
- else:
- onnx2_w += 1
- elif g["white_wins"]:
- if g["white_eval"] == "onnx":
- onnx_w += 1
- else:
- onnx2_w += 1
-print(
- f"identical-onnx|n={n}|black={bw}({bw/n:.3f})|white={ww}({ww/n:.3f})|draws={dr}|"
- f"onnx_w={onnx_w}|onnx2_w={onnx2_w}|challenger={rate:.3f}|wilson=[{lo:.3f},{hi:.3f}]|{dec}|sec={sec}"
-)
-PY
diff --git a/scripts/bench-arena.sh b/scripts/bench-arena.sh
deleted file mode 100644
index 1039b52..0000000
--- a/scripts/bench-arena.sh
+++ /dev/null
@@ -1,137 +0,0 @@
-#!/usr/bin/env bash
-# Run standard arena scenarios and emit per-cycle metrics.
-set -euo pipefail
-
-# shellcheck disable=SC1091
-source "$(cd "$(dirname "$0")" && pwd)/common.sh"
-cd "$ROOT"
-
-GOFER_BIN="${GOFER_BIN:-bin/gofer}"
-ARENA_GAMES="${ARENA_GAMES:-200}"
-ARENA_PLAYOUTS="${ARENA_PLAYOUTS:-400}"
-ARENA_OPENING_MOVES="${ARENA_OPENING_MOVES:-8}"
-PARALLEL="${PARALLEL:-$(nproc 2>/dev/null || echo 8)}"
-REPORT_DIR=".tectonix/reports/arena-bench"
-PROMOTE_WIN="${PROMOTE_WIN:-0.55}"
-
-mkdir -p "$REPORT_DIR" models bin
-export PATH="/usr/local/go/bin:${PATH:-}"
-export PYTHONPATH="${ROOT}:${PYTHONPATH:-}"
-if [[ -f .venv/bin/activate ]]; then
- # shellcheck disable=SC1091
- source .venv/bin/activate
-fi
-
-go build -o "$GOFER_BIN" ./cmd/gofer
-
-CHAMP_PORT=8080
-CHALLENGER_PORT=8081
-SIDECAR_PIDS=()
-
-start_sidecar() {
- local model="$1" port="$2"
- python training/inference_server.py --model "$model" --port "$port" &
- SIDECAR_PIDS+=($!)
- for _ in $(seq 1 30); do
- if curl -sf "http://127.0.0.1:${port}/health" >/dev/null; then return 0; fi
- sleep 1
- done
- echo "sidecar on port ${port} failed" >&2
- return 1
-}
-
-stop_sidecars() {
- pkill -f 'inference_server.py' 2>/dev/null || true
- for pid in "${SIDECAR_PIDS[@]:-}"; do kill "$pid" 2>/dev/null || true; done
- SIDECAR_PIDS=()
-}
-trap stop_sidecars EXIT
-
-if [[ ! -f models/gofer-9x9-bootstrap.onnx ]]; then
- python training/export_onnx.py --out models/gofer-9x9-bootstrap.onnx
-fi
-cp -f models/gofer-9x9-bootstrap.onnx models/gofer-9x9-candidate.onnx
-cp -f models/gofer-9x9-bootstrap.onnx models/gofer-9x9-best.onnx
-
-# name|black|white|onnx_url|onnx_url_2|seed|notes
-SCENARIOS=(
- "heuristic-onnx|heuristic|onnx|http://127.0.0.1:${CHAMP_PORT}||42|bootstrap sanity"
- "uniform-heuristic|uniform|heuristic||||7|lopsided reject"
- "heuristic-uniform|heuristic|uniform||||11|lopsided reject alt"
- "identical-onnx|onnx|onnx2|http://127.0.0.1:${CHAMP_PORT}|http://127.0.0.1:${CHALLENGER_PORT}|99|equal net-vs-net"
- "heuristic-onnx-repeat|heuristic|onnx|http://127.0.0.1:${CHAMP_PORT}||123|bootstrap repeat seed"
-)
-
-SUMMARY="${REPORT_DIR}/summary.jsonl"
-: >"$SUMMARY"
-
-echo "arena bench: games=$ARENA_GAMES playouts=$ARENA_PLAYOUTS parallel=$PARALLEL"
-echo "cycle_id|game_count|win_rate|wilson_low|wilson_high|promoted|arena_sec|path|notes"
-
-for spec in "${SCENARIOS[@]}"; do
- IFS='|' read -r cid black white url1 url2 seed notes <<<"$spec"
- json="${REPORT_DIR}/arena-${cid}.json"
- stop_sidecars
-
- needs_sidecar=0
- [[ "$black" == onnx* || "$white" == onnx* ]] && needs_sidecar=1
- if [[ "$needs_sidecar" == "1" ]]; then
- start_sidecar models/gofer-9x9-best.onnx "$CHAMP_PORT"
- if [[ "$black" == onnx2 || "$white" == onnx2 ]]; then
- start_sidecar models/gofer-9x9-candidate.onnx "$CHALLENGER_PORT"
- fi
- fi
-
- arena_args=(
- -arena -games "$ARENA_GAMES" -size 9 -playouts "$ARENA_PLAYOUTS"
- -black-eval "$black" -white-eval "$white"
- -arena-parallel "$PARALLEL" -arena-opening-moves "$ARENA_OPENING_MOVES"
- -eval-timeout 2s -arena-enhanced none -seed "$seed" -json "$json"
- )
- [[ -n "$url1" ]] && arena_args+=(-onnx-url "$url1")
- [[ -n "$url2" ]] && arena_args+=(-onnx-url-2 "$url2")
-
- t0=$(date +%s.%N)
- "./$GOFER_BIN" "${arena_args[@]}" 2>"${REPORT_DIR}/${cid}.log" | tee -a "${REPORT_DIR}/${cid}.log"
- t1=$(date +%s.%N)
- arena_sec=$(python3 -c "print(round(float('$t1')-float('$t0'), 1))")
-
- python3 - "$json" "$cid" "$arena_sec" "$notes" "$SUMMARY" "$PROMOTE_WIN" <<'PY'
-import json, sys
-from pathlib import Path
-
-report_path, cid, arena_sec, notes, summary_path, promote_win = sys.argv[1:7]
-d = json.loads(Path(report_path).read_text())
-gc = int(d.get("game_count", 0))
-rate = float(d.get("win_rate_challenger", 0))
-lo = float(d.get("wilson_ci_low", 0))
-hi = float(d.get("wilson_ci_high", 0))
-go_promoted = bool(d.get("promoted", False))
-py_promote = rate >= float(promote_win) and lo > 0.5
-path = "lopsided" if gc < 200 else "close"
-row = {
- "cycle_id": cid,
- "game_count": gc,
- "win_rate_challenger": rate,
- "wilson_ci_low": lo,
- "wilson_ci_high": hi,
- "go_promoted": go_promoted,
- "py_promote": py_promote,
- "decision": "promote" if py_promote else "reject",
- "arena_sec": float(arena_sec),
- "path": path,
- "notes": notes,
-}
-print(
- f"{cid}|{gc}|{rate:.3f}|{lo:.3f}|{hi:.3f}|"
- f"{'promote' if py_promote else 'reject'}|{arena_sec}|{path}|{notes}"
-)
-with open(summary_path, "a", encoding="utf-8") as f:
- f.write(json.dumps(row) + "\n")
-if go_promoted != py_promote:
- print(f"WARN {cid}: go promoted={go_promoted} py promote={py_promote}", file=sys.stderr)
-PY
-done
-
-echo ""
-echo "Wrote $SUMMARY"
diff --git a/scripts/common.sh b/scripts/common.sh
deleted file mode 100644
index 78b436f..0000000
--- a/scripts/common.sh
+++ /dev/null
@@ -1,4 +0,0 @@
-#!/usr/bin/env bash
-# Shared shell setup for Gofer ops scripts.
-
-ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
diff --git a/scripts/fetch-warmstart-smoke-data.sh b/scripts/fetch-warmstart-smoke-data.sh
deleted file mode 100644
index 3f5e3a6..0000000
--- a/scripts/fetch-warmstart-smoke-data.sh
+++ /dev/null
@@ -1,82 +0,0 @@
-#!/usr/bin/env bash
-# Download a small SGF corpus for supervised warm-start smoke conversion.
-# Does not touch Lightsail or live training state.
-set -euo pipefail
-
-ROOT="$(cd "$(dirname "$0")/.." && pwd)"
-RAW="${ROOT}/training/experiments/warmstart/raw"
-SEVEN_Z="${ROOT}/.tectonix/artifacts/7zr.exe"
-mkdir -p "${RAW}/cgos" "${RAW}/minigo" "${RAW}/aeb" "${ROOT}/.tectonix/artifacts"
-
-log() { echo "[fetch-warmstart] $*"; }
-
-extract_7z() {
- local archive="$1"
- local outdir="$2"
- if command -v 7z >/dev/null 2>&1; then
- 7z x -y "-o${outdir}" "${archive}" >/dev/null
- elif command -v 7za >/dev/null 2>&1; then
- 7za x -y "-o${outdir}" "${archive}" >/dev/null
- elif [[ -x "${SEVEN_Z}" ]]; then
- "${SEVEN_Z}" x -y "-o${outdir}" "${archive}" >/dev/null
- else
- return 1
- fi
-}
-
-fetch_cgos() {
- local base="http://www.yss-aya.com/cgos/9x9/archives"
- # Eight recent months — enough for ~100k rows after filtering.
- local archives=(
- 9x9_2025_10.tar.bz2 9x9_2025_11.tar.bz2 9x9_2025_12.tar.bz2
- 9x9_2026_01.tar.bz2 9x9_2026_02.tar.bz2 9x9_2026_03.tar.bz2
- 9x9_2026_04.tar.bz2 9x9_2026_05.tar.bz2
- )
- for arc in "${archives[@]}"; do
- local dest="${RAW}/cgos/${arc}"
- if [[ -f "${dest}" ]]; then
- log "cgos skip ${arc}"
- continue
- fi
- log "cgos ${arc}"
- curl -fsSL "${base}/${arc}" -o "${dest}"
- tar -xjf "${dest}" -C "${RAW}/cgos"
- done
-}
-
-fetch_minigo() {
- local base="https://storage.googleapis.com/minigo-pub/v3-9x9/sgf"
- local archives=(
- 0-16.tar.gz
- 000017-choice-wolf.tar.gz
- 000025-on-foal.tar.gz
- )
- for arc in "${archives[@]}"; do
- local dest="${RAW}/minigo/${arc}"
- if [[ -f "${dest}" ]]; then
- log "minigo skip ${arc}"
- else
- log "minigo ${arc}"
- curl -fsSL "${base}/${arc}" -o "${dest}"
- fi
- tar -xzf "${dest}" -C "${RAW}/minigo" 2>/dev/null || true
- done
-}
-
-fetch_aeb() {
- local dest="${RAW}/aeb/games.7z"
- if [[ ! -f "${dest}" ]]; then
- log "aeb games.7z"
- curl -fsSL "https://homepages.cwi.nl/~aeb/go/games/games.7z" -o "${dest}"
- fi
- if [[ ! -d "${RAW}/aeb/games" ]]; then
- if ! extract_7z "${dest}" "${RAW}/aeb"; then
- log "aeb skip extract (no 7z/7zr found)"
- fi
- fi
-}
-
-fetch_cgos
-fetch_minigo
-fetch_aeb
-log "done: ${RAW}"
diff --git a/scripts/gating.env b/scripts/gating.env
deleted file mode 100644
index 7d842fd..0000000
--- a/scripts/gating.env
+++ /dev/null
@@ -1,17 +0,0 @@
-# Canonical gating constants for Gofer ML pipeline v3.
-# Keep in sync with cmd/gofer/gating.go (PromoteMin).
-# PROMOTE_WIN: head-to-head win rate the candidate must clear to replace the
-# champion (also requires Wilson CI low > 0.5). There is NO win-target stop.
-PROMOTE_WIN=0.55
-PROMOTE_MIN=0.55
-ARENA_GAMES=200
-# SELFPLAY_PLAYOUTS is the legacy name for the full-search cap (same as SELFPLAY_FULL_PLAYOUTS).
-# Self-play draws a per-move cap: fast (cheap) most moves, full (this value) on a random fraction.
-# Only full-search positions are exported when train-loop passes -full-only=true.
-SELFPLAY_PLAYOUTS=200
-SELFPLAY_FULL_PLAYOUTS=200
-SELFPLAY_FAST_PLAYOUTS=50
-SELFPLAY_CAP_RANDOMIZE_P=0.20
-ARENA_PLAYOUTS=400
-# hold = run arena but never install candidate (scoring investigation); normal = auto-promote
-GATING_MODE=normal
diff --git a/scripts/install-ort.sh b/scripts/install-ort.sh
new file mode 100755
index 0000000..8b11e37
--- /dev/null
+++ b/scripts/install-ort.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+# Download ONNX Runtime into third_party/onnxruntime (CPU by default).
+#
+# scripts/install-ort.sh # CPU build
+# scripts/install-ort.sh gpu # CUDA 12 build (needs an NVIDIA driver + CUDA 12 / cuDNN 9)
+#
+# The Go engine finds third_party/onnxruntime/lib automatically; elsewhere set
+# ONNXRUNTIME_SHARED_LIBRARY_PATH to the libonnxruntime.so it prints.
+set -euo pipefail
+
+VERSION="${ORT_VERSION:-1.26.0}"
+FLAVOR="${1:-cpu}"
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+DEST="$ROOT/third_party/onnxruntime"
+
+case "$(uname -s)-$(uname -m)" in
+ Linux-x86_64) PLATFORM=linux-x64 ;;
+ Linux-aarch64) PLATFORM=linux-aarch64 ;;
+ Darwin-arm64) PLATFORM=osx-arm64 ;;
+ *) echo "unsupported platform $(uname -s)-$(uname -m); install ONNX Runtime manually" >&2; exit 1 ;;
+esac
+NAME="onnxruntime-${PLATFORM}-${VERSION}"
+if [[ "$FLAVOR" == "gpu" ]]; then
+ [[ "$PLATFORM" == linux-x64 ]] || { echo "GPU builds are linux-x64 only" >&2; exit 1; }
+ NAME="onnxruntime-linux-x64-gpu-${VERSION}"
+fi
+
+URL="https://github.com/microsoft/onnxruntime/releases/download/v${VERSION}/${NAME}.tgz"
+TMP="$(mktemp -d)"
+trap 'rm -rf "$TMP"' EXIT
+echo "downloading $URL"
+curl -fsSL "$URL" -o "$TMP/ort.tgz"
+tar -xzf "$TMP/ort.tgz" -C "$TMP"
+rm -rf "$DEST"
+mkdir -p "$(dirname "$DEST")"
+mv "$TMP/$NAME" "$DEST"
+LIB=""
+for f in "$DEST/lib/libonnxruntime.so.$VERSION" "$DEST/lib/libonnxruntime.$VERSION.dylib"; do
+ if [[ -f "$f" ]]; then LIB="$f"; break; fi
+done
+echo "installed $LIB"
+echo "export ONNXRUNTIME_SHARED_LIBRARY_PATH=$LIB"
diff --git a/scripts/lightsail-inprocess-cycle.sh b/scripts/lightsail-inprocess-cycle.sh
deleted file mode 100644
index f1ac647..0000000
--- a/scripts/lightsail-inprocess-cycle.sh
+++ /dev/null
@@ -1,83 +0,0 @@
-#!/usr/bin/env bash
-# One training cycle on Lightsail with in-process ORT (no Python sidecar).
-# Run on the instance after pulling code that includes Step 3 wiring.
-#
-# From laptop (SSH + run remotely):
-# bash scripts/aws-run-arena.sh IP inprocess-cycle
-#
-# On the instance directly:
-# bash scripts/lightsail-inprocess-cycle.sh
-#
-# Env overrides (all optional):
-# SKIP_PARITY=1 skip scripts/parity-onnx.sh preflight
-# NEW_SELFPLAY_PER_CYCLE=50 ARENA_GAMES=50 PARALLEL=2
-set -euo pipefail
-
-# shellcheck disable=SC1091
-source "$(cd "$(dirname "$0")" && pwd)/common.sh"
-cd "$ROOT"
-
-export EVAL_BACKEND=inprocess
-export MAX_CYCLES=1
-export WEEK_DAYS="${WEEK_DAYS:-14}"
-export PATH="/usr/local/go/bin:${PATH:-}"
-export PYTHONPATH="${ROOT}:${PYTHONPATH:-}"
-
-# Production train-loop uses PARALLEL=2 on the Lightsail box; override via env if needed.
-export PARALLEL="${PARALLEL:-2}"
-export NEW_SELFPLAY_PER_CYCLE="${NEW_SELFPLAY_PER_CYCLE:-50}"
-export ARENA_GAMES="${ARENA_GAMES:-50}"
-
-RSS_LOG=".tectonix/reports/inprocess-rss.log"
-mkdir -p .tectonix/reports
-
-log() { echo "[$(date -Is)] $*" | tee -a "$RSS_LOG"; }
-
-sample_rss() {
- local pid
- pid="$(pgrep -n gofer 2>/dev/null || true)"
- if [[ -n "$pid" ]]; then
- ps -o pid=,rss=,vsz= -p "$pid" 2>/dev/null | awk -v t="$(date -Is)" '{printf "%s pid=%s rss_kb=%s vsz_kb=%s\n", t, $1, $2, $3}' >>"$RSS_LOG"
- fi
-}
-
-(
- while true; do
- sample_rss
- sleep 10
- done
-) &
-RSS_PID=$!
-trap 'kill "$RSS_PID" 2>/dev/null || true' EXIT
-
-log "=== in-process cycle start EVAL_BACKEND=$EVAL_BACKEND MAX_CYCLES=$MAX_CYCLES ==="
-log "go: $(go version 2>/dev/null || echo missing)"
-log "python: $(python3 --version 2>/dev/null || echo missing)"
-
-# Sidecar path needs Python ORT in .venv; in-process only needs PyTorch for training.
-if [[ -f .venv/bin/activate ]]; then
- # shellcheck disable=SC1091
- source .venv/bin/activate
-fi
-
-if [[ "${SKIP_PARITY:-0}" != "1" ]]; then
- log "parity preflight (500 positions)"
- if [[ -f .venv311/bin/activate ]]; then
- # ORT 1.26.0 requires Python >=3.11; system .venv on Lightsail is 3.10.
- # shellcheck disable=SC1091
- source .venv311/bin/activate
- fi
- GOFER_PARITY_LIMIT=500 bash scripts/parity-onnx.sh 2>&1 | tee -a "$RSS_LOG"
- if [[ -f .venv/bin/activate ]]; then
- # shellcheck disable=SC1091
- source .venv/bin/activate
- fi
-fi
-
-log "train-loop-v3 one cycle selfplay=$NEW_SELFPLAY_PER_CYCLE arena=$ARENA_GAMES parallel=$PARALLEL"
-bash scripts/train-loop-v3.sh 2>&1 | tee -a train-inprocess-cycle.log
-
-sample_rss
-log "=== done; rss log: $RSS_LOG train log: train-inprocess-cycle.log ==="
-log "arena summary:"
-grep -E 'PROMOTE|REJECT|win_rate_challenger|arena.*games|MAX_CYCLES' train-inprocess-cycle.log 2>/dev/null | tail -10 || true
diff --git a/scripts/parity-onnx.sh b/scripts/parity-onnx.sh
deleted file mode 100644
index adec408..0000000
--- a/scripts/parity-onnx.sh
+++ /dev/null
@@ -1,81 +0,0 @@
-#!/usr/bin/env bash
-# ONNX parity harness: Python inference_server.Session vs Go onnxruntime_go.
-# Requires onnxruntime==1.26.0 on Python side (matches onnxruntime_go v1.31.0).
-set -euo pipefail
-
-ROOT="$(cd "$(dirname "$0")/.." && pwd)"
-cd "$ROOT"
-
-LIMIT="${GOFER_PARITY_LIMIT:-500}"
-MODEL="${GOFER_PARITY_MODEL:-models/gofer-9x9-best.onnx}"
-SAMPLES="${GOFER_PARITY_SAMPLES:-training/data/samples.jsonl}"
-REF="${GOFER_PARITY_REF:-.tectonix/reports/parity-ref.jsonl}"
-ORT_VERSION="1.26.0"
-
-if [[ ! -f "$MODEL" ]]; then
- MODEL="models/gofer-9x9-bootstrap.onnx"
-fi
-if [[ ! -f "$MODEL" ]]; then
- echo "missing ONNX model; run: python training/export_onnx.py --out models/gofer-9x9-bootstrap.onnx"
- exit 1
-fi
-if [[ ! -f "$SAMPLES" ]]; then
- echo "missing samples file: $SAMPLES"
- exit 1
-fi
-
-echo "==> ORT version due diligence"
-PY_ORT="$(python -c 'import onnxruntime as ort; print(ort.__version__)' 2>/dev/null || true)"
-echo " python onnxruntime installed: ${PY_ORT:-not installed}"
-echo " target (pinned): onnxruntime==${ORT_VERSION}"
-echo " go wrapper: github.com/yalue/onnxruntime_go v1.31.0 (ORT C API ${ORT_VERSION})"
-
-if [[ "${PY_ORT:-}" != "${ORT_VERSION}" ]]; then
- echo "==> installing onnxruntime==${ORT_VERSION} for parity run"
- pip install -q "onnxruntime==${ORT_VERSION}"
-fi
-
-echo "==> resolve Go ORT shared library"
-GOMODCACHE="$(go env GOMODCACHE)"
-export ONNXRUNTIME_GO_MODROOT="${GOMODCACHE}/github.com/yalue/onnxruntime_go@v1.31.0"
-go get github.com/yalue/onnxruntime_go@v1.31.0
-
-if [[ -z "${ONNXRUNTIME_SHARED_LIBRARY_PATH:-}" ]]; then
- case "$(uname -s)-$(uname -m)" in
- Linux-x86_64|Linux-amd64)
- ART="${ROOT}/.tectonix/artifacts/onnxruntime-linux-x64-${ORT_VERSION}"
- SO="${ART}/lib/libonnxruntime.so.${ORT_VERSION}"
- if [[ ! -f "$SO" ]]; then
- echo "==> downloading ORT ${ORT_VERSION} linux/amd64 (no bundled .so in onnxruntime_go)"
- mkdir -p "${ROOT}/.tectonix/artifacts"
- TGZ="${ROOT}/.tectonix/artifacts/onnxruntime-linux-x64-${ORT_VERSION}.tgz"
- URL="https://github.com/microsoft/onnxruntime/releases/download/v${ORT_VERSION}/onnxruntime-linux-x64-${ORT_VERSION}.tgz"
- curl -fsSL "$URL" -o "$TGZ"
- rm -rf "$ART"
- tar -xzf "$TGZ" -C "${ROOT}/.tectonix/artifacts"
- fi
- export ONNXRUNTIME_SHARED_LIBRARY_PATH="$SO"
- ;;
- MINGW*-x86_64|MSYS*-x86_64|CYGWIN*-x86_64)
- DLL="${ONNXRUNTIME_GO_MODROOT}/test_data/onnxruntime.dll"
- if [[ ! -f "$DLL" ]]; then
- echo "missing bundled Windows DLL at $DLL"
- exit 1
- fi
- export ONNXRUNTIME_SHARED_LIBRARY_PATH="$DLL"
- ;;
- *)
- echo "set ONNXRUNTIME_SHARED_LIBRARY_PATH to libonnxruntime for $(uname -s)-$(uname -m)"
- exit 1
- ;;
- esac
-fi
-echo " ONNXRUNTIME_SHARED_LIBRARY_PATH=${ONNXRUNTIME_SHARED_LIBRARY_PATH}"
-
-echo "==> python reference path (inference_server.Session)"
-python training/parity_harness.py --model "$MODEL" --samples "$SAMPLES" --limit "$LIMIT" --out "$REF"
-
-echo "==> go ORT path (onnx build tag)"
-CGO_ENABLED=1 go test -tags=onnx ./cmd/gofer/ -run TestONNXParityPythonRef -count=1 -v
-
-echo "==> done — parity passed on ${LIMIT} positions"
diff --git a/scripts/plot-gating.sh b/scripts/plot-gating.sh
deleted file mode 100644
index c5f56b5..0000000
--- a/scripts/plot-gating.sh
+++ /dev/null
@@ -1,48 +0,0 @@
-#!/usr/bin/env bash
-# Plot cumulative baseline wins from a gofer arena JSON report.
-# Usage: ./scripts/plot-gating.sh [.tectonix/reports/arena-9x9-baseline.json] [out.png]
-set -euo pipefail
-
-# shellcheck disable=SC1091
-source "$(cd "$(dirname "$0")" && pwd)/common.sh"
-# shellcheck disable=SC1091
-source "$ROOT/scripts/gating.env"
-
-JSON="${1:-.tectonix/reports/arena-9x9-baseline.json}"
-OUT="${2:-.tectonix/reports/gating-cumulative.png}"
-CSV="${OUT%.png}.csv"
-
-if [[ ! -f "$JSON" ]]; then
- echo "missing arena JSON: $JSON" >&2
- exit 1
-fi
-
-python3 - "$JSON" "$CSV" <<'PY'
-import json, sys
-path, csv_path = sys.argv[1], sys.argv[2]
-with open(path, encoding="utf-8") as f:
- data = json.load(f)
-games = data.get("games") or []
-baseline = data.get("baseline_eval", "")
-cum = 0
-rows = ["game,cumulative_baseline_wins,win_rate"]
-for g in games:
- won = False
- if g.get("black_eval") == baseline and g.get("black_wins"):
- won = True
- if g.get("white_eval") == baseline and g.get("white_wins"):
- won = True
- if won:
- cum += 1
- n = g.get("game", len(rows))
- rows.append(f"{n},{cum},{cum/n:.6f}")
-with open(csv_path, "w", encoding="utf-8") as f:
- f.write("\n".join(rows) + "\n")
-PY
-
-if command -v gnuplot >/dev/null 2>&1; then
- gnuplot -e "set terminal png size 800,480; set output '$OUT'; set xlabel 'game'; set ylabel 'cumulative baseline wins'; set title 'Arena gating cumulative'; plot '$CSV' using 1:2 with lines lw 2"
- echo "wrote $OUT"
-else
- echo "gnuplot not found; wrote CSV only: $CSV"
-fi
diff --git a/scripts/remote-arena-gate.sh b/scripts/remote-arena-gate.sh
deleted file mode 100644
index 02d3811..0000000
--- a/scripts/remote-arena-gate.sh
+++ /dev/null
@@ -1,90 +0,0 @@
-#!/usr/bin/env bash
-# Run on a fresh Ubuntu Lightsail box after cloning Gofer.
-# Usage: bash scripts/remote-arena-gate.sh
-set -euo pipefail
-
-# shellcheck disable=SC1091
-source "$(cd "$(dirname "$0")" && pwd)/common.sh"
-cd "$ROOT"
-# shellcheck disable=SC1091
-source "$ROOT/scripts/gating.env"
-
-WIN_TARGET="${WIN_TARGET:-0.75}"
-ENFORCE_GATE="${ENFORCE_GATE:-0}"
-
-echo "==> install deps (Go 1.22+, Python venv)"
-export DEBIAN_FRONTEND=noninteractive
-need_go() {
- if ! command -v go >/dev/null; then return 0; fi
- ver="$(go env GOVERSION 2>/dev/null | sed 's/go//')"
- major="${ver%%.*}"; minor="$(echo "$ver" | cut -d. -f2)"
- [[ "$major" -lt 1 || ( "$major" -eq 1 && "$minor" -lt 22 ) ]]
-}
-if need_go; then
- GO_TAR=go1.22.12.linux-amd64.tar.gz
- curl -fsSL "https://go.dev/dl/${GO_TAR}" -o "/tmp/${GO_TAR}"
- sudo rm -rf /usr/local/go
- sudo tar -C /usr/local -xzf "/tmp/${GO_TAR}"
- export PATH="/usr/local/go/bin:$PATH"
-fi
-if ! command -v python3 >/dev/null; then
- sudo apt-get update -qq
- sudo apt-get install -y python3 python3-pip python3-venv git curl
-elif ! python3 -m venv --help >/dev/null 2>&1; then
- sudo apt-get update -qq
- sudo apt-get install -y python3-venv python3-pip git curl
-fi
-
-python3 -m venv .venv
-# shellcheck disable=SC1091
-source .venv/bin/activate
-pip install -q -r training/requirements.txt
-
-echo "==> tests (short)"
-go test ./... -short -count=1
-
-echo "==> export ONNX if missing"
-test -f models/gofer-9x9-bootstrap.onnx || python training/export_onnx.py --out models/gofer-9x9-bootstrap.onnx
-
-echo "==> optional retrain (skip if SELFPLAY_GAMES=0)"
-SELFPLAY_GAMES="${SELFPLAY_GAMES:-100}"
-if [[ "$SELFPLAY_GAMES" -gt 0 ]]; then
- mkdir -p training/data
- go run ./cmd/gofer -selfplay -games "$SELFPLAY_GAMES" -size 9 -playouts 100 \
- -eval heuristic -o training/data/samples.jsonl -seed 42
- python training/train_bootstrap.py --data training/data/samples.jsonl --epochs 25 \
- --out-dir training/checkpoints
- if [[ ! -f training/checkpoints/best.pt ]]; then
- echo "training failed: no checkpoint at training/checkpoints/best.pt"
- exit 1
- fi
- python training/export_onnx.py --checkpoint training/checkpoints/best.pt \
- --out models/gofer-9x9-bootstrap.onnx
-fi
-
-echo "==> start sidecar"
-pkill -f 'inference_server.py' 2>/dev/null || true
-python training/inference_server.py --model models/gofer-9x9-bootstrap.onnx --port 8080 &
-SIDECAR_PID=$!
-sleep 2
-curl -sf http://127.0.0.1:8080/health >/dev/null || { echo "sidecar failed"; exit 1; }
-
-echo "==> 200-game arena (heuristic vs onnx, equal 400 playouts)"
-go run ./cmd/gofer -arena -games 200 -size 9 -playouts 400 \
- -black-eval heuristic -white-eval onnx \
- -eval-timeout 2s -seed 42 -arena-enhanced none \
- -json .tectonix/reports/arena-9x9-onnx-v25.json | tee .tectonix/reports/arena-9x9-onnx-v25.log
-
-kill "$SIDECAR_PID" 2>/dev/null || true
-echo "==> done. Report: .tectonix/reports/arena-9x9-onnx-v25.json"
-grep -E 'win_rate_challenger|wilson_ci' .tectonix/reports/arena-9x9-onnx-v25.json | head -5 || true
-
-if [[ "$ENFORCE_GATE" == "1" ]]; then
- rate="$(python3 -c "import json; d=json.load(open('.tectonix/reports/arena-9x9-onnx-v25.json')); print(d.get('win_rate_challenger',0))")"
- if python3 -c "import sys; sys.exit(0 if float('$rate') >= float('$WIN_TARGET') else 1)"; then
- echo "gate passed rate=$rate target=$WIN_TARGET"
- else
- echo "gate failed rate=$rate target=$WIN_TARGET"
- exit 1
- fi
-fi
diff --git a/scripts/replay-cycle-arena.sh b/scripts/replay-cycle-arena.sh
deleted file mode 100644
index 59ed8c0..0000000
--- a/scripts/replay-cycle-arena.sh
+++ /dev/null
@@ -1,48 +0,0 @@
-#!/usr/bin/env bash
-# Replay arena with unified komi (validation). Usage: bash scripts/replay-cycle-arena.sh [games] [seed]
-set -euo pipefail
-ROOT="$(cd "$(dirname "$0")/.." && pwd)"
-cd "$ROOT"
-export PATH="/usr/local/go/bin:${PATH:-}"
-
-GAMES="${1:-50}"
-SEED="${2:-66}"
-OUT="/tmp/arena-replay.json"
-
-pkill -f inference_server.py 2>/dev/null || true
-sleep 1
-if [[ -f .venv311/bin/activate ]]; then
- # shellcheck disable=SC1091
- source .venv311/bin/activate
-elif [[ -f .venv/bin/activate ]]; then
- # shellcheck disable=SC1091
- source .venv/bin/activate
-fi
-
-python training/inference_server.py --model models/gofer-9x9-best.onnx --port 8080 &
-python training/inference_server.py --model models/gofer-9x9-candidate.onnx --port 8081 &
-for _ in $(seq 1 40); do
- curl -sf "http://127.0.0.1:8080/health" >/dev/null && curl -sf "http://127.0.0.1:8081/health" >/dev/null && break
- sleep 1
-done
-
-go build -o bin/gofer ./cmd/gofer
-./bin/gofer -eval-backend sidecar -arena -games "$GAMES" -size 9 -komi 6.5 -playouts 200 \
- -black-eval onnx -white-eval onnx2 \
- -onnx-url "http://127.0.0.1:8080" -onnx-url-2 "http://127.0.0.1:8081" \
- -arena-parallel 2 -arena-opening-moves 8 -seed "$SEED" -arena-enhanced none \
- -json "$OUT"
-
-pkill -f inference_server.py 2>/dev/null || true
-
-python3 - "$OUT" <<'PY'
-import json, sys
-new = json.load(open(sys.argv[1]))
-old_path = ".tectonix/reports/arena-cycle-24.json"
-try:
- old = json.load(open(old_path))
- print(f"original_c24 stone b={old['wins_black']} w={old['wins_white']} wr_ch={old['win_rate_challenger']:.3f} n={old.get('game_count')}")
-except FileNotFoundError:
- print("no arena-cycle-24.json")
-print(f"replay_k65 stone b={new['wins_black']} w={new['wins_white']} wr_ch={new['win_rate_challenger']:.3f} n={new.get('game_count')}")
-PY
diff --git a/scripts/run-warmstart-smoke.sh b/scripts/run-warmstart-smoke.sh
deleted file mode 100644
index 5c0f56e..0000000
--- a/scripts/run-warmstart-smoke.sh
+++ /dev/null
@@ -1,54 +0,0 @@
-#!/usr/bin/env bash
-# Smoke pipeline: fetch SGF → convert → validate → short pretrain.
-# Does not touch training/state/best.pt or Lightsail.
-set -euo pipefail
-
-ROOT="$(cd "$(dirname "$0")/.." && pwd)"
-cd "$ROOT"
-
-DATA_DIR="training/experiments/warmstart/data"
-RAW_DIR="training/experiments/warmstart/raw"
-OUT="${DATA_DIR}/supervised-v1-smoke.jsonl"
-CKPT_DIR="training/experiments/warmstart/checkpoints/smoke"
-GOFER="bin/gofer"
-MAX_ROWS="${MAX_ROWS:-100000}"
-PRETRAIN_EPOCHS="${PRETRAIN_EPOCHS:-3}"
-
-mkdir -p "$DATA_DIR" "$CKPT_DIR" bin
-
-log() { echo "[warmstart-smoke] $*"; }
-
-if [[ ! -x "$GOFER" ]]; then
- log "building gofer"
- go build -o "$GOFER" ./cmd/gofer
-fi
-
-bash scripts/fetch-warmstart-smoke-data.sh
-
-log "converting SGF (max ${MAX_ROWS} rows, equal per-source cap)"
-AEB_9X9="${RAW_DIR}/aeb/games/other_sizes/9x9"
-if [[ ! -d "$AEB_9X9" ]]; then
- log "aeb 9x9 dir missing — run fetch-warmstart-smoke-data.sh first"
- exit 1
-fi
-"$GOFER" -convert-sgf -size 9 -convert-epsilon 0.10 -convert-max-rows "$MAX_ROWS" \
- -o "$OUT" \
- "$RAW_DIR/cgos" "$RAW_DIR/minigo" "$AEB_9X9"
-
-log "validating JSONL"
-python training/experiments/warmstart/validate_smoke.py "$OUT"
-"$GOFER" -verify-jsonl -o "$OUT"
-
-log "short pretrain (${PRETRAIN_EPOCHS} epochs)"
-if [[ -f .venv/bin/activate ]]; then
- # shellcheck disable=SC1091
- source .venv/bin/activate
-fi
-python training/train_bootstrap.py \
- --data "$OUT" \
- --epochs "$PRETRAIN_EPOCHS" \
- --lr 0.01 \
- --out-dir "$CKPT_DIR" \
- --val-split 0.1
-
-log "smoke complete: $OUT"
diff --git a/scripts/scope-gpu-server.sh b/scripts/scope-gpu-server.sh
deleted file mode 100644
index 16255f1..0000000
--- a/scripts/scope-gpu-server.sh
+++ /dev/null
@@ -1,233 +0,0 @@
-#!/usr/bin/env bash
-# Collect server facts for Gofer GPU training setup.
-# Safe to paste into chat — no secrets, keys, or env dump.
-#
-# Usage (on GPU box):
-# bash scripts/scope-gpu-server.sh
-# bash scripts/scope-gpu-server.sh | tee ~/gpu-scope-report.txt
-#
-# Or copy this file alone and run before cloning the repo.
-set -uo pipefail
-
-_script_path="${BASH_SOURCE[0]:-${0:-}}"
-if [[ -z "$_script_path" || "$_script_path" == bash || "$_script_path" == -bash ]]; then
- _script_dir="$(pwd)"
-else
- _script_dir="$(cd "$(dirname "$_script_path")" && pwd)"
-fi
-unset _script_path
-ROOT="$(cd "${_script_dir}/.." 2>/dev/null && pwd || pwd)"
-unset _script_dir
-if [[ -d "$ROOT/.git" && -f "$ROOT/scripts/train-loop-v3.sh" ]]; then
- REPORT_DIR="$ROOT/.tectonix/reports"
-else
- REPORT_DIR="${HOME}"
-fi
-mkdir -p "$REPORT_DIR"
-REPORT="${GOFER_SCOPE_REPORT:-${REPORT_DIR}/gpu-scope-report.txt}"
-
-run_cmd() {
- local label="$1"
- shift
- echo "--- ${label} ---"
- if "$@" 2>&1; then
- :
- else
- echo "(exit $?)"
- fi
-}
-
-kv() {
- printf '%s=%s\n' "$1" "$2"
-}
-
-scope_body() {
- echo "=== GOFER GPU SCOPE REPORT ==="
- echo "generated_at=$(date -Is 2>/dev/null || date)"
- echo "hostname=$(hostname 2>/dev/null || echo unknown)"
- echo "report_path=${REPORT}"
- echo ""
-
- echo "=== USER ==="
- run_cmd "whoami" whoami
- run_cmd "id" id
- run_cmd "groups" groups
- run_cmd "umask" umask
- run_cmd "home disk" df -h "${HOME}" 2>/dev/null || df -h .
-
- echo ""
- echo "=== OS ==="
- if [[ -f /etc/os-release ]]; then
- run_cmd "os-release" cat /etc/os-release
- else
- echo "no /etc/os-release"
- fi
- run_cmd "uname" uname -a
-
- echo ""
- echo "=== CPU / MEMORY / DISK ==="
- run_cmd "nproc" nproc
- run_cmd "lscpu" bash -c 'lscpu 2>/dev/null | egrep "Model name|CPU\\(s\\)|Thread|Core|Socket" || true'
- run_cmd "free -h" free -h
- run_cmd "swapon" swapon --show 2>/dev/null || echo "no swap"
- run_cmd "disk" df -h / /tmp "${HOME}" 2>/dev/null || df -h
-
- echo ""
- echo "=== GPU HARDWARE ==="
- run_cmd "lspci" bash -c 'lspci 2>/dev/null | egrep -i "vga|3d|display|accelerator" || true'
- run_cmd "rocm-smi" bash -c 'rocm-smi 2>/dev/null || echo "rocm-smi not found"'
- run_cmd "nvidia-smi" bash -c 'nvidia-smi 2>/dev/null || echo "nvidia-smi not found"'
-
- echo ""
- echo "=== ROCM / CUDA TOOLING ==="
- run_cmd "rocminfo" bash -c 'rocminfo 2>/dev/null | head -60 || echo "rocminfo not found"'
- run_cmd "hipcc" bash -c 'command -v hipcc >/dev/null && hipcc --version || echo "hipcc not found"'
- run_cmd "nvcc" bash -c 'command -v nvcc >/dev/null && nvcc --version || echo "nvcc not found"'
- if [[ -d /opt/rocm ]]; then
- run_cmd "rocm version file" bash -c 'cat /opt/rocm/.info/version 2>/dev/null || ls /opt/rocm | head -10'
- fi
-
- echo ""
- echo "=== PYTHON ==="
- run_cmd "python3" bash -c 'python3 --version 2>/dev/null || echo "python3 missing"'
- for py in python3.12 python3.11 python3.10; do
- if command -v "$py" >/dev/null 2>&1; then
- echo "--- ${py} ---"
- "$py" --version 2>&1 || true
- fi
- done
- run_cmd "pip3" bash -c 'pip3 --version 2>/dev/null || echo "pip3 missing"'
- run_cmd "venv" bash -c 'python3 -m venv --help >/dev/null 2>&1 && echo "venv ok" || echo "venv missing"'
-
- echo ""
- echo "=== PYTORCH PROBE ==="
- python3 - <<'PY' 2>&1 || true
-import sys
-print("python_exe", sys.executable)
-try:
- import torch
- print("torch_version", torch.__version__)
- print("torch_cuda_available", torch.cuda.is_available())
- if torch.cuda.is_available():
- print("torch_cuda_device_count", torch.cuda.device_count())
- for i in range(torch.cuda.device_count()):
- print(f"torch_cuda_device_{i}", torch.cuda.get_device_name(i))
-except Exception as exc:
- print("torch_probe", type(exc).__name__, exc)
-PY
-
- echo ""
- echo "=== GO / BUILD TOOLS ==="
- run_cmd "go" bash -c 'go version 2>/dev/null || echo "go not installed"'
- run_cmd "git" bash -c 'git --version 2>/dev/null || echo "git missing"'
- run_cmd "curl" bash -c 'curl --version 2>/dev/null | head -1 || echo "curl missing"'
- run_cmd "gcc" bash -c 'gcc --version 2>/dev/null | head -1 || echo "gcc missing"'
- run_cmd "tmux" bash -c 'tmux -V 2>/dev/null || echo "tmux missing"'
-
- echo ""
- echo "=== NETWORK / EXPOSURE ==="
- run_cmd "listening tcp" bash -c 'ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null || echo "cannot list listeners"'
- run_cmd "ufw" bash -c 'command -v ufw >/dev/null && ufw status 2>/dev/null || echo "ufw n/a"'
- echo "note: do not paste provider login URLs with embedded tokens"
-
- echo ""
- echo "=== CONTAINER / VM HINTS ==="
- run_cmd "virt" bash -c 'systemd-detect-virt 2>/dev/null || echo "unknown"'
- [[ -f /.dockerenv ]] && echo "dockerenv=yes" || echo "dockerenv=no"
-
- echo ""
- echo "=== EXISTING GOFER INSTALL ==="
- found=""
- for d in "${ROOT}" "${HOME}/GoEngine" "${HOME}/Gofer"; do
- if [[ -f "${d}/scripts/train-loop-v3.sh" ]]; then
- found="$d"
- echo "found_repo=${d}"
- [[ -f "${d}/bin/gofer" ]] && echo "bin_gofer=yes" || echo "bin_gofer=no"
- [[ -f "${d}/models/gofer-9x9-best.onnx" ]] && echo "best_onnx=yes" || echo "best_onnx=no"
- [[ -f "${d}/training/state/best.pt" ]] && echo "best_pt=yes" || echo "best_pt=no"
- break
- fi
- done
- [[ -z "$found" ]] && echo "found_repo=none"
-
- echo ""
- echo "=== PARSEABLE SUMMARY ==="
- distro_id="unknown"
- distro_ver="unknown"
- if [[ -f /etc/os-release ]]; then
- # shellcheck disable=SC1091
- source /etc/os-release
- distro_id="${ID:-unknown}"
- distro_ver="${VERSION_ID:-unknown}"
- fi
-
- gpu_backend="none"
- gpu_count=0
- gpu_model="unknown"
- if command -v rocm-smi >/dev/null 2>&1 && rocm-smi >/dev/null 2>&1; then
- gpu_backend="rocm"
- gpu_count="$(rocm-smi 2>/dev/null | awk '/^[0-9]+\s+[0-9]+\s+0x/{c++} END{print c+0}')"
- [[ "$gpu_count" == 0 ]] && gpu_count=1
- gpu_model="$(rocminfo 2>/dev/null | awk -F': ' '/Marketing Name/ && /AMD|Instinct|MI/{print $2; exit}')"
- [[ -z "$gpu_model" ]] && gpu_model="$(lspci 2>/dev/null | grep -i 'Processing accelerators' | sed 's/.*: //' | head -1)"
- [[ -z "$gpu_model" ]] && gpu_model="amd"
- elif command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi >/dev/null 2>&1; then
- gpu_backend="cuda"
- gpu_count="$(nvidia-smi -L 2>/dev/null | wc -l | tr -d ' ')"
- gpu_model="$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -1 || echo nvidia)"
- fi
-
- rocm_ver="none"
- if [[ -f /opt/rocm/.info/version ]]; then
- rocm_ver="$(tr -d '\n' /dev/null 2>&1 ]]; then
- rocm_ver="$(hipcc --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1 || echo unknown)"
- fi
-
- python_best="none"
- for py in python3.12 python3.11 python3.10 python3; do
- if command -v "$py" >/dev/null 2>&1; then
- python_best="$py"
- break
- fi
- done
-
- public_listeners="unknown"
- if ss -tln 2>/dev/null | grep -qE '0\.0\.0\.0|\[::\]:'; then
- public_listeners="yes"
- elif ss -tln 2>/dev/null; then
- public_listeners="no"
- fi
-
- kv distro "${distro_id}-${distro_ver}"
- kv nproc "$(nproc 2>/dev/null || echo 1)"
- kv ram_gb "$(free -g 2>/dev/null | awk '/^Mem:/{print $2}' || echo 0)"
- kv disk_free_gb_home "$(df -BG "${HOME}" 2>/dev/null | awk 'NR==2{gsub(/G/,"",$4); print $4}' || echo 0)"
- kv gpu_backend "$gpu_backend"
- kv gpu_count "$gpu_count"
- kv gpu_model "$gpu_model"
- kv rocm_version "$rocm_ver"
- kv python_best "$python_best"
- if command -v go >/dev/null 2>&1; then
- kv go_installed yes
- kv go_version "$(go version 2>/dev/null | awk '{print $3}')"
- else
- kv go_installed no
- kv go_version none
- fi
- kv public_listeners "$public_listeners"
- kv inprocess_recommended yes
- kv found_repo "${found:-none}"
-}
-
-scope_body | tee "$REPORT"
-echo ""
-echo "Wrote report: ${REPORT}"
-echo "Share that file (not passwords). Then run: cd ~/GoEngine && bash scripts/setup-gpu-server.sh"
diff --git a/scripts/setup-gpu-server.sh b/scripts/setup-gpu-server.sh
deleted file mode 100644
index a338ef6..0000000
--- a/scripts/setup-gpu-server.sh
+++ /dev/null
@@ -1,353 +0,0 @@
-#!/usr/bin/env bash
-# Install Gofer training stack on a GPU server.
-# Default install directory: ~/GoEngine (override: GOENGINE_ROOT or GOFER_ROOT).
-#
-# Prereq: run scope first (optional):
-# bash scripts/scope-gpu-server.sh
-#
-# Usage:
-# cd ~/GoEngine && bash scripts/setup-gpu-server.sh
-# GOENGINE_ROOT=~/GoEngine bash scripts/setup-gpu-server.sh
-# SKIP_SMOKE=1 bash scripts/setup-gpu-server.sh
-#
-# Security defaults:
-# EVAL_BACKEND=inprocess (no HTTP sidecar)
-# does not install or copy AWS/SSH private keys
-set -euo pipefail
-
-# Safe when piped: curl ... | bash (BASH_SOURCE unset under set -u)
-_script_path="${BASH_SOURCE[0]:-${0:-}}"
-if [[ -z "$_script_path" || "$_script_path" == bash || "$_script_path" == -bash ]]; then
- SCRIPT_DIR="$(pwd)"
-else
- SCRIPT_DIR="$(cd "$(dirname "$_script_path")" && pwd)"
-fi
-unset _script_path
-ROOT="$(cd "${SCRIPT_DIR}/.." 2>/dev/null && pwd || echo "${SCRIPT_DIR}")"
-
-GOENGINE_ROOT="${GOENGINE_ROOT:-${GOFER_ROOT:-${HOME}/GoEngine}}"
-GOFER_ROOT="${GOENGINE_ROOT}"
-GOFER_REPO_URL="${GOFER_REPO_URL:-https://github.com/DevomB/Gofer.git}"
-GOFER_BRANCH="${GOFER_BRANCH:-main}"
-VENV_DIR="${GOFER_VENV:-${GOFER_ROOT}/.venv311}"
-SCOPE_CANDIDATES=(
- "${GOFER_SCOPE_REPORT:-}"
- "${HOME}/GoEngine/.tectonix/reports/gpu-scope-report.txt"
- "${GOFER_ROOT}/.tectonix/reports/gpu-scope-report.txt"
- "${HOME}/gofer-gpu-scope-report.txt"
- "${HOME}/gpu-scope-report.txt"
- "${ROOT}/.tectonix/reports/gpu-scope-report.txt"
-)
-ORT_VERSION="1.26.0"
-GO_VERSION="1.22.5"
-
-log() { echo "[setup-gpu] $*"; }
-
-read_scope() {
- local key="$1" default="${2:-}"
- local file val
- for file in "${SCOPE_CANDIDATES[@]}"; do
- [[ -n "$file" && -f "$file" ]] || continue
- val="$(grep -E "^${key}=" "$file" | tail -1 | cut -d= -f2- || true)"
- if [[ -n "$val" ]]; then
- echo "$val"
- return 0
- fi
- done
- echo "$default"
-}
-
-detect_gpu_backend() {
- if command -v rocm-smi >/dev/null 2>&1 && rocm-smi >/dev/null 2>&1; then
- echo rocm
- elif command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi >/dev/null 2>&1; then
- echo cuda
- else
- echo cpu
- fi
-}
-
-pick_python() {
- local from_scope="$1"
- if [[ -n "$from_scope" && "$from_scope" != none ]] && command -v "$from_scope" >/dev/null 2>&1; then
- echo "$from_scope"
- return
- fi
- # Prefer 3.11 for ORT parity; fall back to newest available.
- for py in python3.11 python3.12 python3.10 python3; do
- if command -v "$py" >/dev/null 2>&1; then
- echo "$py"
- return
- fi
- done
- echo ""
-}
-
-rocm_pip_index() {
- local ver="${1:-none}"
- case "$ver" in
- 7.*|7) echo "https://download.pytorch.org/whl/rocm6.3" ;;
- 6.3*|6.3) echo "https://download.pytorch.org/whl/rocm6.3" ;;
- 6.2*|6.2) echo "https://download.pytorch.org/whl/rocm6.2" ;;
- 6.1*|6.1) echo "https://download.pytorch.org/whl/rocm6.1" ;;
- 6.0*|6.0) echo "https://download.pytorch.org/whl/rocm6.0" ;;
- 5.7*|5.7) echo "https://download.pytorch.org/whl/rocm5.7" ;;
- *) echo "https://download.pytorch.org/whl/rocm6.3" ;;
- esac
-}
-
-detect_gofer_root() {
- local from_scope="$1"
- # Explicit override (GOENGINE_ROOT / GOFER_ROOT).
- if [[ -n "${GOENGINE_ROOT:-}" && -f "${GOENGINE_ROOT}/scripts/train-loop-v3.sh" ]]; then
- echo "$GOENGINE_ROOT"
- return
- fi
- # Running from checkout: .../GoEngine/scripts/setup-gpu-server.sh
- if [[ -f "${SCRIPT_DIR}/train-loop-v3.sh" ]]; then
- echo "$SCRIPT_DIR"
- return
- fi
- if [[ -f "${SCRIPT_DIR}/../scripts/train-loop-v3.sh" ]]; then
- echo "$(cd "${SCRIPT_DIR}/.." && pwd)"
- return
- fi
- if [[ -n "$from_scope" && "$from_scope" != none && -f "${from_scope}/scripts/train-loop-v3.sh" ]]; then
- echo "$from_scope"
- return
- fi
- for d in "${HOME}/GoEngine" "${HOME}/Gofer"; do
- if [[ -f "${d}/scripts/train-loop-v3.sh" ]]; then
- echo "$d"
- return
- fi
- done
- echo "${HOME}/GoEngine"
-}
-
-try_existing_gpu_venv() {
- local candidate="${GOFER_USE_VENV:-}"
- if [[ -z "$candidate" && -f /opt/venv/bin/activate ]]; then
- candidate="/opt/venv"
- fi
- [[ -n "$candidate" && -f "${candidate}/bin/activate" ]] || return 1
- if "${candidate}/bin/python" -c "import torch; assert torch.cuda.is_available()" 2>/dev/null; then
- VENV_DIR="$candidate"
- log "reusing existing GPU venv: ${VENV_DIR} ($("${candidate}/bin/python" -c 'import torch; print(torch.__version__)'))"
- # shellcheck disable=SC1091
- source "${VENV_DIR}/bin/activate"
- pip install -r "${GOFER_ROOT}/training/requirements.txt"
- pip install pytest
- python - <<'PY'
-import torch
-print("torch", torch.__version__)
-print("cuda_available", torch.cuda.is_available())
-print("device0", torch.cuda.get_device_name(0))
-PY
- return 0
- fi
- return 1
-}
-
-install_apt_basics() {
- if ! command -v apt-get >/dev/null 2>&1; then
- log "no apt-get — install git curl python3 venv gcc tmux manually if missing"
- return 0
- fi
- if command -v sudo >/dev/null 2>&1; then
- sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq
- sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \
- git curl build-essential tmux ca-certificates \
- python3 python3-venv python3-pip \
- python3.11 python3.11-venv 2>/dev/null || \
- sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \
- git curl build-essential tmux ca-certificates python3 python3-venv python3-pip
- else
- log "no sudo — assuming provider preinstalled build deps"
- fi
-}
-
-install_go() {
- if command -v go >/dev/null 2>&1; then
- log "go present: $(go version)"
- return 0
- fi
- local tgz="go${GO_VERSION}.linux-amd64.tar.gz"
- log "installing go ${GO_VERSION} to /usr/local/go"
- curl -fsSL "https://go.dev/dl/${tgz}" -o "/tmp/${tgz}"
- if command -v sudo >/dev/null 2>&1; then
- sudo rm -rf /usr/local/go
- sudo tar -C /usr/local -xzf "/tmp/${tgz}"
- else
- rm -rf "${HOME}/.local/go"
- tar -C "${HOME}/.local" -xzf "/tmp/${tgz}"
- export PATH="${HOME}/.local/go/bin:${PATH}"
- grep -q '.local/go/bin' "${HOME}/.bashrc" 2>/dev/null || \
- echo 'export PATH=$HOME/.local/go/bin:$PATH' >>"${HOME}/.bashrc"
- fi
- export PATH="/usr/local/go/bin:${PATH}"
- go version
-}
-
-clone_or_update_repo() {
- if [[ -d "${GOFER_ROOT}/.git" ]]; then
- log "updating ${GOFER_ROOT}"
- git -C "${GOFER_ROOT}" fetch origin
- git -C "${GOFER_ROOT}" checkout "$GOFER_BRANCH"
- git -C "${GOFER_ROOT}" pull --ff-only origin "$GOFER_BRANCH" || true
- else
- log "cloning ${GOFER_REPO_URL} -> ${GOFER_ROOT}"
- git clone --branch "$GOFER_BRANCH" --depth 1 "$GOFER_REPO_URL" "$GOFER_ROOT"
- fi
-}
-
-ensure_ort_lib() {
- local art="${GOFER_ROOT}/.tectonix/artifacts/onnxruntime-linux-x64-${ORT_VERSION}"
- local so="${art}/lib/libonnxruntime.so.${ORT_VERSION}"
- if [[ -f "$so" ]]; then
- export ONNXRUNTIME_SHARED_LIBRARY_PATH="$so"
- return 0
- fi
- log "fetching ONNX Runtime ${ORT_VERSION} (in-process CPU inference)"
- mkdir -p "${GOFER_ROOT}/.tectonix/artifacts"
- local tgz="${GOFER_ROOT}/.tectonix/artifacts/onnxruntime-linux-x64-${ORT_VERSION}.tgz"
- curl -fsSL "https://github.com/microsoft/onnxruntime/releases/download/v${ORT_VERSION}/onnxruntime-linux-x64-${ORT_VERSION}.tgz" -o "$tgz"
- tar -xzf "$tgz" -C "${GOFER_ROOT}/.tectonix/artifacts"
- export ONNXRUNTIME_SHARED_LIBRARY_PATH="$so"
-}
-
-setup_venv_and_torch() {
- local py="$1" backend="$2" rocm_ver="$3"
- if try_existing_gpu_venv; then
- return 0
- fi
- log "venv ${VENV_DIR} python=${py} gpu_backend=${backend} rocm=${rocm_ver}"
- "$py" -m venv "$VENV_DIR"
- # shellcheck disable=SC1091
- source "${VENV_DIR}/bin/activate"
- pip install --upgrade pip wheel
- case "$backend" in
- rocm)
- local idx
- idx="$(rocm_pip_index "$rocm_ver")"
- log "pip torch from ${idx}"
- pip install torch torchvision torchaudio --index-url "$idx"
- ;;
- cuda)
- log "pip torch (cuda default index)"
- pip install torch torchvision torchaudio
- ;;
- *)
- log "pip torch CPU"
- pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
- ;;
- esac
- pip install -r "${GOFER_ROOT}/training/requirements.txt"
- pip install pytest
- python - <<'PY'
-import torch
-print("torch", torch.__version__)
-print("cuda_available", torch.cuda.is_available())
-if torch.cuda.is_available():
- print("device0", torch.cuda.get_device_name(0))
-PY
-}
-
-build_gofer() {
- cd "$GOFER_ROOT"
- export PATH="/usr/local/go/bin:${HOME}/.local/go/bin:${PATH}"
- export PYTHONPATH="${GOFER_ROOT}:${PYTHONPATH:-}"
- ensure_ort_lib
- log "building bin/gofer (-tags=onnx)"
- CGO_ENABLED=1 go build -tags=onnx -o bin/gofer ./cmd/gofer
- go test ./... -short -count=1
- pytest training/ -q
-}
-
-write_train_env() {
- local nproc_val="$1"
- local env_file="${GOFER_ROOT}/.gofer-train.env"
- cat >"$env_file" </dev/null || echo 8)")"
- found_repo="$(read_scope found_repo none)"
- GOFER_ROOT="$(detect_gofer_root "$found_repo")"
- GOENGINE_ROOT="$GOFER_ROOT"
- VENV_DIR="${GOFER_VENV:-${GOFER_ROOT}/.venv311}"
-
- log "goengine_root=${GOFER_ROOT} gpu_backend=${gpu_backend} rocm=${rocm_ver} nproc=${nproc_val}"
-
- install_apt_basics
- py="$(pick_python "$python_best")"
- [[ -n "$py" ]] || { echo "no python3 found"; exit 1; }
-
- install_go
- clone_or_update_repo
- setup_venv_and_torch "$py" "$gpu_backend" "$rocm_ver"
- build_gofer
- write_train_env "$nproc_val"
-
- log "setup complete (directory: ${GOFER_ROOT})"
- log "activate: source ${VENV_DIR}/bin/activate && source ${GOFER_ROOT}/.gofer-train.env"
- log "start training (tmux recommended):"
- echo " tmux new -s gofer"
- echo " cd ${GOFER_ROOT} && source ${VENV_DIR}/bin/activate && source .gofer-train.env"
- echo " bash scripts/train-loop-v3.sh 2>&1 | tee train-v3.log"
-
- if [[ "${SKIP_SMOKE:-0}" != "1" ]]; then
- run_smoke
- else
- log "SKIP_SMOKE=1 — skipped smoke cycle"
- fi
-}
-
-main "$@"
diff --git a/scripts/setup.sh b/scripts/setup.sh
new file mode 100755
index 0000000..926ca7f
--- /dev/null
+++ b/scripts/setup.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+# One-shot setup for a fresh Ubuntu/Debian machine (laptop, Vultr, Lambda, ...):
+# Go toolchain check, GNU Go (evaluation anchor), ONNX Runtime, Python venv, build.
+#
+# scripts/setup.sh # CPU box
+# scripts/setup.sh gpu # NVIDIA box: GPU ONNX Runtime + CUDA PyTorch
+set -euo pipefail
+
+FLAVOR="${1:-cpu}"
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+command -v go >/dev/null || { echo "install Go 1.22+ first: https://go.dev/dl/" >&2; exit 1; }
+if command -v apt-get >/dev/null && ! command -v gnugo >/dev/null && [[ ! -x /usr/games/gnugo ]]; then
+ sudo apt-get install -y gnugo python3-venv || echo "warning: could not install gnugo (evaluation anchors will be unavailable)"
+fi
+
+scripts/install-ort.sh "$FLAVOR"
+
+python3 -m venv .venv
+if [[ "$FLAVOR" == "gpu" ]]; then
+ .venv/bin/pip install -q torch --index-url https://download.pytorch.org/whl/cu124
+else
+ .venv/bin/pip install -q torch --index-url https://download.pytorch.org/whl/cpu
+fi
+.venv/bin/pip install -q -r training/requirements.txt
+
+make build
+echo
+echo "ready. try:"
+echo " make test"
+echo " .venv/bin/python -m training.loop --run runs/9x9 --gofer bin/gofer --hours 8"
diff --git a/scripts/train-loop-v3.sh b/scripts/train-loop-v3.sh
deleted file mode 100644
index c3b926b..0000000
--- a/scripts/train-loop-v3.sh
+++ /dev/null
@@ -1,278 +0,0 @@
-#!/usr/bin/env bash
-# ML pipeline v3: persistent replay, resume training, head-to-head arena promote.
-#
-# Each cycle: parallel self-play (vs the current champion) -> resume-train ->
-# export candidate -> arena the candidate against the CURRENT champion (not a
-# fixed heuristic) -> promote only if the candidate wins decisively. There is no
-# win-target stop: strength keeps climbing until candidates stop beating the
-# champion or the deadline hits.
-#
-# Usage: WEEK_DAYS=14 PARALLEL=8 bash scripts/train-loop-v3.sh
-set -euo pipefail
-
-# shellcheck disable=SC1091
-source "$(cd "$(dirname "$0")" && pwd)/common.sh"
-cd "$ROOT"
-# shellcheck disable=SC1091
-source "$ROOT/scripts/gating.env"
-
-WEEK_DAYS="${WEEK_DAYS:-14}"
-PROMOTE_WIN="${PROMOTE_WIN:-0.55}"
-ARENA_GAMES="${ARENA_GAMES:-200}"
-NEW_SELFPLAY_PER_CYCLE="${NEW_SELFPLAY_PER_CYCLE:-200}"
-SELFPLAY_PLAYOUTS="${SELFPLAY_PLAYOUTS:-200}"
-SELFPLAY_FULL_PLAYOUTS="${SELFPLAY_FULL_PLAYOUTS:-${SELFPLAY_PLAYOUTS}}"
-SELFPLAY_FAST_PLAYOUTS="${SELFPLAY_FAST_PLAYOUTS:-50}"
-SELFPLAY_CAP_RANDOMIZE_P="${SELFPLAY_CAP_RANDOMIZE_P:-0.20}"
-ARENA_PLAYOUTS="${ARENA_PLAYOUTS:-400}"
-SELFPLAY_TEMP_MOVES="${SELFPLAY_TEMP_MOVES:-16}"
-ARENA_OPENING_MOVES="${ARENA_OPENING_MOVES:-8}"
-REPLAY_MAX="${REPLAY_MAX:-50000}"
-TRAIN_EPOCHS_FRESH="${TRAIN_EPOCHS_FRESH:-25}"
-TRAIN_EPOCHS_RESUME="${TRAIN_EPOCHS_RESUME:-15}"
-TRAIN_LR_FRESH="${TRAIN_LR_FRESH:-0.01}"
-TRAIN_LR_RESUME="${TRAIN_LR_RESUME:-0.001}"
-PARALLEL="${PARALLEL:-$(nproc 2>/dev/null || echo 8)}"
-MAX_CYCLES="${MAX_CYCLES:-0}" # 0 = run until WEEK_DAYS deadline; 1 = single unattended cycle
-EVAL_BACKEND="${EVAL_BACKEND:-inprocess}"
-ORT_VERSION="1.26.0"
-ORT_ART="${ROOT}/.tectonix/artifacts/onnxruntime-linux-x64-${ORT_VERSION}"
-ORT_SO="${ORT_ART}/lib/libonnxruntime.so.${ORT_VERSION}"
-GO_BUILD_TAGS=()
-GOFER_EVAL_EXTRA=()
-
-STATE_DIR="training/state"
-DATA_DIR="training/data"
-MANIFEST="${STATE_DIR}/manifest.json"
-REPLAY="${DATA_DIR}/replay.jsonl"
-BEST_PT="${STATE_DIR}/best.pt"
-CANDIDATE_ONNX="models/gofer-9x9-candidate.onnx"
-BEST_ONNX="models/gofer-9x9-best.onnx"
-BOOTSTRAP_ONNX="models/gofer-9x9-bootstrap.onnx"
-CHAMPION_ARCHIVE_DIR="models/archive"
-GOFER_BIN="bin/gofer"
-CHAMP_PORT=8080
-CHALLENGER_PORT=8081
-LOG_FILE="train-v3.log"
-HISTORY_DIR=".tectonix/reports/training-history"
-
-DEADLINE="$(date -d "+${WEEK_DAYS} days" +%s 2>/dev/null || python3 -c "import time; print(int(time.time()+86400*float('${WEEK_DAYS}')))")"
-
-mkdir -p "$STATE_DIR" "$DATA_DIR" models "$HISTORY_DIR" .tectonix/reports bin
-export PATH="/usr/local/go/bin:${PATH:-}"
-export PYTHONPATH="${ROOT}:${PYTHONPATH:-}"
-if [[ -f .venv/bin/activate ]]; then
- # shellcheck disable=SC1091
- source .venv/bin/activate
-fi
-
-log() { echo "[$(date -Is)] $*" | tee -a "$LOG_FILE"; }
-
-ensure_ort_shared_lib() {
- if [[ -n "${ONNXRUNTIME_SHARED_LIBRARY_PATH:-}" && -f "${ONNXRUNTIME_SHARED_LIBRARY_PATH}" ]]; then
- return 0
- fi
- if [[ -f "$ORT_SO" ]]; then
- export ONNXRUNTIME_SHARED_LIBRARY_PATH="$ORT_SO"
- return 0
- fi
- log "downloading ORT ${ORT_VERSION} linux/amd64 for in-process inference"
- mkdir -p "${ROOT}/.tectonix/artifacts"
- local tgz="${ROOT}/.tectonix/artifacts/onnxruntime-linux-x64-${ORT_VERSION}.tgz"
- curl -fsSL "https://github.com/microsoft/onnxruntime/releases/download/v${ORT_VERSION}/onnxruntime-linux-x64-${ORT_VERSION}.tgz" -o "$tgz"
- rm -rf "$ORT_ART"
- tar -xzf "$tgz" -C "${ROOT}/.tectonix/artifacts"
- export ONNXRUNTIME_SHARED_LIBRARY_PATH="$ORT_SO"
-}
-
-gofer_eval_args() {
- # Sets global GOFER_EVAL_EXTRA array for ./bin/gofer invocations.
- GOFER_EVAL_EXTRA=()
- if [[ "$EVAL_BACKEND" == "inprocess" ]]; then
- GOFER_EVAL_EXTRA=(-eval-backend inprocess -model "$1")
- if [[ -n "${2:-}" ]]; then
- GOFER_EVAL_EXTRA+=(-model-2 "$2")
- fi
- elif [[ "$EVAL_BACKEND" == "sidecar" ]]; then
- GOFER_EVAL_EXTRA=(-eval-backend sidecar)
- fi
-}
-
-# Build the engine once and reuse the binary every cycle (no per-cycle recompile).
-if [[ "$EVAL_BACKEND" == "inprocess" ]]; then
- ensure_ort_shared_lib
- GO_BUILD_TAGS=(-tags=onnx)
- log "BUILD $GOFER_BIN (in-process ORT, -tags=onnx)"
-else
- log "BUILD $GOFER_BIN (sidecar, no CGO)"
-fi
-CGO_ENABLED=1 go build "${GO_BUILD_TAGS[@]}" -o "$GOFER_BIN" ./cmd/gofer
-
-SIDECAR_PIDS=()
-start_sidecar() {
- local model="$1" port="$2"
- python training/inference_server.py --model "$model" --port "$port" &
- SIDECAR_PIDS+=($!)
- for _ in $(seq 1 30); do
- if curl -sf "http://127.0.0.1:${port}/health" >/dev/null; then return 0; fi
- sleep 1
- done
- echo "sidecar on port ${port} failed to become healthy" >&2
- return 1
-}
-
-stop_sidecars() {
- pkill -f 'inference_server.py' 2>/dev/null || true
- for pid in "${SIDECAR_PIDS[@]:-}"; do
- kill "$pid" 2>/dev/null || true
- done
- SIDECAR_PIDS=()
-}
-trap stop_sidecars EXIT
-
-if [[ ! -f "$MANIFEST" ]]; then
- python -c "from pathlib import Path; from training.manifest import default_manifest, save_manifest; save_manifest(Path('$MANIFEST'), default_manifest())"
-fi
-
-cycle="$(python3 -c "import json; print(json.load(open('$MANIFEST')).get('cycle',0)+1)")"
-first_cycle="$cycle"
-
-while [[ "$(date +%s)" -lt "$DEADLINE" ]]; do
- samples="${DATA_DIR}/samples-cycle${cycle}.jsonl"
- have_champion=0
- [[ -f "$BEST_ONNX" ]] && have_champion=1
-
- # ---- self-play (parallel; vs current champion when one exists) ----
- if [[ "$have_champion" == "1" ]]; then
- log "cycle $cycle selfplay=$NEW_SELFPLAY_PER_CYCLE (mix vs champion) parallel=$PARALLEL backend=$EVAL_BACKEND"
- gofer_eval_args "$BEST_ONNX"
- if [[ "$EVAL_BACKEND" != "inprocess" ]]; then
- start_sidecar "$BEST_ONNX" "$CHAMP_PORT"
- fi
- "./$GOFER_BIN" -selfplay -games "$NEW_SELFPLAY_PER_CYCLE" -size 9 \
- -playouts "$SELFPLAY_PLAYOUTS" -full-only=true -selfplay-eval mix \
- -selfplay-fast-playouts "$SELFPLAY_FAST_PLAYOUTS" \
- -selfplay-full-playouts "$SELFPLAY_FULL_PLAYOUTS" \
- -selfplay-cap-randomize-p "$SELFPLAY_CAP_RANDOMIZE_P" \
- -selfplay-onnx-fraction 0.7 -selfplay-parallel "$PARALLEL" \
- -selfplay-temp-moves "$SELFPLAY_TEMP_MOVES" \
- -onnx-url "http://127.0.0.1:${CHAMP_PORT}" \
- "${GOFER_EVAL_EXTRA[@]}" \
- -o "$samples" -seed "$((42 + cycle))"
- if [[ "$EVAL_BACKEND" != "inprocess" ]]; then
- stop_sidecars
- fi
- else
- log "cycle $cycle selfplay=$NEW_SELFPLAY_PER_CYCLE (heuristic bootstrap) parallel=$PARALLEL"
- "./$GOFER_BIN" -selfplay -games "$NEW_SELFPLAY_PER_CYCLE" -size 9 \
- -playouts "$SELFPLAY_PLAYOUTS" -full-only=true -selfplay-eval heuristic \
- -selfplay-fast-playouts "$SELFPLAY_FAST_PLAYOUTS" \
- -selfplay-full-playouts "$SELFPLAY_FULL_PLAYOUTS" \
- -selfplay-cap-randomize-p "$SELFPLAY_CAP_RANDOMIZE_P" \
- -selfplay-parallel "$PARALLEL" -selfplay-temp-moves "$SELFPLAY_TEMP_MOVES" \
- -o "$samples" -seed "$((42 + cycle))"
- fi
-
- python -m training.cycle append-replay "$samples" --max-lines "$REPLAY_MAX" | tee -a "$LOG_FILE"
-
- # ---- train (resume the champion's weights when present) ----
- if [[ -f "$BEST_PT" ]]; then
- cp "$BEST_PT" "${STATE_DIR}/best.pt.pre-cycle"
- python training/train_bootstrap.py \
- --data "$REPLAY" --resume "$BEST_PT" --out-dir "$STATE_DIR" \
- --epochs "$TRAIN_EPOCHS_RESUME" --lr "$TRAIN_LR_RESUME"
- else
- python training/train_bootstrap.py \
- --data "$REPLAY" --out-dir "$STATE_DIR" \
- --epochs "$TRAIN_EPOCHS_FRESH" --lr "$TRAIN_LR_FRESH"
- fi
-
- python training/export_onnx.py --checkpoint "$BEST_PT" --out "$CANDIDATE_ONNX"
-
- # ---- arena: candidate vs CURRENT champion (head-to-head) ----
- report=".tectonix/reports/arena-cycle-${cycle}.json"
- if [[ "$have_champion" == "1" ]]; then
- log "cycle $cycle arena=$ARENA_GAMES candidate vs champion parallel=$PARALLEL backend=$EVAL_BACKEND"
- gofer_eval_args "$BEST_ONNX" "$CANDIDATE_ONNX"
- if [[ "$EVAL_BACKEND" != "inprocess" ]]; then
- start_sidecar "$BEST_ONNX" "$CHAMP_PORT"
- start_sidecar "$CANDIDATE_ONNX" "$CHALLENGER_PORT"
- fi
- "./$GOFER_BIN" -arena -games "$ARENA_GAMES" -size 9 -playouts "$ARENA_PLAYOUTS" \
- -black-eval onnx -white-eval onnx2 \
- -onnx-url "http://127.0.0.1:${CHAMP_PORT}" \
- -onnx-url-2 "http://127.0.0.1:${CHALLENGER_PORT}" \
- "${GOFER_EVAL_EXTRA[@]}" \
- -arena-parallel "$PARALLEL" -arena-opening-moves "$ARENA_OPENING_MOVES" \
- -eval-timeout 2s -arena-enhanced none \
- -seed "$((42 + cycle))" -json "$report" | tee -a "$LOG_FILE"
- if [[ "$EVAL_BACKEND" != "inprocess" ]]; then
- stop_sidecars
- fi
- else
- log "cycle $cycle arena=$ARENA_GAMES candidate vs heuristic (bootstrap sanity check) parallel=$PARALLEL backend=$EVAL_BACKEND"
- gofer_eval_args "$CANDIDATE_ONNX"
- if [[ "$EVAL_BACKEND" != "inprocess" ]]; then
- start_sidecar "$CANDIDATE_ONNX" "$CHAMP_PORT"
- fi
- "./$GOFER_BIN" -arena -games "$ARENA_GAMES" -size 9 -playouts "$ARENA_PLAYOUTS" \
- -black-eval heuristic -white-eval onnx \
- -onnx-url "http://127.0.0.1:${CHAMP_PORT}" \
- "${GOFER_EVAL_EXTRA[@]}" \
- -arena-parallel "$PARALLEL" -arena-opening-moves "$ARENA_OPENING_MOVES" \
- -eval-timeout 2s -arena-enhanced none \
- -seed "$((42 + cycle))" -json "$report" | tee -a "$LOG_FILE"
- if [[ "$EVAL_BACKEND" != "inprocess" ]]; then
- stop_sidecars
- fi
- fi
-
- # The first cycle seeds the champion unconditionally (the net only imitates the
- # heuristic from bootstrap data; real gains come from net-vs-net self-play next).
- seed_flag=()
- [[ "$have_champion" == "0" ]] && seed_flag=(--seed-champion)
- GATING_MODE="${GATING_MODE:-normal}"
- promote="$(python -m training.cycle record-cycle "$cycle" "$report" \
- --promote-win "$PROMOTE_WIN" --history-dir "$HISTORY_DIR" \
- --gating-mode "$GATING_MODE" "${seed_flag[@]}")"
- rate="$(python3 -c "import json; print(json.load(open('$report')).get('win_rate_challenger',0))")"
- would_promote="$(python3 -c "import json; print(json.load(open('${HISTORY_DIR}/cycle-${cycle}.json')).get('would_promote', False))")"
-
- if [[ "$GATING_MODE" == "hold" ]]; then
- log "HOLD cycle=$cycle rate=$rate would_promote=$would_promote gating_mode=hold (no champion swap)"
- if [[ -f "${STATE_DIR}/best.pt.pre-cycle" ]]; then
- cp "${STATE_DIR}/best.pt.pre-cycle" "$BEST_PT"
- rm -f "${STATE_DIR}/best.pt.pre-cycle"
- fi
- elif [[ "$promote" == "promote" ]]; then
- if [[ "$have_champion" == "0" ]]; then
- log "SEED cycle=$cycle rate=$rate (first champion established; self-play now uses the net)"
- else
- log "PROMOTE cycle=$cycle rate=$rate promote_win=$PROMOTE_WIN (candidate is the new champion)"
- if [[ -f "$BEST_ONNX" ]]; then
- mkdir -p "$CHAMPION_ARCHIVE_DIR"
- archive="${CHAMPION_ARCHIVE_DIR}/pre-promote-cycle-${cycle}.onnx"
- cp "$BEST_ONNX" "$archive"
- log "archived pre-promotion champion -> $archive"
- fi
- fi
- cp "$CANDIDATE_ONNX" "$BEST_ONNX"
- cp "$BEST_ONNX" "$BOOTSTRAP_ONNX"
- rm -f "${STATE_DIR}/best.pt.pre-cycle"
- else
- log "REJECT cycle=$cycle rate=$rate; keeping champion $BEST_ONNX"
- if [[ -f "${STATE_DIR}/best.pt.pre-cycle" ]]; then
- cp "${STATE_DIR}/best.pt.pre-cycle" "$BEST_PT"
- rm -f "${STATE_DIR}/best.pt.pre-cycle"
- fi
- fi
-
- cycle=$((cycle + 1))
- if [[ "$MAX_CYCLES" -gt 0 && $((cycle - first_cycle)) -ge "$MAX_CYCLES" ]]; then
- log "MAX_CYCLES=$MAX_CYCLES complete after cycle $((cycle - 1))"
- exit 0
- fi
-done
-
-log "deadline reached after cycle $((cycle - 1))"
-exit 0
diff --git a/scripts/weekly-train-loop.sh b/scripts/weekly-train-loop.sh
deleted file mode 100644
index 08f1813..0000000
--- a/scripts/weekly-train-loop.sh
+++ /dev/null
@@ -1,11 +0,0 @@
-#!/usr/bin/env bash
-# LEGACY — superseded by scripts/train-loop-v3.sh (ML pipeline v3, ADR 0003).
-# This file is a compatibility alias only; do not extend it.
-# Kept so old docs/commands that reference weekly-train-loop.sh still work.
-set -euo pipefail
-
-# shellcheck disable=SC1091
-source "$(cd "$(dirname "$0")" && pwd)/common.sh"
-# shellcheck disable=SC1091
-source "$ROOT/scripts/gating.env"
-exec bash "$ROOT/scripts/train-loop-v3.sh"
diff --git a/training/__init__.py b/training/__init__.py
index 4724773..e69de29 100644
--- a/training/__init__.py
+++ b/training/__init__.py
@@ -1 +0,0 @@
-"""Gofer bootstrap training package."""
diff --git a/training/conftest.py b/training/conftest.py
deleted file mode 100644
index 65c1086..0000000
--- a/training/conftest.py
+++ /dev/null
@@ -1,8 +0,0 @@
-"""Pytest path: allow `from replay import` style in training tests."""
-
-from __future__ import annotations
-
-import sys
-from pathlib import Path
-
-sys.path.insert(0, str(Path(__file__).resolve().parent))
diff --git a/training/cycle.py b/training/cycle.py
deleted file mode 100644
index bada36b..0000000
--- a/training/cycle.py
+++ /dev/null
@@ -1,159 +0,0 @@
-"""Train-loop v3 cycle helpers (manifest, replay, promote decision)."""
-
-from __future__ import annotations
-
-import argparse
-import json
-import shutil
-from pathlib import Path
-
-from training.manifest import default_manifest, load_manifest, save_manifest, utc_now
-from training.replay import append_jsonl, count_lines, trim
-
-STATE_DIR = Path("training/state")
-DATA_DIR = Path("training/data")
-MANIFEST_PATH = STATE_DIR / "manifest.json"
-REPLAY_PATH = DATA_DIR / "replay.jsonl"
-BEST_PT = STATE_DIR / "best.pt"
-BEST_ONNX = Path("models/gofer-9x9-best.onnx")
-BOOTSTRAP_ONNX = Path("models/gofer-9x9-bootstrap.onnx")
-
-
-def is_header_line(line: str) -> bool:
- return '"type":"header"' in line.replace(" ", "")
-
-
-def init_from_cycle2(*, best_win_rate: float = 0.58, best_cycle: int = 2) -> dict:
- STATE_DIR.mkdir(parents=True, exist_ok=True)
- DATA_DIR.mkdir(parents=True, exist_ok=True)
- src_pt = Path("training/checkpoints/cycle2/best.pt")
- if not src_pt.exists():
- raise FileNotFoundError(f"missing seed checkpoint: {src_pt}")
- shutil.copy2(src_pt, BEST_PT)
- if REPLAY_PATH.exists():
- REPLAY_PATH.unlink()
- for name in ("samples-cycle1.jsonl", "samples-cycle2.jsonl"):
- path = DATA_DIR / name
- if path.exists():
- append_jsonl(path, REPLAY_PATH)
- manifest = default_manifest(seed="cycle2", best_win_rate=best_win_rate, best_cycle=best_cycle)
- manifest["cycle"] = best_cycle
- manifest["replay_rows"] = count_lines(REPLAY_PATH)
- save_manifest(MANIFEST_PATH, manifest)
- if BEST_ONNX.exists():
- shutil.copy2(BEST_ONNX, BOOTSTRAP_ONNX)
- return manifest
-
-
-def append_replay(samples: Path, *, max_lines: int = 50000) -> int:
- append_jsonl(samples, REPLAY_PATH)
- trim(REPLAY_PATH, max_lines)
- return count_lines(REPLAY_PATH)
-
-
-def should_promote(win_rate: float, wilson_ci_low: float, *, promote_win: float) -> bool:
- """Head-to-head gate: the candidate must beat the CURRENT champion.
-
- Promotion requires clearing the win-rate bar (default 0.55) AND the Wilson
- lower bound being above 0.5, i.e. significantly better than a coin flip over
- the arena sample. There is no fixed-heuristic anchor and no win-target stop,
- so strength keeps climbing until candidates can no longer beat the champion.
- """
- return win_rate >= promote_win and wilson_ci_low > 0.5
-
-
-def record_cycle(
- cycle: int,
- report_path: Path,
- *,
- promote_win: float,
- history_dir: Path,
- seed_champion: bool = False,
- gating_mode: str = "normal",
-) -> tuple[bool, dict]:
- arena = json.loads(report_path.read_text(encoding="utf-8"))
- rate = float(arena.get("win_rate_challenger", 0))
- ci_low = float(arena.get("wilson_ci_low", 0))
- manifest = load_manifest(MANIFEST_PATH)
- # seed_champion: the first network in the lineage is installed unconditionally
- # so self-play can start using the net. The arena still runs as a sanity check,
- # but there is no champion yet to gate against.
- would_promote = seed_champion or should_promote(rate, ci_low, promote_win=promote_win)
- hold = gating_mode.strip().lower() == "hold"
- promote = would_promote and not hold
-
- manifest["cycle"] = cycle
- manifest["replay_rows"] = count_lines(REPLAY_PATH)
- entry = {
- "cycle": cycle,
- "win_rate": rate,
- "wilson_ci_low": ci_low,
- "wilson_ci_high": float(arena.get("wilson_ci_high", 0)),
- "promote_threshold": promote_win,
- "promoted": promote,
- "gating_mode": gating_mode,
- "would_promote": would_promote,
- "gating_hold": hold,
- "arena": arena,
- "manifest_before": dict(manifest),
- }
- if promote:
- # best_win_rate here is the candidate's rate vs the champion it beat,
- # recorded for history; each new champion resets the head-to-head baseline.
- manifest["best_win_rate"] = rate
- manifest["best_cycle"] = cycle
- manifest["last_promoted_at"] = utc_now()
- entry["manifest_after"] = dict(manifest)
- history_dir.mkdir(parents=True, exist_ok=True)
- (history_dir / f"cycle-{cycle}.json").write_text(json.dumps(entry, indent=2) + "\n", encoding="utf-8")
- save_manifest(MANIFEST_PATH, manifest)
- return promote, entry
-
-
-def main() -> None:
- p = argparse.ArgumentParser(description="train-loop v3 cycle helpers")
- sub = p.add_subparsers(dest="cmd", required=True)
-
- init_p = sub.add_parser("init-cycle2", help="seed state from cycle-2 artifacts")
- init_p.add_argument("--best-win-rate", type=float, default=0.58)
- init_p.add_argument("--best-cycle", type=int, default=2)
-
- append_p = sub.add_parser("append-replay", help="append samples and trim replay buffer")
- append_p.add_argument("samples", type=Path)
- append_p.add_argument("--max-lines", type=int, default=50000)
-
- rec_p = sub.add_parser("record-cycle", help="write cycle history and update manifest")
- rec_p.add_argument("cycle", type=int)
- rec_p.add_argument("report", type=Path)
- rec_p.add_argument("--promote-win", type=float, default=0.55,
- help="head-to-head win rate the candidate must clear to replace the champion")
- rec_p.add_argument("--history-dir", type=Path, default=Path(".tectonix/reports/training-history"))
- rec_p.add_argument("--seed-champion", action="store_true",
- help="install this candidate as the first champion unconditionally (bootstrap cycle)")
- rec_p.add_argument("--gating-mode", default="normal",
- choices=("normal", "hold"),
- help="hold: run arena but never promote (logs would_promote for later)")
-
- args = p.parse_args()
- if args.cmd == "init-cycle2":
- m = init_from_cycle2(best_win_rate=args.best_win_rate, best_cycle=args.best_cycle)
- print(json.dumps(m))
- elif args.cmd == "append-replay":
- print(append_replay(args.samples, max_lines=args.max_lines))
- elif args.cmd == "record-cycle":
- promote, entry = record_cycle(
- args.cycle,
- args.report,
- promote_win=args.promote_win,
- history_dir=args.history_dir,
- seed_champion=args.seed_champion,
- gating_mode=args.gating_mode,
- )
- if entry.get("gating_hold"):
- print("hold-would-promote" if entry.get("would_promote") else "hold-reject")
- else:
- print("promote" if promote else "reject")
-
-
-if __name__ == "__main__":
- main()
diff --git a/training/data.py b/training/data.py
new file mode 100644
index 0000000..4335724
--- /dev/null
+++ b/training/data.py
@@ -0,0 +1,117 @@
+"""Self-play shards (.npz written by `gofer selfplay`) and augmented batches."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+
+import numpy as np
+import torch
+
+from training.features import NUM_GLOBALS, NUM_PLANES
+
+FIELDS = ("spatial", "global", "policy", "value", "q", "ownership", "ownership_weight")
+
+
+@dataclass
+class Rows:
+ spatial: np.ndarray # uint8 [N, C, S, S]
+ glob: np.ndarray # float32 [N, G]
+ policy: np.ndarray # float32 [N, S*S+1]
+ value: np.ndarray # float32 [N] game result for the side to move
+ q: np.ndarray # float32 [N] root search value
+ ownership: np.ndarray # int8 [N, S*S]
+ ownership_weight: np.ndarray # float32 [N]
+
+ def __len__(self) -> int:
+ return len(self.value)
+
+ @property
+ def size(self) -> int:
+ return self.spatial.shape[-1]
+
+
+def load_shard(path: Path) -> Rows:
+ with np.load(path) as d:
+ rows = Rows(
+ spatial=d["spatial"],
+ glob=d["global"],
+ policy=d["policy"],
+ value=d["value"],
+ q=d["q"],
+ ownership=d["ownership"],
+ ownership_weight=d["ownership_weight"],
+ )
+ if rows.spatial.shape[1] != NUM_PLANES or rows.glob.shape[1] != NUM_GLOBALS:
+ raise ValueError(f"{path}: feature schema mismatch {rows.spatial.shape} {rows.glob.shape}")
+ return rows
+
+
+def concat(parts: list[Rows]) -> Rows:
+ return Rows(*(np.concatenate([getattr(p, f) for p in parts]) for f in Rows.__dataclass_fields__))
+
+
+def load_window(shards: list[Path], max_rows: int) -> Rows:
+ """Load the newest shards (by order given, oldest first) up to max_rows."""
+ parts: list[Rows] = []
+ total = 0
+ for path in reversed(shards):
+ r = load_shard(path)
+ parts.append(r)
+ total += len(r)
+ if total >= max_rows:
+ break
+ rows = concat(list(reversed(parts)))
+ if len(rows) > max_rows:
+ rows = Rows(*(getattr(rows, f)[-max_rows:] for f in Rows.__dataclass_fields__))
+ return rows
+
+
+def symmetrize(spatial: torch.Tensor, policy: torch.Tensor, own: torch.Tensor, sym: int):
+ """Apply dihedral symmetry `sym` (bit 4: transpose, 1: flip x, 2: flip y)
+ to a batch, matching internal/nn/features.go."""
+ s = spatial.shape[-1]
+ board = policy[:, :-1].reshape(-1, s, s)
+ own = own.reshape(-1, s, s)
+ tensors = [spatial, board[:, None], own[:, None]]
+ out = []
+ for t in tensors:
+ if sym & 4:
+ t = t.transpose(-1, -2)
+ if sym & 1:
+ t = t.flip(-1)
+ if sym & 2:
+ t = t.flip(-2)
+ out.append(t)
+ spatial, board, own = out
+ policy = torch.cat([board.reshape(-1, s * s), policy[:, -1:]], dim=1)
+ return spatial, policy, own.reshape(-1, s * s)
+
+
+class Sampler:
+ """Uniform random minibatches from a window, each with a random symmetry."""
+
+ def __init__(self, rows: Rows, batch_size: int, q_weight: float, seed: int) -> None:
+ self.rows = rows
+ self.batch_size = batch_size
+ self.q_weight = q_weight
+ self.rng = np.random.default_rng(seed)
+
+ def batch(self, device: torch.device):
+ r = self.rows
+ idx = self.rng.integers(0, len(r), self.batch_size)
+ spatial = torch.from_numpy(r.spatial[idx].astype(np.float32))
+ glob = torch.from_numpy(r.glob[idx])
+ policy = torch.from_numpy(r.policy[idx])
+ value = torch.from_numpy((1 - self.q_weight) * r.value[idx] + self.q_weight * r.q[idx])
+ own = torch.from_numpy(r.ownership[idx].astype(np.float32))
+ own_w = torch.from_numpy(r.ownership_weight[idx])
+ syms = self.rng.integers(0, 8, self.batch_size)
+ sp_out, pol_out, own_out = torch.empty_like(spatial), torch.empty_like(policy), torch.empty_like(own)
+ for s in range(8):
+ m = torch.from_numpy(np.nonzero(syms == s)[0])
+ if len(m) == 0:
+ continue
+ a, b, c = symmetrize(spatial[m], policy[m], own[m], s)
+ sp_out[m], pol_out[m], own_out[m] = a, b, c
+ return tuple(t.to(device, non_blocking=True) for t in (sp_out, glob, pol_out, value, own_out, own_w))
diff --git a/training/data/ablation/README.md b/training/data/ablation/README.md
deleted file mode 100644
index e63e46b..0000000
--- a/training/data/ablation/README.md
+++ /dev/null
@@ -1,17 +0,0 @@
-# Net-size ablation snapshot
-
-`net-size-replay-snapshot.jsonl` is a **frozen copy** of production
-`training/data/replay.jsonl` (50k rows, captured Jul 2026 pre–Piece-1-cap-randomization
-cycles). **Do not append** new self-play data here.
-
-Used only by `training/net_size_ablation.py`. Findings and decision:
-`docs/decisions/0005-net-size-ablation.md`.
-
-To refresh the snapshot for a future re-run (only when intentionally starting a
-new ablation cohort):
-
-```bash
-cp training/data/replay.jsonl training/data/ablation/net-size-replay-snapshot.jsonl
-```
-
-The snapshot file is large (~130 MB) and is not committed to git.
diff --git a/training/dataset.py b/training/dataset.py
deleted file mode 100644
index 8062870..0000000
--- a/training/dataset.py
+++ /dev/null
@@ -1,57 +0,0 @@
-"""Load self-play JSONL samples for bootstrap training."""
-
-from __future__ import annotations
-
-import json
-from pathlib import Path
-from typing import Iterator
-
-import torch
-from torch.utils.data import Dataset
-
-BOARD_SIZE = 9
-POLICY_SIZE = BOARD_SIZE * BOARD_SIZE + 1
-PLANES = 8
-SPATIAL_SIZE = PLANES * BOARD_SIZE * BOARD_SIZE
-GLOBALS = 4
-
-
-def iter_samples(path: Path) -> Iterator[dict]:
- with path.open() as f:
- for line in f:
- line = line.strip()
- if not line:
- continue
- row = json.loads(line)
- if row.get("type") == "header":
- if row.get("schema_version", 1) != 1:
- raise ValueError(f"unsupported schema {row.get('schema_version')}")
- continue
- yield row
-
-
-class SampleDataset(Dataset):
- """Self-play samples with exported features and board-indexed policy."""
-
- def __init__(self, path: Path) -> None:
- self.rows = [
- r for r in iter_samples(path)
- if len(r.get("policy", [])) == POLICY_SIZE and r.get("features_spatial")
- ]
- if not self.rows:
- raise ValueError(f"no valid samples in {path}")
-
- def __len__(self) -> int:
- return len(self.rows)
-
- def __getitem__(self, idx: int) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
- row = self.rows[idx]
- spatial = torch.tensor(row["features_spatial"], dtype=torch.float32).reshape(PLANES, BOARD_SIZE, BOARD_SIZE)
- globals_ = torch.tensor(row["features_global"], dtype=torch.float32)
- policy = torch.tensor(row["policy"], dtype=torch.float32)
- value = torch.tensor(float(row.get("value", 0.0)), dtype=torch.float32)
- own = row.get("ownership") or [0.0] * (BOARD_SIZE * BOARD_SIZE)
- if len(own) != BOARD_SIZE * BOARD_SIZE:
- own = [0.0] * (BOARD_SIZE * BOARD_SIZE)
- ownership = torch.tensor(own, dtype=torch.float32)
- return spatial, globals_, policy, value, ownership
diff --git a/training/elo.py b/training/elo.py
new file mode 100644
index 0000000..6459111
--- /dev/null
+++ b/training/elo.py
@@ -0,0 +1,50 @@
+"""Bradley-Terry Elo ratings from pairwise match results."""
+
+from __future__ import annotations
+
+import math
+from collections import defaultdict
+
+
+def fit(results: list[dict], anchor: str, prior_games: float = 1.0, iters: int = 2000) -> dict[str, dict]:
+ """Fit ratings from results [{"a", "b", "wins", "losses", "draws"}].
+
+ Draws count as half a win each way. `prior_games` adds that many virtual
+ drawn games between every pair that met, which keeps perfect scores finite.
+ Returns {player: {"elo": r, "stderr": s, "games": n}} with `anchor` at 0.
+ """
+ wins: dict[tuple[str, str], float] = defaultdict(float)
+ games: dict[tuple[str, str], float] = defaultdict(float)
+ players: set[str] = set()
+ for r in results:
+ a, b = r["a"], r["b"]
+ players |= {a, b}
+ n = r["wins"] + r["losses"] + r["draws"]
+ wa = r["wins"] + 0.5 * r["draws"]
+ wins[(a, b)] += wa + prior_games / 2
+ wins[(b, a)] += n - wa + prior_games / 2
+ games[(a, b)] += n + prior_games
+ games[(b, a)] += n + prior_games
+ if anchor not in players:
+ anchor = sorted(players)[0] if players else anchor
+ gamma = {p: 1.0 for p in players}
+ for _ in range(iters):
+ delta = 0.0
+ for p in players:
+ w = sum(wins[(p, q)] for q in players if (p, q) in wins)
+ denom = sum(games[(p, q)] / (gamma[p] + gamma[q]) for q in players if (p, q) in games)
+ if denom > 0 and w > 0:
+ new = w / denom
+ delta = max(delta, abs(math.log(new / gamma[p])))
+ gamma[p] = new
+ if delta < 1e-9:
+ break
+ ref = gamma.get(anchor, 1.0)
+ out = {}
+ for p in players:
+ # Fisher information of log-strength gives an approximate standard error.
+ info = sum(games[(p, q)] * gamma[p] * gamma[q] / (gamma[p] + gamma[q]) ** 2 for q in players if (p, q) in games)
+ se = 400 / math.log(10) / math.sqrt(info) if info > 0 else float("inf")
+ n = sum(games[(p, q)] for q in players if (p, q) in games) - prior_games * sum(1 for q in players if (p, q) in games)
+ out[p] = {"elo": 400 * math.log10(gamma[p] / ref), "stderr": 0.0 if p == anchor else se, "games": int(round(n))}
+ return out
diff --git a/training/export.py b/training/export.py
new file mode 100644
index 0000000..cf4a77d
--- /dev/null
+++ b/training/export.py
@@ -0,0 +1,79 @@
+"""Export a checkpoint to ONNX (inputs spatial/global, outputs policy/value)."""
+
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+
+import numpy as np
+import torch
+
+from training.features import NUM_GLOBALS, NUM_PLANES
+from training.model import GoferNet
+
+
+class _Inference(torch.nn.Module):
+ def __init__(self, net: GoferNet) -> None:
+ super().__init__()
+ self.net = net
+
+ def forward(self, spatial: torch.Tensor, glob: torch.Tensor):
+ policy, value, _ = self.net(spatial, glob)
+ return policy, value
+
+
+def export(model: GoferNet, path: Path, verify: bool = True) -> None:
+ model = model.cpu().eval()
+ s = model.cfg.board_size
+ spatial = torch.zeros(2, NUM_PLANES, s, s)
+ glob = torch.zeros(2, NUM_GLOBALS)
+ tmp = path.with_suffix(".tmp.onnx")
+ # The wrapper must be in eval mode too: export traces (and afterwards
+ # restores) the mode of the module it is given, BatchNorm included.
+ torch.onnx.export(
+ _Inference(model).eval(),
+ (spatial, glob),
+ tmp,
+ input_names=["spatial", "global"],
+ output_names=["policy", "value"],
+ dynamic_axes={"spatial": {0: "batch"}, "global": {0: "batch"}, "policy": {0: "batch"}, "value": {0: "batch"}},
+ opset_version=17,
+ dynamo=False,
+ )
+ model.eval()
+ if verify:
+ verify_onnx(model, tmp)
+ tmp.replace(path)
+
+
+def verify_onnx(model: GoferNet, path: Path, atol: float = 1e-3) -> None:
+ import onnxruntime as ort
+
+ s = model.cfg.board_size
+ rng = np.random.default_rng(0)
+ spatial = (rng.random((5, NUM_PLANES, s, s)) < 0.3).astype(np.float32)
+ glob = rng.standard_normal((5, NUM_GLOBALS)).astype(np.float32)
+ sess = ort.InferenceSession(str(path), providers=["CPUExecutionProvider"])
+ p_onnx, v_onnx = sess.run(None, {"spatial": spatial, "global": glob})
+ with torch.no_grad():
+ p_t, v_t, _ = model(torch.from_numpy(spatial), torch.from_numpy(glob))
+ if not (np.allclose(p_onnx, p_t.numpy(), atol=atol) and np.allclose(v_onnx, v_t.numpy(), atol=atol)):
+ raise RuntimeError(f"ONNX export of {path} does not match PyTorch")
+
+
+def main() -> None:
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("checkpoint", type=Path)
+ ap.add_argument("out", type=Path)
+ args = ap.parse_args()
+ from training.model import ModelConfig
+
+ ck = torch.load(args.checkpoint, map_location="cpu", weights_only=False)
+ model = GoferNet(ModelConfig(**ck["model_config"]))
+ model.load_state_dict(ck["model"])
+ export(model, args.out)
+ print(f"wrote {args.out}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/training/export_onnx.py b/training/export_onnx.py
deleted file mode 100644
index c13b0aa..0000000
--- a/training/export_onnx.py
+++ /dev/null
@@ -1,93 +0,0 @@
-"""Export Gofer bootstrap net to ONNX."""
-
-from __future__ import annotations
-
-import argparse
-from pathlib import Path
-
-import torch
-
-from model import GoferBootstrapNet, BOARD_SIZE, POLICY_SIZE
-
-
-class PolicyValueNet(torch.nn.Module):
- """Policy + value only — skips ownership head at inference."""
-
- def __init__(self, net: GoferBootstrapNet) -> None:
- super().__init__()
- self.net = net
-
- def forward(
- self, spatial_input: torch.Tensor, global_input: torch.Tensor
- ) -> tuple[torch.Tensor, torch.Tensor]:
- logits, value, _ = self.net(spatial_input, global_input)
- return logits, value
-
-
-def export_onnx(
- out_path: Path,
- checkpoint: Path | None = None,
- seed: int = 42,
- *,
- with_ownership: bool = False,
-) -> None:
- torch.manual_seed(seed)
- net = GoferBootstrapNet()
- if checkpoint and checkpoint.exists():
- state = torch.load(checkpoint, map_location="cpu", weights_only=True)
- net.load_state_dict(state)
- net.eval()
-
- spatial = torch.zeros(1, 8, BOARD_SIZE, BOARD_SIZE)
- global_in = torch.zeros(1, 4)
- out_path.parent.mkdir(parents=True, exist_ok=True)
- if with_ownership:
- export_model = net
- output_names = ["policy_logits", "value", "ownership"]
- dynamic_axes = {
- "spatial_input": {0: "batch"},
- "global_input": {0: "batch"},
- "policy_logits": {0: "batch"},
- "value": {0: "batch"},
- "ownership": {0: "batch"},
- }
- else:
- export_model = PolicyValueNet(net)
- output_names = ["policy_logits", "value"]
- dynamic_axes = {
- "spatial_input": {0: "batch"},
- "global_input": {0: "batch"},
- "policy_logits": {0: "batch"},
- "value": {0: "batch"},
- }
- torch.onnx.export(
- export_model,
- (spatial, global_in),
- str(out_path),
- input_names=["spatial_input", "global_input"],
- output_names=output_names,
- dynamic_axes=dynamic_axes,
- opset_version=18,
- dynamo=False,
- )
- heads = "policy+value+ownership" if with_ownership else "policy+value"
- print(f"wrote {out_path} policy_size={POLICY_SIZE} outputs={heads}")
-
-
-def main() -> None:
- p = argparse.ArgumentParser()
- p.add_argument("--out", default="models/gofer-9x9-bootstrap.onnx")
- p.add_argument("--checkpoint", default="", help="optional .pt checkpoint")
- p.add_argument("--seed", type=int, default=42)
- p.add_argument(
- "--with-ownership",
- action="store_true",
- help="export all three heads (training/debug); default is policy+value only",
- )
- args = p.parse_args()
- ckpt = Path(args.checkpoint) if args.checkpoint else None
- export_onnx(Path(args.out), ckpt, args.seed, with_ownership=args.with_ownership)
-
-
-if __name__ == "__main__":
- main()
diff --git a/training/features.py b/training/features.py
new file mode 100644
index 0000000..aed5eb3
--- /dev/null
+++ b/training/features.py
@@ -0,0 +1,5 @@
+"""Feature schema shared with the Go engine (internal/nn/features.go)."""
+
+SCHEMA_VERSION = 3
+NUM_PLANES = 13
+NUM_GLOBALS = 4
diff --git a/training/inference_server.py b/training/inference_server.py
deleted file mode 100644
index 5d3d7d2..0000000
--- a/training/inference_server.py
+++ /dev/null
@@ -1,173 +0,0 @@
-"""HTTP ONNX Runtime sidecar for Gofer batched eval."""
-
-from __future__ import annotations
-
-import argparse
-import json
-import signal
-import sys
-import time
-from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
-from pathlib import Path
-from typing import Any
-
-import numpy as np
-import onnxruntime as ort
-
-SCHEMA_VERSION = 2
-BOARD_SIZE = 9
-POLICY_SIZE = BOARD_SIZE * BOARD_SIZE + 1
-LOG_EVERY_N = 50
-
-
-def softmax(x: np.ndarray) -> np.ndarray:
- x = x - np.max(x)
- e = np.exp(x)
- return e / np.sum(e)
-
-
-def pick_providers() -> list[str]:
- available = set(ort.get_available_providers())
- if "CUDAExecutionProvider" in available:
- try:
- import torch # noqa: F401
-
- if torch.cuda.is_available():
- return ["CUDAExecutionProvider", "CPUExecutionProvider"]
- except Exception:
- pass
- return ["CPUExecutionProvider"]
-
-
-def _session_options() -> ort.SessionOptions:
- opts = ort.SessionOptions()
- opts.intra_op_num_threads = 1
- opts.inter_op_num_threads = 1
- return opts
-
-
-class Session:
- def __init__(self, model_path: str) -> None:
- self.model_path = model_path
- self.providers = pick_providers()
- self.sess = ort.InferenceSession(
- model_path, sess_options=_session_options(), providers=self.providers
- )
- self.spatial_name = "spatial_input"
- self.global_name = "global_input"
- self.request_count = 0
-
- def reload(self, model_path: str) -> None:
- self.model_path = model_path
- self.sess = ort.InferenceSession(
- model_path, sess_options=_session_options(), providers=self.providers
- )
-
- def eval_batch(self, spatial: list[list[float]], globals_: list[list[float]]) -> list[dict[str, Any]]:
- t0 = time.perf_counter()
- batch = len(spatial)
- sp = np.array(spatial, dtype=np.float32).reshape(batch, 8, BOARD_SIZE, BOARD_SIZE)
- gl = np.array(globals_, dtype=np.float32).reshape(batch, 4)
- feeds = {self.spatial_name: sp, self.global_name: gl}
- # Model may emit a third "ownership" output; the engine only needs
- # policy + value, so take the first two and ignore the rest.
- outputs = self.sess.run(None, feeds)
- logits, value = outputs[0], outputs[1]
- results = []
- for i in range(batch):
- policy = softmax(logits[i]).astype(np.float32).tolist()
- if len(policy) != POLICY_SIZE:
- raise ValueError(f"policy len {len(policy)} != {POLICY_SIZE}")
- results.append({"value": float(value[i]), "policy": policy})
- self.request_count += 1
- if self.request_count % LOG_EVERY_N == 0:
- ms = (time.perf_counter() - t0) * 1000
- print(
- f"sidecar batch={batch} latency_ms={ms:.1f} requests={self.request_count}",
- file=sys.stderr,
- )
- return results
-
-
-def make_handler(session: Session):
- class Handler(BaseHTTPRequestHandler):
- def log_message(self, fmt: str, *args: Any) -> None:
- pass
-
- def do_GET(self) -> None:
- if self.path != "/health":
- self.send_error(404)
- return
- write_json(self, 200, health_payload(session))
-
- def do_POST(self) -> None:
- if self.path != "/v1/eval":
- self.send_error(404)
- return
- handle_eval(self, session)
-
- return Handler
-
-
-def health_payload(session: Session) -> dict[str, Any]:
- return {
- "status": "ok",
- "schema_version": SCHEMA_VERSION,
- "policy_size": POLICY_SIZE,
- "spatial_shape": [8, BOARD_SIZE, BOARD_SIZE],
- "global_shape": [4],
- "model": session.model_path,
- "providers": session.providers,
- }
-
-
-def write_json(handler: BaseHTTPRequestHandler, status: int, payload: dict[str, Any]) -> None:
- body = json.dumps(payload).encode()
- handler.send_response(status)
- handler.send_header("Content-Type", "application/json")
- handler.send_header("Content-Length", str(len(body)))
- handler.end_headers()
- handler.wfile.write(body)
-
-
-def read_json(handler: BaseHTTPRequestHandler) -> dict[str, Any]:
- length = int(handler.headers.get("Content-Length", "0"))
- return json.loads(handler.rfile.read(length))
-
-
-def handle_eval(handler: BaseHTTPRequestHandler, session: Session) -> None:
- try:
- req = read_json(handler)
- if req.get("schema_version") != SCHEMA_VERSION:
- raise ValueError("schema_version mismatch")
- results = session.eval_batch(req["spatial"], req["globals"])
- write_json(handler, 200, {"results": results})
- except Exception as exc:
- write_json(handler, 400, {"error": str(exc)})
-
-
-def main() -> None:
- p = argparse.ArgumentParser(description="Gofer ONNX inference sidecar")
- p.add_argument("--model", required=True, help="path to .onnx model")
- p.add_argument("--port", type=int, default=8080)
- args = p.parse_args()
- model_path = str(Path(args.model))
- session = Session(model_path)
- server = ThreadingHTTPServer(("127.0.0.1", args.port), make_handler(session))
-
- def on_hup(_signum: int, _frame: object) -> None:
- session.reload(model_path)
- print(f"sidecar reloaded model={model_path}", file=sys.stderr)
-
- if hasattr(signal, "SIGHUP"):
- signal.signal(signal.SIGHUP, on_hup)
-
- print(
- f"sidecar listening on http://127.0.0.1:{args.port} model={model_path} providers={session.providers}",
- file=sys.stderr,
- )
- server.serve_forever()
-
-
-if __name__ == "__main__":
- main()
diff --git a/training/loop.py b/training/loop.py
new file mode 100644
index 0000000..85a5d00
--- /dev/null
+++ b/training/loop.py
@@ -0,0 +1,271 @@
+"""AlphaZero-style training loop: self-play -> train -> export -> evaluate.
+
+Every phase writes its outputs before state.json records it as done, so the
+loop can be stopped at any point and resumed with the same command.
+
+ python -m training.loop --run runs/9x9 --gofer bin/gofer --hours 6
+"""
+
+from __future__ import annotations
+
+import argparse
+import dataclasses
+import glob
+import json
+import os
+import re
+import shutil
+import subprocess
+import sys
+import time
+from dataclasses import dataclass, field
+from pathlib import Path
+
+import numpy as np
+import torch
+
+from training import elo
+from training.data import load_shard, load_window
+from training.export import export
+from training.model import GoferNet, ModelConfig, param_count
+from training.train import TrainConfig, cosine_lr, device, evaluate, load_checkpoint, make_optimizer, save_checkpoint, train_steps
+
+
+@dataclass
+class LoopConfig:
+ board_size: int = 9
+ komi: float = 7.5
+ blocks: int = 6
+ channels: int = 64
+ # self-play
+ games_per_gen: int = 600
+ full_visits: int = 160
+ fast_visits: int = 32
+ full_prob: float = 0.25
+ temp_moves: int = 10
+ resign: float = -0.9
+ no_resign_prob: float = 0.1
+ # training
+ window_min: int = 60_000 # replay window grows from here...
+ window_max: int = 400_000 # ...to here
+ window_growth: int = 25_000 # rows added to the window per generation
+ sample_reuse: float = 8.0 # training samples drawn per new self-play row
+ total_gens: int = 30 # horizon for the learning-rate schedule
+ train: TrainConfig = field(default_factory=TrainConfig)
+ # evaluation
+ eval_games: int = 40
+ eval_visits: int = 100
+ anchors: list[str] = field(default_factory=lambda: ["gnugo,level=1", "gnugo,level=6"])
+ anchor_every: int = 2
+ anchor_games: int = 40
+
+ @classmethod
+ def from_dict(cls, d: dict) -> "LoopConfig":
+ d = dict(d)
+ d["train"] = TrainConfig(**d.get("train", {}))
+ return cls(**d)
+
+
+class Run:
+ def __init__(self, root: Path, cfg: LoopConfig, gofer: Path, threads: int) -> None:
+ self.root, self.cfg, self.gofer, self.threads = root, cfg, gofer, threads
+ for d in ("models", "ckpt", "data", "eval", "sgf"):
+ (root / d).mkdir(parents=True, exist_ok=True)
+ self.state_path = root / "state.json"
+ self.state = json.loads(self.state_path.read_text()) if self.state_path.exists() else {"gen": 0, "step": 0, "done": {}}
+ self.env = dict(os.environ)
+ if "ONNXRUNTIME_SHARED_LIBRARY_PATH" not in self.env:
+ self.env["ONNXRUNTIME_SHARED_LIBRARY_PATH"] = find_ort_library()
+ if Path("/usr/games").is_dir(): # Debian/Ubuntu install gnugo there
+ self.env["PATH"] = self.env.get("PATH", "") + ":/usr/games"
+
+ # ---- bookkeeping -------------------------------------------------
+ def log(self, msg: str) -> None:
+ line = f"[{time.strftime('%H:%M:%S')}] {msg}"
+ print(line, flush=True)
+ with open(self.root / "log.txt", "a") as f:
+ f.write(line + "\n")
+
+ def save_state(self) -> None:
+ tmp = self.state_path.with_suffix(".tmp")
+ tmp.write_text(json.dumps(self.state, indent=2))
+ tmp.replace(self.state_path)
+
+ def mark(self, gen: int, phase: str, info: dict | None = None) -> None:
+ self.state["done"].setdefault(str(gen), {})[phase] = info or True
+ self.save_state()
+
+ def is_done(self, gen: int, phase: str) -> bool:
+ return phase in self.state["done"].get(str(gen), {})
+
+ def metric(self, record: dict) -> None:
+ with open(self.root / "metrics.jsonl", "a") as f:
+ f.write(json.dumps(record) + "\n")
+
+ def model_path(self, gen: int) -> Path:
+ return self.root / "models" / f"gen-{gen:04d}.onnx"
+
+ def ckpt_path(self, gen: int) -> Path:
+ return self.root / "ckpt" / f"gen-{gen:04d}.pt"
+
+ def gofer_run(self, args: list[str]) -> None:
+ cmd = [str(self.gofer), *args]
+ self.log("$ " + " ".join(cmd))
+ subprocess.run(cmd, check=True, env=self.env)
+
+ # ---- phases ------------------------------------------------------
+ def init(self) -> None:
+ if self.ckpt_path(0).exists():
+ return
+ torch.manual_seed(0)
+ mc = ModelConfig(board_size=self.cfg.board_size, blocks=self.cfg.blocks, channels=self.cfg.channels)
+ model = GoferNet(mc).to(device())
+ opt = make_optimizer(model, self.cfg.train)
+ save_checkpoint(self.ckpt_path(0), model, opt, 0)
+ export(model, self.model_path(0))
+ self.log(f"initialized {mc.blocks}x{mc.channels} network ({param_count(model):,} parameters)")
+
+ def selfplay(self, gen: int) -> None:
+ if self.is_done(gen, "selfplay"):
+ return
+ c = self.cfg
+ out = self.root / "data" / f"gen-{gen:04d}"
+ if out.exists():
+ shutil.rmtree(out) # partial output from an interrupted run
+ stats = self.root / "eval" / f"selfplay-{gen:04d}.json"
+ self.gofer_run([
+ "selfplay", "-model", str(self.model_path(gen)), "-size", str(c.board_size), "-komi", str(c.komi),
+ "-games", str(c.games_per_gen), "-full-visits", str(c.full_visits), "-fast-visits", str(c.fast_visits),
+ "-full-prob", str(c.full_prob), "-temp-moves", str(c.temp_moves), "-resign", str(c.resign),
+ "-no-resign-prob", str(c.no_resign_prob), "-seed", str(1000 + gen), "-out", str(out),
+ "-prefix", f"gen-{gen:04d}", "-sgf", str(self.root / "sgf" / f"gen-{gen:04d}"), "-stats", str(stats),
+ ])
+ info = json.loads(stats.read_text())
+ self.metric({"gen": gen, "phase": "selfplay", **{k: v for k, v in info.items() if k != "shards"}})
+ self.mark(gen, "selfplay", {"rows": info["rows"], "seconds": info["seconds"]})
+
+ def shards(self, upto: int) -> list[Path]:
+ out: list[Path] = []
+ for g in range(upto + 1):
+ out += sorted(Path(p) for p in glob.glob(str(self.root / "data" / f"gen-{g:04d}" / "*.npz")))
+ return out
+
+ def train(self, gen: int) -> None:
+ if self.is_done(gen, "train"):
+ return
+ c = self.cfg
+ dev = device()
+ model, opt, step = load_checkpoint(self.ckpt_path(gen), c.train, dev)
+ new_shards = self.shards(gen)[len(self.shards(gen - 1)) if gen > 0 else 0 :]
+ new_rows = load_window(new_shards, 10**9) if new_shards else None
+ val = evaluate(model, new_rows) if new_rows is not None else {}
+ window = min(c.window_max, c.window_min + gen * c.window_growth)
+ rows = load_window(self.shards(gen), window)
+ steps = max(1, int(c.sample_reuse * len(new_rows) / c.train.batch_size)) if new_rows is not None else 0
+ lr = cosine_lr(c.train.lr, gen, c.total_gens)
+ self.log(f"train gen {gen}: window {len(rows):,} rows, {steps} steps, lr {lr:.2e}; "
+ f"previous net on new data: policy {val.get('policy', 0):.3f} (target entropy {val.get('policy_entropy', 0):.3f}) value {val.get('value', 0):.3f}")
+ metrics, step = train_steps(model, opt, rows, steps, c.train, lr, step, seed=gen, log=self.log)
+ save_checkpoint(self.ckpt_path(gen + 1), model, opt, step)
+ export(model, self.model_path(gen + 1))
+ self.state["step"] = step
+ self.metric({"gen": gen, "phase": "train", "window": len(rows), "lr": lr, **metrics,
+ **{f"val_{k}": v for k, v in val.items()}})
+ self.mark(gen, "train", {"steps": steps})
+
+ def match(self, a: str, b: str, games: int, visits: int, tag: str) -> dict:
+ out = self.root / "eval" / f"{tag}.json"
+ if not out.exists():
+ self.gofer_run([
+ "arena", "-a", a, "-b", b, "-games", str(games), "-visits", str(visits), "-size", str(self.cfg.board_size),
+ "-komi", str(self.cfg.komi), "-parallel", str(2 * self.threads), "-json", str(out), "-seed", str(len(tag)),
+ ])
+ return json.loads(out.read_text())
+
+ def evaluate(self, gen: int) -> None:
+ if self.is_done(gen, "eval"):
+ return
+ c = self.cfg
+ new, old = gen + 1, gen
+ name = lambda g: f"gen-{g:04d}"
+ results = []
+ r = self.match(f"nn:{self.model_path(new)}", f"nn:{self.model_path(old)}", c.eval_games, c.eval_visits, f"{name(new)}-vs-{name(old)}")
+ results.append({"a": name(new), "b": name(old), "wins": r["wins"], "losses": r["losses"], "draws": r["draws"]})
+ self.log(f"eval: {name(new)} vs {name(old)}: {r['wins']}-{r['losses']}-{r['draws']} elo {r['elo']:+.0f} [{r['elo_ci_low']:+.0f}, {r['elo_ci_high']:+.0f}]")
+ if new % c.anchor_every == 0 or new == 1:
+ for anchor in c.anchors:
+ label = anchor_label(anchor)
+ r = self.match(f"nn:{self.model_path(new)}", anchor, c.anchor_games, c.eval_visits, f"{name(new)}-vs-{label}")
+ results.append({"a": name(new), "b": label, "wins": r["wins"], "losses": r["losses"], "draws": r["draws"]})
+ self.log(f"eval: {name(new)} vs {label}: {r['wins']}-{r['losses']}-{r['draws']} score {r['score']:.2f}")
+ self.state.setdefault("results", []).extend(results)
+ ratings = elo.fit(self.state["results"], anchor="gen-0000")
+ (self.root / "eval" / "ratings.json").write_text(json.dumps(ratings, indent=2, sort_keys=True))
+ self.metric({"gen": new, "phase": "eval", "elo": ratings.get(name(new), {}).get("elo"), "results": results})
+ self.log("ratings: " + ", ".join(f"{p} {v['elo']:+.0f}±{v['stderr']:.0f}" for p, v in sorted(ratings.items(), key=lambda kv: -kv[1]["elo"])[:8]))
+ self.mark(gen, "eval")
+
+ def run(self, hours: float, max_gens: int) -> None:
+ deadline = time.time() + hours * 3600
+ self.init()
+ while self.state["gen"] < max_gens:
+ gen = self.state["gen"]
+ for phase in (self.selfplay, self.train, self.evaluate):
+ if time.time() > deadline:
+ self.log("time budget reached; resume with the same command")
+ return
+ phase(gen)
+ self.state["gen"] = gen + 1
+ self.save_state()
+ self.log(f"finished {max_gens} generations")
+
+
+def anchor_label(spec: str) -> str:
+ """'gnugo,level=6' -> 'gnugo-l6' (options other than level are dropped)."""
+ kind, *opts = spec.split(",")
+ level = next((o.split("=", 1)[1] for o in opts if o.startswith("level=")), None)
+ label = f"{kind}-l{level}" if level else kind
+ return re.sub(r"[^A-Za-z0-9.-]+", "-", label)
+
+
+def find_ort_library() -> str:
+ import onnxruntime
+
+ libs = glob.glob(str(Path(onnxruntime.__file__).parent / "capi" / "libonnxruntime.so*"))
+ libs = [l for l in libs if "providers" not in l]
+ if not libs:
+ raise SystemExit("set ONNXRUNTIME_SHARED_LIBRARY_PATH to libonnxruntime.so")
+ return sorted(libs)[-1]
+
+
+def main() -> None:
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument("--run", type=Path, required=True, help="run directory (created or resumed)")
+ ap.add_argument("--gofer", type=Path, default=Path("bin/gofer"), help="gofer binary built with -tags onnx")
+ ap.add_argument("--hours", type=float, default=1e9, help="stop after this much wall time")
+ ap.add_argument("--gens", type=int, default=10**6, help="stop after this many generations")
+ ap.add_argument("--set", action="append", default=[], metavar="KEY=VALUE", help="override a LoopConfig field (new runs only)")
+ ap.add_argument("--threads", type=int, default=os.cpu_count() or 4)
+ args = ap.parse_args()
+ cfg_path = args.run / "config.json"
+ if cfg_path.exists():
+ cfg = LoopConfig.from_dict(json.loads(cfg_path.read_text()))
+ else:
+ d = dataclasses.asdict(LoopConfig())
+ for kv in args.set:
+ k, v = kv.split("=", 1)
+ target = d["train"] if k.startswith("train.") else d
+ k = k.removeprefix("train.")
+ if k not in target:
+ raise SystemExit(f"unknown config key {k}")
+ cur = target[k]
+ target[k] = json.loads(v) if not isinstance(cur, str) else v
+ cfg = LoopConfig.from_dict(d)
+ args.run.mkdir(parents=True, exist_ok=True)
+ cfg_path.write_text(json.dumps(dataclasses.asdict(cfg), indent=2))
+ torch.set_num_threads(args.threads)
+ Run(args.run, cfg, args.gofer.resolve(), args.threads).run(args.hours, args.gens)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/training/manifest.py b/training/manifest.py
deleted file mode 100644
index 91779e8..0000000
--- a/training/manifest.py
+++ /dev/null
@@ -1,43 +0,0 @@
-"""Training loop manifest for ML pipeline v3."""
-
-from __future__ import annotations
-
-import json
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import Any
-
-MANIFEST_VERSION = 3
-
-
-def default_manifest(*, seed: str = "fresh", best_win_rate: float = 0.0, best_cycle: int = 0) -> dict[str, Any]:
- return {
- "version": MANIFEST_VERSION,
- "cycle": best_cycle,
- "best_win_rate": best_win_rate,
- "best_cycle": best_cycle,
- "replay_rows": 0,
- "last_promoted_at": None,
- "seed": seed,
- }
-
-
-def load_manifest(path: Path) -> dict[str, Any]:
- if not path.exists():
- return default_manifest()
- with path.open(encoding="utf-8") as f:
- data = json.load(f)
- if data.get("version") != MANIFEST_VERSION:
- data["version"] = MANIFEST_VERSION
- return data
-
-
-def save_manifest(path: Path, data: dict[str, Any]) -> None:
- path.parent.mkdir(parents=True, exist_ok=True)
- with path.open("w", encoding="utf-8") as f:
- json.dump(data, f, indent=2)
- f.write("\n")
-
-
-def utc_now() -> str:
- return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
diff --git a/training/model.py b/training/model.py
index 6c2286d..de2f06f 100644
--- a/training/model.py
+++ b/training/model.py
@@ -1,69 +1,109 @@
-"""Bootstrap 9x9 ResNet-small for Gofer v2.5."""
+"""Residual policy/value network with global pooling (KataGo-style heads)."""
from __future__ import annotations
+from dataclasses import asdict, dataclass
+
import torch
import torch.nn as nn
import torch.nn.functional as F
-FEATURE_PLANES = 8
-GLOBALS = 4
-BOARD_SIZE = 9
-POLICY_SIZE = BOARD_SIZE * BOARD_SIZE + 1
+from training.features import NUM_GLOBALS, NUM_PLANES
+
+
+@dataclass
+class ModelConfig:
+ board_size: int = 9
+ blocks: int = 6
+ channels: int = 64
+ head_channels: int = 32
+
+ def to_dict(self) -> dict:
+ return asdict(self)
+
+
+def global_pool(x: torch.Tensor) -> torch.Tensor:
+ """Mean and max over the board: [N, C, H, W] -> [N, 2C]."""
+ return torch.cat([x.mean(dim=(2, 3)), x.amax(dim=(2, 3))], dim=1)
class ResBlock(nn.Module):
- def __init__(self, channels: int) -> None:
+ def __init__(self, c: int) -> None:
+ super().__init__()
+ self.conv1 = nn.Conv2d(c, c, 3, padding=1, bias=False)
+ self.bn1 = nn.BatchNorm2d(c)
+ self.conv2 = nn.Conv2d(c, c, 3, padding=1, bias=False)
+ self.bn2 = nn.BatchNorm2d(c)
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ y = F.relu(self.bn1(self.conv1(x)))
+ y = self.bn2(self.conv2(y))
+ return F.relu(x + y)
+
+
+class GlobalPoolBlock(nn.Module):
+ """Residual block whose second conv is biased by pooled board context,
+ so local decisions can see whole-board state (ko threats, who is ahead)."""
+
+ def __init__(self, c: int) -> None:
super().__init__()
- self.conv1 = nn.Conv2d(channels, channels, 3, padding=1, bias=False)
- self.bn1 = nn.BatchNorm2d(channels)
- self.conv2 = nn.Conv2d(channels, channels, 3, padding=1, bias=False)
- self.bn2 = nn.BatchNorm2d(channels)
+ self.conv1 = nn.Conv2d(c, c, 3, padding=1, bias=False)
+ self.bn1 = nn.BatchNorm2d(c)
+ self.pool_fc = nn.Linear(2 * c, c)
+ self.conv2 = nn.Conv2d(c, c, 3, padding=1, bias=False)
+ self.bn2 = nn.BatchNorm2d(c)
def forward(self, x: torch.Tensor) -> torch.Tensor:
- out = F.relu(self.bn1(self.conv1(x)))
- out = self.bn2(self.conv2(out))
- return F.relu(x + out)
+ y = F.relu(self.bn1(self.conv1(x)))
+ y = y + self.pool_fc(global_pool(y))[:, :, None, None]
+ y = self.bn2(self.conv2(y))
+ return F.relu(x + y)
+
+class GoferNet(nn.Module):
+ """Inputs: spatial [N, NUM_PLANES, S, S], global [N, NUM_GLOBALS].
+ Outputs: policy logits [N, S*S+1], value [N] in [-1, 1] for the side to
+ move, ownership [N, S*S] in [-1, 1] for the side to move."""
-class GoferBootstrapNet(nn.Module):
- # Wider/deeper than the 4x32 bootstrap: 6x64 gives a much higher strength
- # ceiling on 9x9 while staying cheap enough to train per cycle.
- def __init__(self, board_size: int = BOARD_SIZE, channels: int = 64, blocks: int = 6) -> None:
+ def __init__(self, cfg: ModelConfig) -> None:
super().__init__()
- self.board_size = board_size
- self.policy_size = board_size * board_size + 1
- flat = channels * board_size * board_size
-
- self.stem = nn.Sequential(
- nn.Conv2d(FEATURE_PLANES, channels, 3, padding=1, bias=False),
- nn.BatchNorm2d(channels),
- nn.ReLU(inplace=True),
- )
- self.blocks = nn.Sequential(*[ResBlock(channels) for _ in range(blocks)])
- self.global_fc = nn.Sequential(
- nn.Linear(GLOBALS, channels),
- nn.ReLU(inplace=True),
- )
- self.policy_fc = nn.Linear(flat + channels, self.policy_size)
- self.value_fc = nn.Sequential(
- nn.Linear(flat + channels, channels),
- nn.ReLU(inplace=True),
- nn.Linear(channels, 1),
- )
- # Ownership auxiliary head (KataGo-style): predicts per-point final owner
- # in [-1,1]. Shares the trunk, so it sharpens features the value/policy
- # heads reuse; the Go engine ignores this output at inference time.
- self.ownership_conv = nn.Conv2d(channels, 1, 1)
-
- def forward(
- self, spatial_input: torch.Tensor, global_input: torch.Tensor
- ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
- x = self.stem(spatial_input)
- x = self.blocks(x)
- g = self.global_fc(global_input)
- flat = torch.cat([x.reshape(x.size(0), -1), g], dim=1)
- policy_logits = self.policy_fc(flat)
- value = torch.tanh(self.value_fc(flat).squeeze(-1))
- ownership = torch.tanh(self.ownership_conv(x)).reshape(x.size(0), self.board_size * self.board_size)
- return policy_logits, value, ownership
+ self.cfg = cfg
+ c, h = cfg.channels, cfg.head_channels
+ self.stem = nn.Conv2d(NUM_PLANES, c, 3, padding=1, bias=False)
+ self.stem_global = nn.Linear(NUM_GLOBALS, c)
+ self.stem_bn = nn.BatchNorm2d(c)
+ blocks: list[nn.Module] = []
+ for i in range(cfg.blocks):
+ # every third block (starting with the second) gets global pooling
+ blocks.append(GlobalPoolBlock(c) if i % 3 == 1 else ResBlock(c))
+ self.trunk = nn.Sequential(*blocks)
+
+ self.policy_conv = nn.Conv2d(c, h, 1, bias=False)
+ self.policy_bn = nn.BatchNorm2d(h)
+ self.policy_out = nn.Conv2d(h, 1, 1)
+ self.pass_fc = nn.Linear(2 * h, 1)
+
+ self.value_conv = nn.Conv2d(c, h, 1, bias=False)
+ self.value_bn = nn.BatchNorm2d(h)
+ self.value_fc1 = nn.Linear(2 * h, 64)
+ self.value_fc2 = nn.Linear(64, 1)
+ self.ownership_out = nn.Conv2d(h, 1, 1)
+
+ def forward(self, spatial: torch.Tensor, glob: torch.Tensor):
+ x = self.stem(spatial) + self.stem_global(glob)[:, :, None, None]
+ x = F.relu(self.stem_bn(x))
+ x = self.trunk(x)
+
+ p = F.relu(self.policy_bn(self.policy_conv(x)))
+ board_logits = self.policy_out(p).flatten(1)
+ pass_logit = self.pass_fc(global_pool(p))
+ policy = torch.cat([board_logits, pass_logit], dim=1)
+
+ v = F.relu(self.value_bn(self.value_conv(x)))
+ value = torch.tanh(self.value_fc2(F.relu(self.value_fc1(global_pool(v))))).squeeze(1)
+ ownership = torch.tanh(self.ownership_out(v)).flatten(1)
+ return policy, value, ownership
+
+
+def param_count(model: nn.Module) -> int:
+ return sum(p.numel() for p in model.parameters())
diff --git a/training/net_size_ablation.py b/training/net_size_ablation.py
deleted file mode 100644
index ec93b02..0000000
--- a/training/net_size_ablation.py
+++ /dev/null
@@ -1,407 +0,0 @@
-"""Offline net-size ablation on a frozen replay snapshot.
-
-Does not change GoferBootstrapNet defaults or the live training pipeline.
-See docs/decisions/0005-net-size-ablation.md for findings and decision.
-
-Prerequisites
--------------
-- Frozen JSONL at ``training/data/ablation/net-size-replay-snapshot.jsonl``
- (copy of production replay; do not append mid-run). See
- ``training/data/ablation/README.md``.
-- Run from repo root with training on PYTHONPATH and the project venv active::
-
- cd /path/to/Gofer
- source .venv/bin/activate # or .venv311 on Lightsail
- export PYTHONPATH=training
-
-Full ablation (all candidates, ONNX export, latency)
-----------------------------------------------------
-Trains 6x64-baseline, 4x32, 4x48, 6x32 from scratch; writes
-``.tectonix/reports/net-size-ablation/results.json``::
-
- python -u training/net_size_ablation.py \\
- --data training/data/ablation/net-size-replay-snapshot.jsonl \\
- --epochs 25 --out-dir .tectonix/reports/net-size-ablation
-
-Seed variance check (subset of configs, no ONNX export)
--------------------------------------------------------
-Re-trains named configs with multiple init seeds; writes
-``seed-variance.json`` under ``--out-dir``::
-
- python -u training/net_size_ablation.py --variance \\
- --configs 4x48,6x32 --seeds 11,42,99 \\
- --epochs 25 --out-dir .tectonix/reports/net-size-ablation
-
-Config names for ``--configs`` must match keys in ``CONFIG_LOOKUP`` below
-(e.g. ``6x64-baseline``, ``4x48``, ``6x32``, ``4x32``).
-
-Outputs
--------
-- ``manifest.json`` — snapshot path, line count, hyperparameters
-- ``results.json`` — full ablation metrics per candidate
-- ``seed-variance.json`` — per-seed runs + mean/std/min/max summary
-- ``/best.pt`` — checkpoints (offline only; not deployed)
-"""
-
-from __future__ import annotations
-
-import argparse
-import json
-import statistics
-import time
-from dataclasses import asdict, dataclass
-from pathlib import Path
-
-import torch
-import torch.nn.functional as F
-from torch.utils.data import DataLoader, Subset
-
-from dataset import SampleDataset
-from export_onnx import PolicyValueNet
-from model import BOARD_SIZE, GoferBootstrapNet
-from train_bootstrap import OWNERSHIP_LOSS_WEIGHT, SPLIT_SEED, split_indices
-
-NET_CONFIGS: list[tuple[str, int, int]] = [
- ("6x64-baseline", 6, 64),
- ("4x32", 4, 32),
- ("4x48", 4, 48),
- ("6x32", 6, 32),
-]
-
-CONFIG_LOOKUP: dict[str, tuple[int, int]] = {name: (b, c) for name, b, c in NET_CONFIGS}
-
-
-@dataclass
-class AblationResult:
- name: str
- blocks: int
- channels: int
- params: int
- best_val_loss: float
- final_val_loss: float
- epochs_run: int
- onnx_bytes: int
- latency_ms_median: float
- latency_ms_p95: float
-
-
-def count_params(net: torch.nn.Module) -> int:
- return sum(p.numel() for p in net.parameters())
-
-
-def make_net(blocks: int, channels: int) -> GoferBootstrapNet:
- return GoferBootstrapNet(blocks=blocks, channels=channels)
-
-
-def run_epoch(
- net: GoferBootstrapNet,
- loader: DataLoader,
- opt: torch.optim.Optimizer | None,
- *,
- train: bool,
-) -> float:
- if train:
- net.train()
- else:
- net.eval()
- total = 0.0
- n_batches = 0
- ctx = torch.enable_grad() if train else torch.no_grad()
- with ctx:
- for spatial, globals_, policy, value, ownership in loader:
- if train and opt is not None:
- opt.zero_grad()
- logits, pred_v, pred_own = net(spatial, globals_)
- target = policy / policy.sum(dim=1, keepdim=True).clamp(min=1e-8)
- loss_p = -(target * torch.log_softmax(logits, dim=1)).sum(dim=1).mean()
- loss_v = F.mse_loss(pred_v, value)
- loss_own = F.mse_loss(pred_own, ownership)
- loss = loss_p + loss_v + OWNERSHIP_LOSS_WEIGHT * loss_own
- if train and opt is not None:
- loss.backward()
- opt.step()
- total += loss.item()
- n_batches += 1
- return total / max(n_batches, 1)
-
-
-def train_candidate(
- name: str,
- blocks: int,
- channels: int,
- data: Path,
- out_dir: Path,
- *,
- epochs: int,
- lr: float,
- val_split: float,
- init_seed: int | None = None,
-) -> tuple[Path, float, float, int]:
- if init_seed is not None:
- torch.manual_seed(init_seed)
-
- ds = SampleDataset(data)
- train_idx, val_idx = split_indices(len(ds), val_split, SPLIT_SEED)
- gen = torch.Generator().manual_seed(init_seed if init_seed is not None else SPLIT_SEED)
- train_loader = DataLoader(
- Subset(ds, train_idx), batch_size=64, shuffle=True, generator=gen
- )
- val_loader = DataLoader(Subset(ds, val_idx), batch_size=64, shuffle=False) if val_idx else None
-
- net = make_net(blocks, channels)
- opt = torch.optim.SGD(net.parameters(), lr=lr, momentum=0.9)
- out_dir.mkdir(parents=True, exist_ok=True)
- best_path = out_dir / "best.pt"
- best_val = float("inf")
- final_val = float("inf")
-
- for epoch in range(epochs):
- run_epoch(net, train_loader, opt, train=True)
- val_loss = run_epoch(net, val_loader, None, train=False) if val_loader else float("nan")
- final_val = val_loss
- print(f" [{name}] epoch {epoch + 1}/{epochs} val_loss={val_loss:.4f}")
- if val_loss < best_val:
- best_val = val_loss
- torch.save(net.state_dict(), best_path)
-
- if not best_path.exists():
- torch.save(net.state_dict(), best_path)
- return best_path, best_val, final_val, epochs
-
-
-def export_candidate(checkpoint: Path, out_path: Path, blocks: int, channels: int) -> None:
- net = make_net(blocks, channels)
- state = torch.load(checkpoint, map_location="cpu", weights_only=True)
- net.load_state_dict(state)
- net.eval()
- wrapper = PolicyValueNet(net)
- spatial = torch.zeros(1, 8, BOARD_SIZE, BOARD_SIZE)
- global_in = torch.zeros(1, 4)
- out_path.parent.mkdir(parents=True, exist_ok=True)
- torch.onnx.export(
- wrapper,
- (spatial, global_in),
- str(out_path),
- input_names=["spatial_input", "global_input"],
- output_names=["policy_logits", "value"],
- dynamic_axes={
- "spatial_input": {0: "batch"},
- "global_input": {0: "batch"},
- "policy_logits": {0: "batch"},
- "value": {0: "batch"},
- },
- opset_version=18,
- dynamo=False,
- )
-
-
-def benchmark_onnx_latency(onnx_path: Path, *, runs: int = 300) -> tuple[float, float]:
- import numpy as np
- import onnxruntime as ort
-
- session = ort.InferenceSession(str(onnx_path), providers=["CPUExecutionProvider"])
- spatial = np.zeros((1, 8, BOARD_SIZE, BOARD_SIZE), dtype=np.float32)
- global_in = np.zeros((1, 4), dtype=np.float32)
- feeds = {"spatial_input": spatial, "global_input": global_in}
-
- for _ in range(20):
- session.run(None, feeds)
-
- times_ms: list[float] = []
- for _ in range(runs):
- t0 = time.perf_counter()
- session.run(None, feeds)
- times_ms.append((time.perf_counter() - t0) * 1000.0)
-
- times_ms.sort()
- median = statistics.median(times_ms)
- p95 = times_ms[int(0.95 * (len(times_ms) - 1))]
- return median, p95
-
-
-def run_ablation(
- data: Path,
- out_root: Path,
- *,
- epochs: int,
- lr: float,
- val_split: float,
-) -> list[AblationResult]:
- results: list[AblationResult] = []
- for name, blocks, channels in NET_CONFIGS:
- print(f"\n=== training {name} ({blocks}x{channels}) ===")
- cand_dir = out_root / name
- ckpt, best_val, final_val, epochs_run = train_candidate(
- name, blocks, channels, data, cand_dir, epochs=epochs, lr=lr, val_split=val_split
- )
- net = make_net(blocks, channels)
- net.load_state_dict(torch.load(ckpt, map_location="cpu", weights_only=True))
- params = count_params(net)
-
- onnx_path = cand_dir / f"gofer-9x9-{name}.onnx"
- export_candidate(ckpt, onnx_path, blocks, channels)
- median_ms, p95_ms = benchmark_onnx_latency(onnx_path)
-
- results.append(
- AblationResult(
- name=name,
- blocks=blocks,
- channels=channels,
- params=params,
- best_val_loss=best_val,
- final_val_loss=final_val,
- epochs_run=epochs_run,
- onnx_bytes=onnx_path.stat().st_size,
- latency_ms_median=median_ms,
- latency_ms_p95=p95_ms,
- )
- )
- return results
-
-
-def run_seed_variance(
- data: Path,
- out_root: Path,
- config_names: list[str],
- seeds: list[int],
- *,
- epochs: int,
- lr: float,
- val_split: float,
-) -> list[dict]:
- rows: list[dict] = []
- for name in config_names:
- if name not in CONFIG_LOOKUP:
- raise ValueError(f"unknown config {name!r}")
- blocks, channels = CONFIG_LOOKUP[name]
- for seed in seeds:
- run_name = f"{name}/seed-{seed}"
- print(f"\n=== training {run_name} ({blocks}x{channels}) init_seed={seed} ===")
- cand_dir = out_root / name / f"seed-{seed}"
- _, best_val, final_val, epochs_run = train_candidate(
- run_name,
- blocks,
- channels,
- data,
- cand_dir,
- epochs=epochs,
- lr=lr,
- val_split=val_split,
- init_seed=seed,
- )
- rows.append(
- {
- "name": name,
- "blocks": blocks,
- "channels": channels,
- "init_seed": seed,
- "best_val_loss": best_val,
- "final_val_loss": final_val,
- "epochs_run": epochs_run,
- }
- )
- return rows
-
-
-def summarize_variance(rows: list[dict]) -> list[dict]:
- by_name: dict[str, list[float]] = {}
- for row in rows:
- by_name.setdefault(row["name"], []).append(row["best_val_loss"])
- summary: list[dict] = []
- for name, vals in sorted(by_name.items()):
- summary.append(
- {
- "name": name,
- "n": len(vals),
- "mean": statistics.mean(vals),
- "stddev": statistics.pstdev(vals) if len(vals) > 1 else 0.0,
- "min": min(vals),
- "max": max(vals),
- "vals": vals,
- }
- )
- return summary
-
-
-def main() -> None:
- p = argparse.ArgumentParser(description="Net-size ablation on frozen replay snapshot")
- p.add_argument(
- "--data",
- default="training/data/ablation/net-size-replay-snapshot.jsonl",
- help="frozen replay JSONL (must not change mid-run)",
- )
- p.add_argument("--out-dir", default=".tectonix/reports/net-size-ablation")
- p.add_argument("--epochs", type=int, default=25, help="fixed epochs per candidate (no early stop)")
- p.add_argument("--lr", type=float, default=0.01)
- p.add_argument("--val-split", type=float, default=0.1)
- p.add_argument(
- "--variance",
- action="store_true",
- help="train only --configs with --seeds (no ONNX export/latency)",
- )
- p.add_argument(
- "--configs",
- default="4x48,6x32",
- help="comma-separated config names for --variance mode",
- )
- p.add_argument(
- "--seeds",
- default="11,42,99",
- help="comma-separated init seeds for --variance mode",
- )
- args = p.parse_args()
-
- data = Path(args.data)
- if not data.exists():
- raise SystemExit(f"snapshot missing: {data}")
-
- out_root = Path(args.out_dir)
- out_root.mkdir(parents=True, exist_ok=True)
- manifest = {
- "snapshot": str(data.resolve()),
- "snapshot_lines": sum(1 for _ in data.open(encoding="utf-8")),
- "epochs": args.epochs,
- "lr": args.lr,
- "val_split": args.val_split,
- "split_seed": SPLIT_SEED,
- "note": "Frozen pre-Piece-1-cycle replay from Lightsail; do not regenerate mid-ablation.",
- }
- (out_root / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
-
- if args.variance:
- config_names = [s.strip() for s in args.configs.split(",") if s.strip()]
- seeds = [int(s.strip()) for s in args.seeds.split(",") if s.strip()]
- rows = run_seed_variance(
- data, out_root, config_names, seeds,
- epochs=args.epochs, lr=args.lr, val_split=args.val_split,
- )
- summary = summarize_variance(rows)
- payload = {"runs": rows, "summary": summary}
- out_path = out_root / "seed-variance.json"
- out_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
- print("\n=== seed variance summary ===")
- for s in summary:
- spread = s["max"] - s["min"]
- print(
- f"{s['name']}: mean={s['mean']:.4f} std={s['stddev']:.4f} "
- f"min={s['min']:.4f} max={s['max']:.4f} spread={spread:.4f} vals={s['vals']}"
- )
- print(f"\nresults: {out_path}")
- return
-
- results = run_ablation(data, out_root, epochs=args.epochs, lr=args.lr, val_split=args.val_split)
- payload = [asdict(r) for r in results]
- (out_root / "results.json").write_text(json.dumps(payload, indent=2), encoding="utf-8")
-
- print("\n=== ablation summary ===")
- print(f"{'config':<16} {'params':>10} {'best_val':>10} {'onnx_MB':>8} {'lat_ms':>8}")
- for r in results:
- print(
- f"{r.name:<16} {r.params:>10,} {r.best_val_loss:>10.4f} "
- f"{r.onnx_bytes / 1_048_576:>8.2f} {r.latency_ms_median:>8.2f}"
- )
- print(f"\nresults: {out_root / 'results.json'}")
-
-
-if __name__ == "__main__":
- main()
diff --git a/training/parity_harness.py b/training/parity_harness.py
deleted file mode 100644
index 5802b98..0000000
--- a/training/parity_harness.py
+++ /dev/null
@@ -1,92 +0,0 @@
-"""ONNX parity harness — Python reference path (inference_server.Session, no HTTP)."""
-
-from __future__ import annotations
-
-import argparse
-import json
-import sys
-import time
-from pathlib import Path
-
-import onnxruntime as ort
-
-# Reuse sidecar session + softmax so parity matches production inference code.
-from inference_server import Session, BOARD_SIZE, POLICY_SIZE # noqa: E402
-
-
-def load_positions(samples_path: Path, limit: int) -> list[dict]:
- rows: list[dict] = []
- with samples_path.open(encoding="utf-8") as f:
- for line in f:
- line = line.strip()
- if not line:
- continue
- row = json.loads(line)
- if row.get("type") == "header":
- continue
- if "features_spatial" not in row or "features_global" not in row:
- continue
- rows.append(row)
- if len(rows) >= limit:
- break
- return rows
-
-
-def main() -> int:
- p = argparse.ArgumentParser(description="Python ORT reference for Go parity harness")
- p.add_argument("--model", required=True, help="path to policy+value ONNX export")
- p.add_argument("--samples", default="training/data/samples.jsonl")
- p.add_argument("--limit", type=int, default=500)
- p.add_argument("--out", default=".tectonix/reports/parity-ref.jsonl")
- args = p.parse_args()
-
- print(f"onnxruntime python {ort.__version__}", file=sys.stderr)
- if ort.__version__ != "1.26.0":
- print(
- f"warning: parity harness expects onnxruntime==1.26.0 (matches onnxruntime_go v1.31.0); got {ort.__version__}",
- file=sys.stderr,
- )
-
- positions = load_positions(Path(args.samples), args.limit)
- if len(positions) < 100:
- print(f"need at least 100 positions, got {len(positions)}", file=sys.stderr)
- return 1
-
- session = Session(str(Path(args.model)))
- out_path = Path(args.out)
- out_path.parent.mkdir(parents=True, exist_ok=True)
-
- total_ns = 0
- with out_path.open("w", encoding="utf-8") as out:
- for i, row in enumerate(positions):
- spatial = row["features_spatial"]
- globals_ = row["features_global"]
- if len(spatial) != 8 * BOARD_SIZE * BOARD_SIZE:
- print(f"skip row {i}: bad spatial len {len(spatial)}", file=sys.stderr)
- continue
- if len(globals_) != 4:
- print(f"skip row {i}: bad global len {len(globals_)}", file=sys.stderr)
- continue
- t0 = time.perf_counter_ns()
- results = session.eval_batch([spatial], [globals_])
- total_ns += time.perf_counter_ns() - t0
- r = results[0]
- if len(r["policy"]) != POLICY_SIZE:
- print(f"skip row {i}: policy len {len(r['policy'])}", file=sys.stderr)
- continue
- rec = {
- "index": i,
- "board_hash": row.get("board_hash"),
- "value": r["value"],
- "policy": r["policy"],
- }
- out.write(json.dumps(rec) + "\n")
-
- n = len(positions)
- ms_per = (total_ns / n) / 1e6
- print(f"python_ref positions={n} ms_per_pos={ms_per:.4f} out={out_path}", file=sys.stderr)
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/training/replay.py b/training/replay.py
deleted file mode 100644
index d3aa8c1..0000000
--- a/training/replay.py
+++ /dev/null
@@ -1,70 +0,0 @@
-"""FIFO replay buffer for self-play JSONL samples."""
-
-from __future__ import annotations
-
-from pathlib import Path
-
-
-def count_lines(path: Path) -> int:
- if not path.exists():
- return 0
- n = 0
- with path.open(encoding="utf-8") as f:
- for line in f:
- if line.strip():
- n += 1
- return n
-
-
-def _is_header(line: str) -> bool:
- return '"type":"header"' in line.replace(" ", "")
-
-
-def _write_line(fout, line: str) -> None:
- fout.write(line if line.endswith("\n") else line + "\n")
-
-
-def _iter_nonempty(path: Path):
- with path.open(encoding="utf-8") as f:
- for line in f:
- if line.strip():
- yield line
-
-
-def _should_skip_header(line: str, dst_has_content: bool, skip_header: bool) -> bool:
- return skip_header and dst_has_content and _is_header(line)
-
-
-def append_jsonl(src: Path, dst: Path, *, skip_header_if_dst: bool = True) -> int:
- """Stream-copy lines from src into dst. Returns number of lines appended."""
- dst.parent.mkdir(parents=True, exist_ok=True)
- appended = 0
- dst_has_content = dst.exists() and dst.stat().st_size > 0
- mode = "a" if dst_has_content else "w"
- with dst.open(mode, encoding="utf-8") as fout:
- for line in _iter_nonempty(src):
- if _should_skip_header(line, dst_has_content, skip_header_if_dst):
- continue
- if skip_header_if_dst and _is_header(line):
- dst_has_content = True
- _write_line(fout, line)
- appended += 1
- return appended
-
-
-def trim(path: Path, max_lines: int = 50000) -> int:
- """Keep the tail of path (FIFO). Returns lines removed."""
- if not path.exists():
- return 0
- lines = list(_iter_nonempty(path))
- if len(lines) <= max_lines:
- return 0
- removed = len(lines) - max_lines
- header = lines[0] if lines and _is_header(lines[0]) else None
- tail = lines[-max_lines:]
- if header and (not tail or not _is_header(tail[0])):
- body = [ln for ln in tail if not _is_header(ln)]
- tail = [header] + body[-(max_lines - 1) :]
- with path.open("w", encoding="utf-8") as f:
- f.writelines(tail)
- return removed
diff --git a/training/report.py b/training/report.py
new file mode 100644
index 0000000..ce2ec25
--- /dev/null
+++ b/training/report.py
@@ -0,0 +1,111 @@
+"""Summarize a training run: Elo by generation, losses, anchor results.
+
+ python -m training.report runs/9x9 --out docs/results
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+
+
+def load(run: Path):
+ state = json.loads((run / "state.json").read_text())
+ ratings = json.loads((run / "eval" / "ratings.json").read_text())
+ metrics = [json.loads(l) for l in (run / "metrics.jsonl").read_text().splitlines() if l.strip()]
+ return state, ratings, metrics
+
+
+def anchor_table(state: dict) -> dict[str, dict[str, tuple[int, int, int]]]:
+ """{generation: {anchor: (wins, losses, draws)}} for non-generation opponents."""
+ out: dict[str, dict[str, tuple[int, int, int]]] = {}
+ for r in state.get("results", []):
+ if not r["b"].startswith("gen-"):
+ out.setdefault(r["a"], {})[r["b"]] = (r["wins"], r["losses"], r["draws"])
+ return out
+
+
+def plot(ratings: dict, metrics: list[dict], path: Path) -> None:
+ import matplotlib
+
+ matplotlib.use("Agg")
+ import matplotlib.pyplot as plt
+ import matplotlib.ticker
+
+ gens = sorted((int(p.split("-")[1]), v) for p, v in ratings.items() if p.startswith("gen-"))
+ anchors = {p: v for p, v in ratings.items() if not p.startswith("gen-")}
+ train = [m for m in metrics if m.get("phase") == "train"]
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4), dpi=130)
+ x = [g for g, _ in gens]
+ y = [v["elo"] for _, v in gens]
+ e = [v["stderr"] for _, v in gens]
+ ax1.errorbar(x, y, yerr=e, marker="o", ms=4, capsize=2, color="#3b6fd8", label="Gofer generation")
+ ranked = sorted(anchors.items(), key=lambda kv: kv[1]["elo"])
+ for i, (name, v) in enumerate(ranked):
+ ax1.axhline(v["elo"], ls="--", lw=1, color="#888")
+ # put a label under its line when the next anchor sits close above it
+ crowded = i + 1 < len(ranked) and ranked[i + 1][1]["elo"] - v["elo"] < 150
+ ax1.text(0.2, v["elo"], name, va="top" if crowded else "bottom", ha="left", fontsize=8, color="#555")
+ ax1.xaxis.set_major_locator(matplotlib.ticker.MaxNLocator(integer=True))
+ ax2.xaxis.set_major_locator(matplotlib.ticker.MaxNLocator(integer=True))
+ ax1.set_xlabel("generation")
+ ax1.set_ylabel("Elo (gen 0 = 0)")
+ ax1.set_title("Strength by generation")
+ ax1.grid(alpha=0.3)
+ if train:
+ g = [m["gen"] for m in train]
+ ax2.plot(g, [m.get("val_policy", float("nan")) for m in train], marker="o", ms=3, label="policy loss (new data, before training)")
+ ax2.plot(g, [m.get("val_value", float("nan")) for m in train], marker="o", ms=3, label="value loss (new data, before training)")
+ ax2.set_xlabel("generation")
+ ax2.set_title("Held-out losses")
+ ax2.grid(alpha=0.3)
+ ax2.legend(fontsize=8)
+ fig.tight_layout()
+ fig.savefig(path)
+
+
+def markdown(state: dict, ratings: dict, metrics: list[dict]) -> str:
+ sp = {m["gen"]: m for m in metrics if m.get("phase") == "selfplay"}
+ tr = {m["gen"]: m for m in metrics if m.get("phase") == "train"}
+ anchors = anchor_table(state)
+ lines = [
+ "| Gen | Elo | Self-play games/s | Avg moves | Rows | Policy loss* | Value loss* | Anchors (W-L) |",
+ "|----:|----:|------------------:|----------:|-----:|-------------:|------------:|:--------------|",
+ ]
+ for gen in sorted({int(p.split("-")[1]) for p in ratings if p.startswith("gen-")}):
+ name = f"gen-{gen:04d}"
+ r = ratings[name]
+ s = sp.get(gen, {})
+ t = tr.get(gen, {})
+ a = ", ".join(f"{k} {w}-{l}" for k, (w, l, _) in sorted(anchors.get(name, {}).items()))
+ moves = s.get("moves", 0) / max(s.get("games", 1), 1)
+ lines.append(
+ f"| {gen} | {r['elo']:+.0f} ± {r['stderr']:.0f} | {s.get('games_per_second', 0):.2f} | {moves:.0f} | "
+ f"{s.get('rows', 0):,} | {t.get('val_policy', float('nan')):.3f} | {t.get('val_value', float('nan')):.3f} | {a} |"
+ )
+ others = {p: v for p, v in ratings.items() if not p.startswith("gen-")}
+ if others:
+ lines.append("")
+ lines.append("Anchors: " + ", ".join(f"{p} {v['elo']:+.0f} ± {v['stderr']:.0f}" for p, v in sorted(others.items())))
+ lines.append("")
+ lines.append("\\* loss of that generation's network on the self-play data it generated, measured before training on it.")
+ return "\n".join(lines)
+
+
+def main() -> None:
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument("run", type=Path)
+ ap.add_argument("--out", type=Path, default=None, help="directory for elo.png and summary.md")
+ args = ap.parse_args()
+ state, ratings, metrics = load(args.run)
+ md = markdown(state, ratings, metrics)
+ print(md)
+ if args.out:
+ args.out.mkdir(parents=True, exist_ok=True)
+ (args.out / "summary.md").write_text(md + "\n")
+ plot(ratings, metrics, args.out / "elo.png")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/training/requirements-ort-parity.txt b/training/requirements-ort-parity.txt
deleted file mode 100644
index c27f6c2..0000000
--- a/training/requirements-ort-parity.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-# Pinned ORT for Go onnxruntime_go v1.31.0 parity (see scripts/parity-onnx.sh).
-# Production sidecar should use the same major.minor to avoid kernel drift.
-onnxruntime==1.26.0
diff --git a/training/requirements.txt b/training/requirements.txt
index 1a7b5bf..cc9ce1f 100644
--- a/training/requirements.txt
+++ b/training/requirements.txt
@@ -1,5 +1,7 @@
-torch>=2.0
+torch>=2.2
numpy>=1.24
-onnx>=1.14
-onnxruntime==1.26.0
+onnx>=1.15
+onnxruntime>=1.17
onnxscript>=0.1
+pytest>=8
+matplotlib>=3.8 # training.report only
diff --git a/training/test_cycle.py b/training/test_cycle.py
deleted file mode 100644
index 839ba52..0000000
--- a/training/test_cycle.py
+++ /dev/null
@@ -1,104 +0,0 @@
-"""Tests for train-loop v3 cycle helpers."""
-
-from __future__ import annotations
-
-import json
-from pathlib import Path
-
-from training.cycle import append_replay, init_from_cycle2, record_cycle, should_promote
-
-
-def test_should_promote_head_to_head() -> None:
- # Clears the win bar and is significantly above 0.5 -> promote.
- assert should_promote(0.60, 0.53, promote_win=0.55)
- # Wins on average but not significantly (CI low <= 0.5) -> reject noise.
- assert not should_promote(0.56, 0.49, promote_win=0.55)
- # Below the win bar -> reject.
- assert not should_promote(0.52, 0.50, promote_win=0.55)
-
-
-def test_seed_champion_forces_promote(tmp_path: Path, monkeypatch) -> None:
- state = tmp_path / "state"
- data = tmp_path / "data"
- state.mkdir(parents=True)
- data.mkdir(parents=True)
- manifest = state / "manifest.json"
- replay = data / "replay.jsonl"
- manifest.write_text(json.dumps({"version": 3, "cycle": 0, "replay_rows": 0, "seed": "fresh"}), encoding="utf-8")
- replay.write_text("{}\n", encoding="utf-8")
- monkeypatch.setattr("training.cycle.STATE_DIR", state)
- monkeypatch.setattr("training.cycle.DATA_DIR", data)
- monkeypatch.setattr("training.cycle.MANIFEST_PATH", manifest)
- monkeypatch.setattr("training.cycle.REPLAY_PATH", replay)
-
- report = tmp_path / "arena.json"
- # A losing sanity result (0.42 vs heuristic) must still seed the first champion.
- report.write_text(json.dumps({"win_rate_challenger": 0.42, "wilson_ci_low": 0.35, "wilson_ci_high": 0.49}))
- promote, _ = record_cycle(1, report, promote_win=0.55, history_dir=tmp_path / "h", seed_champion=True)
- assert promote
-
-def test_gating_hold_blocks_promote(tmp_path: Path, monkeypatch) -> None:
- state = tmp_path / "state"
- data = tmp_path / "data"
- history = tmp_path / "history"
- manifest = state / "manifest.json"
- replay = data / "replay.jsonl"
- state.mkdir(parents=True)
- data.mkdir(parents=True)
- manifest.write_text(
- json.dumps({"version": 3, "cycle": 1, "best_win_rate": 0.58, "best_cycle": 2, "replay_rows": 1, "seed": "test"}),
- encoding="utf-8",
- )
- replay.write_text('{"type":"header"}\n{}\n', encoding="utf-8")
- monkeypatch.setattr("training.cycle.STATE_DIR", state)
- monkeypatch.setattr("training.cycle.DATA_DIR", data)
- monkeypatch.setattr("training.cycle.MANIFEST_PATH", manifest)
- monkeypatch.setattr("training.cycle.REPLAY_PATH", replay)
-
- report = tmp_path / "arena.json"
- report.write_text(json.dumps({"win_rate_challenger": 0.85, "wilson_ci_low": 0.77, "wilson_ci_high": 0.91}))
- promote, entry = record_cycle(4, report, promote_win=0.55, history_dir=history, gating_mode="hold")
- assert not promote
- assert entry["would_promote"]
- assert entry["gating_hold"]
- saved = json.loads(manifest.read_text(encoding="utf-8"))
- assert saved.get("best_win_rate") == 0.58
-
-
-def test_record_cycle_writes_history(tmp_path: Path, monkeypatch) -> None:
- state = tmp_path / "state"
- data = tmp_path / "data"
- history = tmp_path / "history"
- manifest = state / "manifest.json"
- replay = data / "replay.jsonl"
- state.mkdir(parents=True)
- data.mkdir(parents=True)
- manifest.write_text(
- json.dumps(
- {
- "version": 3,
- "cycle": 1,
- "best_win_rate": 0.58,
- "best_cycle": 2,
- "replay_rows": 1,
- "seed": "test",
- }
- ),
- encoding="utf-8",
- )
- replay.write_text('{"type":"header"}\n{}\n', encoding="utf-8")
-
- monkeypatch.setattr("training.cycle.STATE_DIR", state)
- monkeypatch.setattr("training.cycle.DATA_DIR", data)
- monkeypatch.setattr("training.cycle.MANIFEST_PATH", manifest)
- monkeypatch.setattr("training.cycle.REPLAY_PATH", replay)
-
- report = tmp_path / "arena.json"
- report.write_text(json.dumps({"win_rate_challenger": 0.62, "wilson_ci_low": 0.55, "wilson_ci_high": 0.7}))
-
- promote, entry = record_cycle(3, report, promote_win=0.55, history_dir=history)
- assert promote
- assert entry["win_rate"] == 0.62
- saved = json.loads(manifest.read_text(encoding="utf-8"))
- assert saved["best_win_rate"] == 0.62
- assert (history / "cycle-3.json").exists()
diff --git a/training/test_export.py b/training/test_export.py
deleted file mode 100644
index c5e3630..0000000
--- a/training/test_export.py
+++ /dev/null
@@ -1,67 +0,0 @@
-"""PyTorch vs ONNX Runtime export parity."""
-
-from __future__ import annotations
-
-from pathlib import Path
-
-import numpy as np
-import onnxruntime as ort
-import torch
-
-from export_onnx import export_onnx
-from model import BOARD_SIZE, GoferBootstrapNet
-
-ROOT = Path(__file__).resolve().parent
-
-
-def test_export_ort_matches_pytorch(tmp_path: Path) -> None:
- ckpt = tmp_path / "tiny.pt"
- onnx_path = tmp_path / "model.onnx"
-
- net = GoferBootstrapNet()
- torch.save(net.state_dict(), ckpt)
- export_onnx(onnx_path, ckpt, with_ownership=True)
-
- spatial = torch.zeros(1, 8, BOARD_SIZE, BOARD_SIZE)
- global_in = torch.zeros(1, 4)
- net.eval()
- with torch.no_grad():
- logits_pt, value_pt, own_pt = net(spatial, global_in)
-
- sess = ort.InferenceSession(str(onnx_path), providers=["CPUExecutionProvider"])
- feeds = {
- "spatial_input": spatial.numpy(),
- "global_input": global_in.numpy(),
- }
- logits_ort, value_ort, own_ort = sess.run(None, feeds)
-
- assert np.max(np.abs(logits_pt.numpy() - logits_ort)) < 1e-4
- assert np.max(np.abs(value_pt.numpy() - value_ort)) < 1e-4
- assert np.max(np.abs(own_pt.numpy() - own_ort)) < 1e-4
-
-
-def test_inference_export_omits_ownership(tmp_path: Path) -> None:
- ckpt = tmp_path / "tiny.pt"
- onnx_path = tmp_path / "infer.onnx"
-
- net = GoferBootstrapNet()
- torch.save(net.state_dict(), ckpt)
- export_onnx(onnx_path, ckpt)
-
- spatial = torch.zeros(1, 8, BOARD_SIZE, BOARD_SIZE)
- global_in = torch.zeros(1, 4)
- net.eval()
- with torch.no_grad():
- logits_pt, value_pt, _ = net(spatial, global_in)
-
- sess = ort.InferenceSession(str(onnx_path), providers=["CPUExecutionProvider"])
- feeds = {
- "spatial_input": spatial.numpy(),
- "global_input": global_in.numpy(),
- }
- outputs = sess.run(None, feeds)
- assert len(outputs) == 2
- logits_ort, value_ort = outputs
-
- assert np.max(np.abs(logits_pt.numpy() - logits_ort)) < 1e-4
- assert np.max(np.abs(value_pt.numpy() - value_ort)) < 1e-4
diff --git a/training/test_replay.py b/training/test_replay.py
deleted file mode 100644
index 24fdbdc..0000000
--- a/training/test_replay.py
+++ /dev/null
@@ -1,37 +0,0 @@
-"""Tests for FIFO replay buffer."""
-
-from __future__ import annotations
-
-from pathlib import Path
-
-from replay import append_jsonl, count_lines, trim
-
-FIXTURE = Path(__file__).resolve().parent / "testdata" / "fixture_samples.jsonl"
-
-
-def test_append_and_count(tmp_path: Path) -> None:
- dst = tmp_path / "replay.jsonl"
- n = append_jsonl(FIXTURE, dst)
- assert n > 0
- assert count_lines(dst) == n
-
-
-def test_append_skips_duplicate_header(tmp_path: Path) -> None:
- dst = tmp_path / "replay.jsonl"
- append_jsonl(FIXTURE, dst)
- before = count_lines(dst)
- append_jsonl(FIXTURE, dst)
- after = count_lines(dst)
- assert after > before
- lines = dst.read_text(encoding="utf-8").strip().splitlines()
- headers = [ln for ln in lines if '"type":"header"' in ln.replace(" ", "")]
- assert len(headers) == 1
-
-
-def test_trim_fifo(tmp_path: Path) -> None:
- dst = tmp_path / "replay.jsonl"
- append_jsonl(FIXTURE, dst)
- append_jsonl(FIXTURE, dst)
- removed = trim(dst, 10)
- assert count_lines(dst) <= 10
- assert removed >= 0
diff --git a/training/test_train.py b/training/test_train.py
deleted file mode 100644
index 8b99423..0000000
--- a/training/test_train.py
+++ /dev/null
@@ -1,108 +0,0 @@
-"""Tests for train_bootstrap resume and val-based best.pt selection."""
-
-from __future__ import annotations
-
-import subprocess
-import sys
-from pathlib import Path
-
-import torch
-from torch.utils.data import DataLoader, Subset
-
-from dataset import SampleDataset
-from model import GoferBootstrapNet
-from train_bootstrap import TrainJob, TrainOptions, load_weights, run_epoch, split_indices, train, training_device
-
-ROOT = Path(__file__).resolve().parent
-FIXTURE = ROOT / "testdata" / "fixture_samples.jsonl"
-
-
-def _run_train(out_dir: Path, **kwargs: object) -> Path:
- return train(
- TrainJob(
- data=FIXTURE,
- epochs=int(kwargs.get("epochs", 5)), # type: ignore[arg-type]
- lr=float(kwargs.get("lr", 0.05)), # type: ignore[arg-type]
- out_dir=out_dir,
- options=TrainOptions(
- resume=kwargs.get("resume"), # type: ignore[arg-type]
- init_from=kwargs.get("init_from"), # type: ignore[arg-type]
- val_split=float(kwargs.get("val_split", 0.2)), # type: ignore[arg-type]
- patience=int(kwargs.get("patience", 100)), # type: ignore[arg-type]
- ),
- )
- )
-
-
-def test_best_pt_differs_from_last_when_val_diverges(tmp_path: Path) -> None:
- out = tmp_path / "ckpt"
- best = _run_train(out, epochs=8, lr=0.08, val_split=0.25)
- last = torch.load(out / "last.pt", map_location="cpu", weights_only=False)
- assert best == out / "best.pt"
- assert best.exists() and (out / "last.pt").exists()
- # best should track min val epoch, not necessarily the final epoch
- assert last["epoch"] >= 1
- if last["epoch"] > last.get("best_epoch", 0):
- assert best.read_bytes() != (out / "last.pt").read_bytes() or last["val_loss"] >= torch.load(
- out / "last.pt", map_location="cpu", weights_only=False
- )["val_loss"]
-
-
-def test_resume_continues_from_prior_weights(tmp_path: Path) -> None:
- out = tmp_path / "run"
- _run_train(out, epochs=1, lr=0.01, val_split=0.2)
- last1 = torch.load(out / "last.pt", map_location="cpu", weights_only=False)
- saved = torch.load(out / "best.pt", map_location="cpu", weights_only=True)
-
- net = GoferBootstrapNet()
- device = training_device()
- load_weights(net, out / "best.pt")
- net = net.to(device)
- for key, tensor in saved.items():
- assert torch.allclose(net.state_dict()[key].cpu(), tensor)
-
- ds = SampleDataset(FIXTURE)
- _, val_idx = split_indices(len(ds), 0.2)
- val_loader = DataLoader(Subset(ds, val_idx), batch_size=min(64, len(val_idx)), shuffle=False)
- val_before = run_epoch(net, val_loader, None, train=False, device=device)
- assert abs(val_before - last1["val_loss"]) < 0.05
-
-
-def test_cli_resume_flag(tmp_path: Path) -> None:
- out = tmp_path / "cli"
- out.mkdir()
- subprocess.run(
- [
- sys.executable,
- str(ROOT / "train_bootstrap.py"),
- "--data",
- str(FIXTURE),
- "--epochs",
- "2",
- "--out-dir",
- str(out),
- "--val-split",
- "0.2",
- ],
- check=True,
- cwd=ROOT.parent,
- )
- subprocess.run(
- [
- sys.executable,
- str(ROOT / "train_bootstrap.py"),
- "--data",
- str(FIXTURE),
- "--epochs",
- "2",
- "--out-dir",
- str(out),
- "--resume",
- str(out / "best.pt"),
- "--lr",
- "0.001",
- ],
- check=True,
- cwd=ROOT.parent,
- )
- assert (out / "best.pt").exists()
diff --git a/training/testdata/fixture_samples.jsonl b/training/testdata/fixture_samples.jsonl
deleted file mode 100644
index e57ec7f..0000000
--- a/training/testdata/fixture_samples.jsonl
+++ /dev/null
@@ -1,63 +0,0 @@
-{"git_commit":"(devel)","schema_version":1,"type":"header"}
-{"board_hash":11270766059008532211,"move_num":1,"policy":[0,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685,0.012345685],"policy_opp":[0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215,0.0121951215],"features_spatial":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.0121951215,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,0,-1,-1,-1,-1,-1,-1],"full_search":true}
-{"board_hash":1248075431394674167,"move_num":3,"policy":[0,0,0.030674862,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0,0.018404918,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.018404918,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945],"policy_opp":[0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.036585364,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,0,-1,-1,-1,-1,-1,-1],"full_search":true}
-{"board_hash":17140542872161184093,"move_num":4,"policy":[0,0,0,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0,0.041420106,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.059171584,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317,0.011834317],"policy_opp":[0,0,0.030674862,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0,0.018404918,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.018404918,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945,0.012269945],"features_spatial":[1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.048780486,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,0,-1,-1,-1,-1,-1,-1],"full_search":true}
-{"board_hash":16643336055096365560,"move_num":11,"policy":[0,0,0,0,0.032467525,0.01298701,0.01298701,0.01298701,0.01298701,0,0,0,0.058441542,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0,0,0.019480515,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0,0,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.019480515,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701,0.01298701],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.6363636,0,0,0,0,0,0,0,0.36363634,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[0,1,1,1,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.13414635,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,0,-1,-1,-1,-1,-1,-1],"full_search":true}
-{"board_hash":11162444786737841982,"move_num":13,"policy":[0,0,0,0,0.046357583,0.013245024,0.013245024,0.013245024,0.013245024,0,0,0,0,0.03973507,0.013245024,0.013245024,0.013245024,0.013245024,0,0,0.013245024,0.03973507,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0,0,0,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024,0.013245024],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.125,0,0,0,0,0,0,0,0,0.3125,0,0,0,0,0,0,0.25,0.3125,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[0,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.15853658,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,0,-1,-1,-1,-1,-1,-1],"full_search":true}
-{"board_hash":4414034087230886542,"move_num":20,"policy":[0,0,0,0,0,0,0.014492758,0.014492758,0.014492758,0,0,0,0,0,0,0.014492758,0.014492758,0.014492758,0,0,0,0.021739136,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0,0,0,0.028985515,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0,0,0.05797103,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.03623189,0.028985515,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758,0.014492758],"policy_opp":[0,0,0,0,0,0,0.25,0,0,0,0,0,0,0,0.5,0,0,0,0,0,0,0,0.25,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.24390244,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,0,-1,-1,-1,-1,-1,-1],"full_search":true}
-{"board_hash":17919133993202121264,"move_num":23,"policy":[0,0,0,0,0,0,0,0.030303054,0.015151527,0,0,0,0,0,0,0.06818187,0.015151527,0.015151527,0,0,0,0.015151527,0.037878815,0.030303054,0.015151527,0.015151527,0.015151527,0,0,0,0,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0,0,0,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527,0.015151527],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.3,0,0,0,0,0,0,0,0,0.3,0,0,0,0,0,0,0.2,0.2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.2804878,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,0,-1,-1,-1,-1,-1,-1],"full_search":true}
-{"board_hash":17618235516919906939,"move_num":25,"policy":[0,0,0,0,0,0,0,0.038759675,0.01550387,0,0,0,0,0,0,0,0.038759675,0.01550387,0,0,0,0.01550387,0.038759675,0.038759675,0.038759675,0.01550387,0.01550387,0,0,0,0,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0,0,0,0,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387,0.01550387],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.1875,0,0,0,0,0,0,0,0,0,0.125,0,0,0,0,0,0,0,0.3125,0,0,0,0,0,0.125,0.125,0.125,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.30487806,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,0,-1,-1,-1,-1,-1,-1],"full_search":true}
-{"board_hash":16013244370330403326,"move_num":27,"policy":[0,0,0,0,0,0,0,0,0.040000036,0,0,0,0,0,0,0,0.072000064,0.016000014,0,0,0,0,0.02400002,0.032000028,0.032000028,0.016000014,0.016000014,0,0,0,0,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0,0,0,0,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.5555555,0,0,0,0,0,0,0,0,0,0.22222221,0,0,0,0,0,0,0,0,0.22222221,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.3292683,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,0,-1,-1,-1,-1,-1,-1],"full_search":true}
-{"board_hash":17744156081290351846,"move_num":29,"policy":[0,0,0,0,0,0,0,0,0.04132234,0,0,0,0,0,0,0,0,0.04132234,0,0,0,0,0.016528934,0.04132234,0.04132234,0.04132234,0.016528934,0,0,0,0,0,0.016528934,0.016528934,0.016528934,0.016528934,0,0,0,0,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.1875,0,0,0,0,0,0,0,0,0.1875,0,0,0,0,0.1875,0.1875,0.125,0.125,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.35365853,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,0,-1,-1,-1,-1,-1,-1],"full_search":true}
-{"board_hash":15505348657862205518,"move_num":34,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.017699111,0,0.017699111,0.017699111,0,0,0,0,0,0.035398223,0.017699111,0.017699111,0.017699111,0,0,0,0,0,0.035398223,0.017699111,0.017699111,0.017699111,0.044247776,0.044247776,0.044247776,0.035398223,0.035398223,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111,0.017699111],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.4,0.3,0.3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.41463414,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,0,-1,-1,-1,-1,-1,-1],"full_search":true}
-{"board_hash":14242209896629629519,"move_num":38,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.01904762,0,0,0,0,0,0,0,0,0.03809524,0.01904762,0.01904762,0.01904762,0,0,0,0,0,0.03809524,0.01904762,0.01904762,0.01904762,0,0,0.06666667,0.03809524,0.03809524,0.01904762,0.01904762,0.01904762,0.01904762,0.03809524,0.03809524,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.25,0,0,0.4166667,0,0,0,0,0,0,0.16666667,0.16666667,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.46341464,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,0,-1,-1,-1,-1,-1,-1],"full_search":true}
-{"board_hash":15078576723990390401,"move_num":43,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.05434784,0,0.076086976,0.05434784,0,0,0,0,0,0.021739136,0.05434784,0.021739136,0.021739136,0,0,0,0,0,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136,0.021739136],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.375,0,0,0,0,0,0.25,0,0,0,0,0,0,0,0,0,0,0,0,0.375,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.5243902,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,0,-1,-1,-1,-1,-1,-1],"full_search":true}
-{"board_hash":17581770928261758579,"move_num":50,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.060975637,0,0.024390254,0,0,0,0,0,0,0,0.024390254,0.024390254,0.04878051,0.04878051,0.04878051,0.04878051,0.060975637,0.04878051,0.04878051,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.5,0.5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.6097561,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,0,-1,-1,-1,-1,-1,-1],"full_search":true}
-{"board_hash":16288000110505445584,"move_num":51,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.14666675,0,0,0,0,0,0,0,0.05333336,0.05333336,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668,0.02666668],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.060975637,0,0.024390254,0,0,0,0,0,0,0,0.024390254,0.024390254,0.04878051,0.04878051,0.04878051,0.04878051,0.060975637,0.04878051,0.04878051,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254,0.024390254],"features_spatial":[0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.6219512,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,0,-1,-1,-1,-1,-1,-1],"full_search":true}
-{"board_hash":4752378336322670967,"move_num":55,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.10769231,0,0,0,0.03076923,0.03076923,0.03076923,0.03076923,0.03076923,0.046153847,0.07692308,0.03076923,0.03076923,0.03076923,0.03076923,0.03076923,0.03076923,0.03076923,0.03076923,0.03076923,0.03076923,0.03076923,0.03076923,0.03076923,0.03076923,0.03076923,0.03076923,0.03076923,0.03076923,0.06153846],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.625,0.375,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.6707317,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,0,-1,-1,-1,-1,-1,-1],"full_search":true}
-{"board_hash":4415340439353113643,"move_num":58,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.109375,0.0625,0.0625,0.046875,0,0.03125,0.0625,0.0625,0.0625,0.03125,0.03125,0.03125,0.03125,0.03125,0.03125,0.03125,0.03125,0.03125,0.03125,0.03125,0.03125,0.03125,0.03125,0.03125,0.03125],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.54545456,0.45454547,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.70731705,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,0,-1,-1,-1,-1,-1,-1],"full_search":true}
-{"board_hash":5749600867365699624,"move_num":62,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.08928574,0,0,0,0.07142859,0.08928574,0.10714289,0.07142859,0.07142859,0.035714295,0.035714295,0.035714295,0.035714295,0.035714295,0.035714295,0.035714295,0.035714295,0.035714295,0.035714295,0.035714295,0.035714295,0.035714295,0.035714295],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.33333334,0,0,0,0,0,0,0,0,0,0.33333334,0.33333334,0,0,0,0,0,0,0,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.75609756,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,0,-1,-1,-1,-1,-1,-1],"full_search":true}
-{"board_hash":8730107139168345334,"move_num":64,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.07692305,0,0,0,0.07692305,0.13461535,0,0.11538458,0.07692305,0.038461525,0.038461525,0,0.038461525,0.038461525,0.038461525,0.05769229,0.038461525,0.038461525,0.038461525,0.038461525,0.038461525,0.038461525,0.038461525],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.30769232,0.3846154,0.30769232,0,0,0,0,0,0,0,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,1,0,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.7804878,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,0,-1,-1,-1,-1,-1,-1],"full_search":true}
-{"board_hash":11394994723248814102,"move_num":77,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.1,0.1,0.1,0.6,0,0,0,0,0.1],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.2777778,0.22222222,0.22222222,0.2777778,0,0,0,0,0,0],"features_spatial":[0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0],"features_global":[0.65,0.9390244,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,0,-1,-1,-1,-1,-1,-1],"full_search":true}
-{"board_hash":8074832345443787597,"move_num":5,"policy":[0,0,0,0.04790415,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0,0.023952074,0.04191613,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0.3,0,0,0,0,0,0,0,0.7,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.06097561,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,-1,-1,-1,-1,-1,-1,1,1,1,1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,0,-1,-1,0,-1],"full_search":true}
-{"board_hash":14460793205253096372,"move_num":6,"policy":[0,0,0,0,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0,0.03636366,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0,0.03636366,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.04242427,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122,0.01212122],"policy_opp":[0,0,0,0.04790415,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0,0.023952074,0.04191613,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037,0.011976037],"features_spatial":[1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.07317073,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,-1,-1,-1,-1,-1,-1,1,1,1,1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,0,-1,-1,0,-1],"full_search":true}
-{"board_hash":6930698351014705321,"move_num":11,"policy":[0,0,0,0,0,0,0.03184717,0.012738869,0.012738869,0,0.012738869,0.03184717,0.03184717,0.03184717,0.03184717,0.012738869,0.012738869,0.012738869,0,0,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0,0,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869,0.012738869],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0.33333337,0,0,0,0,0,0,0,0,0,0.22222224,0,0,0,0,0,0,0,0.44444448,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.13414635,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,-1,-1,-1,-1,-1,-1,1,1,1,1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,0,-1,-1,0,-1],"full_search":true}
-{"board_hash":5071633008538567174,"move_num":18,"policy":[0,0,0,0,0,0,0,0.013986024,0.013986024,0,0,0.03496506,0,0,0,0.013986024,0.013986024,0.013986024,0,0,0,0.020979036,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0,0,0,0.027972048,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.03496506,0.03496506,0.03496506,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024,0.013986024],"policy_opp":[0,0,0,0,0,0,0,0.19999999,0,0,0,0.29999998,0,0,0.5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,1,0,0,0,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.2195122,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,-1,-1,-1,-1,-1,-1,1,1,1,1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,0,-1,-1,0,-1],"full_search":true}
-{"board_hash":9494868878277330980,"move_num":21,"policy":[0,0,0,0,0,0,0,0.036496334,0.014598534,0,0,0,0,0,0,0,0.036496334,0.014598534,0,0,0,0.021897802,0.036496334,0.029197069,0.036496334,0.014598534,0.014598534,0,0,0,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534,0.014598534],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.23076922,0,0,0,0,0,0.3076923,0.23076922,0.23076922,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[0,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.25609756,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,-1,-1,-1,-1,-1,-1,1,1,1,1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,0,-1,-1,0,-1],"full_search":true}
-{"board_hash":11693945809048647145,"move_num":30,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.041666687,0,0.016666675,0.016666675,0.016666675,0.016666675,0,0,0,0,0.025000013,0.016666675,0.016666675,0.016666675,0.016666675,0,0,0,0,0.03333335,0.016666675,0.016666675,0.016666675,0.016666675,0.041666687,0.041666687,0.03333335,0.03333335,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675,0.016666675],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.3,0.3,0.2,0.2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.36585367,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,-1,-1,-1,-1,-1,-1,1,1,1,1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,0,-1,-1,0,-1],"full_search":true}
-{"board_hash":15673718160687223722,"move_num":35,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.07407411,0.04629632,0,0,0,0,0,0.037037056,0.04629632,0.018518528,0.018518528,0,0,0,0,0,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.5,0,0,0,0,0,0,0,0,0,0.25,0,0,0,0.25,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[0,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.42682928,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,-1,-1,-1,-1,-1,-1,1,1,1,1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,0,-1,-1,0,-1],"full_search":true}
-{"board_hash":10755903017706602365,"move_num":36,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.018518526,0,0,0,0,0,0.027777787,0.018518526,0.018518526,0.018518526,0,0,0,0,0,0.037037052,0.018518526,0.018518526,0.018518526,0.046296313,0.046296313,0.037037052,0.037037052,0.046296313,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526,0.018518526],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.07407411,0.04629632,0,0,0,0,0,0.037037056,0.04629632,0.018518528,0.018518528,0,0,0,0,0,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528,0.018518528],"features_spatial":[1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.4390244,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,-1,-1,-1,-1,-1,-1,1,1,1,1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,0,-1,-1,0,-1],"full_search":true}
-{"board_hash":8055982198461318300,"move_num":38,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.02857143,0.01904762,0.01904762,0.01904762,0,0,0,0,0,0.03809524,0.01904762,0.01904762,0.01904762,0,0.07619048,0.03809524,0.03809524,0.03809524,0.01904762,0.01904762,0.01904762,0.01904762,0.03809524,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.5,0,0,0,0,0,0,0.33333334,0.16666667,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.46341464,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,-1,-1,-1,-1,-1,-1,1,1,1,1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,0,-1,-1,0,-1],"full_search":true}
-{"board_hash":14631163411782975915,"move_num":39,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.040000014,0.06000002,0.06000002,0.06000002,0,0,0,0,0,0.020000007,0.020000007,0.020000007,0.020000007,0,0,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007,0.020000007],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.02857143,0.01904762,0.01904762,0.01904762,0,0,0,0,0,0.03809524,0.01904762,0.01904762,0.01904762,0,0.07619048,0.03809524,0.03809524,0.03809524,0.01904762,0.01904762,0.01904762,0.01904762,0.03809524,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762,0.01904762],"features_spatial":[0,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.47560975,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,-1,-1,-1,-1,-1,-1,1,1,1,1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,0,-1,-1,0,-1],"full_search":true}
-{"board_hash":8205038753563944138,"move_num":42,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.020833341,0.020833341,0,0,0,0,0,0.020833341,0.020833341,0.020833341,0.020833341,0,0,0,0.06250002,0.041666683,0.020833341,0.020833341,0.020833341,0.020833341,0.041666683,0.041666683,0.03125001,0.020833341,0.020833341,0.020833341,0.020833341,0.020833341,0.020833341,0.020833341,0.020833341,0.020833341,0.020833341,0.0729167,0.020833341,0.020833341,0.020833341,0.020833341,0.020833341,0.020833341,0.020833341,0.020833341,0.020833341,0.020833341,0.020833341,0.020833341,0.020833341,0.020833341],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.5,0,0.5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,1,1,1,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.5121951,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,-1,-1,-1,-1,-1,-1,1,1,1,1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,0,-1,-1,0,-1],"full_search":true}
-{"board_hash":15329356573785080287,"move_num":44,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.022727279,0,0,0,0,0,0.022727279,0.022727279,0.022727279,0.022727279,0,0,0,0.068181835,0.034090918,0.022727279,0.022727279,0.022727279,0.022727279,0.034090918,0.034090918,0.034090918,0.022727279,0.034090918,0.022727279,0.022727279,0.022727279,0.022727279,0.022727279,0.022727279,0.022727279,0.034090918,0,0.034090918,0.022727279,0.022727279,0.022727279,0.022727279,0.022727279,0.022727279,0.022727279,0.034090918,0.022727279,0.022727279,0.022727279,0.022727279,0.022727279],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.5384615,0.23076923,0,0,0,0,0,0,0.23076923,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,1,1,1,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.5365854,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,-1,-1,-1,-1,-1,-1,1,1,1,1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,0,-1,-1,0,-1],"full_search":true}
-{"board_hash":8918292926964096299,"move_num":46,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.023809513,0.023809513,0.023809513,0.023809513,0,0,0,0,0.07142854,0.023809513,0.023809513,0.023809513,0.023809513,0.03571427,0.03571427,0.03571427,0.03571427,0.03571427,0.023809513,0.023809513,0.023809513,0.023809513,0.023809513,0.023809513,0.023809513,0.03571427,0,0.03571427,0.023809513,0.023809513,0.023809513,0.023809513,0.023809513,0.023809513,0.023809513,0.03571427,0.023809513,0.023809513,0.023809513,0.023809513,0.023809513],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.5833333,0,0,0,0,0,0,0.25,0.16666666,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.5609756,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,-1,-1,-1,-1,-1,-1,1,1,1,1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,0,-1,-1,0,-1],"full_search":true}
-{"board_hash":6389428013060237704,"move_num":50,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.026666677,0,0,0.026666677,0,0,0,0,0,0.040000014,0.026666677,0.026666677,0.026666677,0.053333353,0.040000014,0.040000014,0.06666669,0,0.040000014,0.026666677,0.026666677,0.026666677,0.026666677,0.026666677,0.026666677,0.040000014,0,0.040000014,0.026666677,0.026666677,0.026666677,0.026666677,0.026666677,0.026666677,0.026666677,0.026666677,0.026666677,0.026666677,0.026666677,0.026666677,0.026666677],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.28571427,0,0.35714284,0.2142857,0,0,0,0,0,0,0.14285713,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.6097561,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,-1,-1,-1,-1,-1,-1,1,1,1,1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,0,-1,-1,0,-1],"full_search":true}
-{"board_hash":10858256061374618806,"move_num":54,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.028985515,0.028985515,0.028985515,0.028985515,0,0.086956546,0.07246378,0,0,0.043478273,0.028985515,0.028985515,0.028985515,0.043478273,0.028985515,0.028985515,0.07246378,0,0.043478273,0.028985515,0.028985515,0.028985515,0.028985515,0.028985515,0.028985515,0.028985515,0.028985515,0.028985515,0.028985515,0.028985515,0.028985515,0.028985515],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.5555556,0,0,0,0,0,0,0.22222222,0.22222222,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,0,0,1,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,1,1,0,0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.6585366,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,-1,-1,-1,-1,-1,-1,1,1,1,1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,0,-1,-1,0,-1],"full_search":true}
-{"board_hash":4755177234305730330,"move_num":55,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0588235,0.08823525,0.08823525,0.08823525,0,0,0.02941175,0,0,0.02941175,0.02941175,0.02941175,0.02941175,0.02941175,0.02941175,0.02941175,0.02941175,0,0.02941175,0.02941175,0.02941175,0.02941175,0.02941175,0.02941175,0.02941175,0.02941175,0.02941175,0.02941175,0.02941175,0.02941175,0.02941175,0.02941175],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.028985515,0.028985515,0.028985515,0.028985515,0,0.086956546,0.07246378,0,0,0.043478273,0.028985515,0.028985515,0.028985515,0.043478273,0.028985515,0.028985515,0.07246378,0,0.043478273,0.028985515,0.028985515,0.028985515,0.028985515,0.028985515,0.028985515,0.028985515,0.028985515,0.028985515,0.028985515,0.028985515,0.028985515,0.028985515],"features_spatial":[0,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,0,1,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,1,0,0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.6707317,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,-1,-1,-1,-1,-1,-1,1,1,1,1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,0,-1,-1,0,-1],"full_search":true}
-{"board_hash":18080805504822509818,"move_num":57,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.109375,0,0.109375,0.0625,0,0,0,0,0,0.03125,0.0625,0.03125,0.03125,0.03125,0.03125,0.03125,0.03125,0,0.03125,0.03125,0.03125,0.03125,0.03125,0.03125,0.03125,0.03125,0.03125,0.03125,0.03125,0.03125,0.03125,0.03125],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.41666666,0,0,0.16666666,0,0,0,0,0,0,0.41666666,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[0,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,1,0,0,0,0,0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.69512194,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,-1,-1,-1,-1,-1,-1,1,1,1,1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,0,-1,-1,0,-1],"full_search":true}
-{"board_hash":14709356400307687242,"move_num":62,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.052631587,0.035087723,0,0.035087723,0.07017545,0.08771931,0.12280703,0,0,0.07017545,0.035087723,0.07017545,0.035087723,0.035087723,0.035087723,0.035087723,0.052631587,0.052631587,0.035087723,0.035087723,0.035087723,0.035087723,0.035087723],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.7777778,0,0,0,0,0,0,0.22222224,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,1,1,1,1,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.75609756,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,-1,-1,-1,-1,-1,-1,1,1,1,1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,0,-1,-1,0,-1],"full_search":true}
-{"board_hash":15472939218192496590,"move_num":67,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.11904762,0,0,0,0,0,0.11904762,0.14285715,0.04761905,0.04761905,0.04761905,0.0952381,0.04761905,0.04761905,0.04761905,0.04761905,0.04761905,0.04761905,0.04761905,0.04761905],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.375,0,0,0,0,0,0,0,0,0,0,0.25,0.375,0,0,0,0,0,0],"features_spatial":[0,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,1,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.81707317,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,-1,-1,-1,-1,-1,-1,1,1,1,1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,0,-1,-1,0,-1],"full_search":true}
-{"board_hash":13823422066908674705,"move_num":70,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.052631587,0,0,0,0,0,0,0,0.052631587,0.052631587,0.18421054,0,0.18421054,0.10526317,0.10526317,0.052631587,0.052631587,0.052631587,0.052631587,0.052631587],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.5555555,0,0,0,0,0,0,0,0,0,0.44444442,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,1,0,1,1,1,1,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,1,0,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.85365856,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,-1,-1,-1,-1,-1,-1,1,1,1,1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,0,-1,-1,0,-1],"full_search":true}
-{"board_hash":13169632084119351254,"move_num":77,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.16,0.32,0,0,0,0,0.08,0,0,0.28,0.08,0.08],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.2,0,0,0,0,0.6,0.2,0,0,0,0,0],"features_spatial":[0,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,0,1,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,1,0,1,1,1,1,1,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],"features_global":[0.65,0.9390244,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,1,-1,-1,-1,-1,-1,-1,1,1,1,1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,1,-1,-1,1,-1,1,1,1,1,0,-1,-1,0,-1],"full_search":true}
-{"board_hash":3618369591883816338,"move_num":19,"policy":[0,0,0,0,0,0,0.042857103,0.0142857,0.0142857,0,0,0,0,0,0,0.042857103,0.0142857,0.0142857,0,0,0,0.0142857,0.035714254,0.035714254,0.0142857,0.0142857,0.0142857,0,0,0,0,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857,0.0142857],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.2857143,0,0,0,0,0,0.2857143,0.21428573,0.21428573,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[0,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.23170732,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,-1,0],"full_search":true}
-{"board_hash":3920036442564126178,"move_num":24,"policy":[0,0,0,0,0,0,0,0.0150375925,0.0150375925,0,0,0,0,0,0,0,0.0150375925,0.0150375925,0,0,0,0,0.045112778,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0,0,0,0,0,0.030075185,0.0150375925,0.0150375925,0.0150375925,0.030075185,0.037593983,0.030075185,0.030075185,0.030075185,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925,0.0150375925],"policy_opp":[0,0,0,0,0,0,0,0.25,0,0,0,0,0,0,0,0,0.25,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.5,0,0,0,0,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0],"features_global":[0.65,0.29268292,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,-1,0],"full_search":true}
-{"board_hash":5959569964861735798,"move_num":26,"policy":[0,0,0,0,0,0,0,0,0.015625,0,0,0,0,0,0,0,0.015625,0.015625,0,0,0,0,0,0.0234375,0.015625,0.015625,0.015625,0,0,0,0,0,0.03125,0.015625,0.015625,0.015625,0.0390625,0.0390625,0.03125,0.03125,0.0390625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625],"policy_opp":[0,0,0,0,0,0,0,0.36363637,0,0,0,0,0,0,0,0,0.27272728,0,0,0,0,0,0,0,0.18181819,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.18181819,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.31707317,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,-1,0],"full_search":true}
-{"board_hash":224461979580636765,"move_num":27,"policy":[0,0,0,0,0,0,0,0,0.031496074,0,0,0,0,0,0,0,0.06299215,0.015748037,0,0,0,0,0,0.023622056,0.031496074,0.015748037,0.015748037,0,0,0,0,0,0.015748037,0.015748037,0.015748037,0.015748037,0,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.031496074,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.031496074,0,0.031496074,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037],"policy_opp":[0,0,0,0,0,0,0,0,0.015625,0,0,0,0,0,0,0,0.015625,0.015625,0,0,0,0,0,0.0234375,0.015625,0.015625,0.015625,0,0,0,0,0,0.03125,0.015625,0.015625,0.015625,0.0390625,0.0390625,0.03125,0.03125,0.0390625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625,0.015625],"features_spatial":[0,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.3292683,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,-1,0],"full_search":true}
-{"board_hash":3052289592767805889,"move_num":28,"policy":[0,0,0,0,0,0,0,0,0.016000014,0,0,0,0,0,0,0,0,0.016000014,0,0,0,0,0,0.02400002,0.016000014,0.016000014,0.016000014,0,0,0,0,0,0.032000028,0.016000014,0.016000014,0.016000014,0,0.064000055,0.032000028,0.032000028,0.032000028,0.016000014,0.016000014,0.016000014,0.016000014,0.032000028,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014],"policy_opp":[0,0,0,0,0,0,0,0,0.031496074,0,0,0,0,0,0,0,0.06299215,0.015748037,0,0,0,0,0,0.023622056,0.031496074,0.015748037,0.015748037,0,0,0,0,0,0.015748037,0.015748037,0.015748037,0.015748037,0,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.031496074,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.031496074,0,0.031496074,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037,0.015748037],"features_spatial":[1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.34146342,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,-1,0],"full_search":true}
-{"board_hash":17947046212886598228,"move_num":29,"policy":[0,0,0,0,0,0,0,0,0.04065043,0,0,0,0,0,0,0,0,0.032520343,0,0,0,0,0,0.024390258,0.032520343,0.032520343,0.016260171,0,0,0,0,0,0.016260171,0.016260171,0.016260171,0.016260171,0,0,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.032520343,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.04065043,0,0.032520343,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171],"policy_opp":[0,0,0,0,0,0,0,0,0.016000014,0,0,0,0,0,0,0,0,0.016000014,0,0,0,0,0,0.02400002,0.016000014,0.016000014,0.016000014,0,0,0,0,0,0.032000028,0.016000014,0.016000014,0.016000014,0,0.064000055,0.032000028,0.032000028,0.032000028,0.016000014,0.016000014,0.016000014,0.016000014,0.032000028,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014,0.016000014],"features_spatial":[0,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.35365853,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,-1,0],"full_search":true}
-{"board_hash":7439335733104023902,"move_num":30,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.016528934,0,0,0,0,0,0.024793401,0.016528934,0.016528934,0.016528934,0,0,0,0,0,0.03305787,0.016528934,0.016528934,0.016528934,0,0,0.049586803,0.04132234,0.03305787,0.016528934,0.016528934,0.016528934,0.016528934,0.03305787,0.04132234,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934],"policy_opp":[0,0,0,0,0,0,0,0,0.04065043,0,0,0,0,0,0,0,0,0.032520343,0,0,0,0,0,0.024390258,0.032520343,0.032520343,0.016260171,0,0,0,0,0,0.016260171,0.016260171,0.016260171,0.016260171,0,0,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.032520343,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.04065043,0,0.032520343,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171,0.016260171],"features_spatial":[1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.36585367,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,-1,0],"full_search":true}
-{"board_hash":15855493238593815055,"move_num":31,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.058823533,0,0,0,0,0,0.025210086,0.033613447,0.033613447,0.016806724,0,0,0,0,0,0.016806724,0.016806724,0.016806724,0.016806724,0,0,0,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.033613447,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.033613447,0,0.042016808,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724,0.016806724],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.016528934,0,0,0,0,0,0.024793401,0.016528934,0.016528934,0.016528934,0,0,0,0,0,0.03305787,0.016528934,0.016528934,0.016528934,0,0,0.049586803,0.04132234,0.03305787,0.016528934,0.016528934,0.016528934,0.016528934,0.03305787,0.04132234,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934,0.016528934],"features_spatial":[0,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.37804878,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,-1,0],"full_search":true}
-{"board_hash":10951442479963130483,"move_num":38,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.02020201,0,0,0,0,0,0,0,0,0.030303016,0.02020201,0.02020201,0.02020201,0,0,0,0,0,0.030303016,0.02020201,0.030303016,0.02020201,0.030303016,0.030303016,0.030303016,0.030303016,0.030303016,0.02020201,0.030303016,0,0.030303016,0.02020201,0.02020201,0.02020201,0.02020201,0.02020201,0.02020201,0.02020201,0.030303016,0.02020201,0.02020201,0.02020201,0.02020201,0.02020201,0.02020201,0.02020201,0.02020201,0.02020201,0.02020201,0.02020201,0.02020201,0,0.02020201,0.02020201,0.02020201,0.02020201,0.02020201,0.02020201,0.02020201],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.36363637,0,0,0.45454547,0,0,0,0,0,0,0.18181819,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.46341464,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,-1,0],"full_search":true}
-{"board_hash":5445828193761825335,"move_num":40,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.021052618,0.021052618,0.021052618,0,0,0,0,0,0.052631542,0.021052618,0.03157893,0.021052618,0.03157893,0.03157893,0.03157893,0.03157893,0.03157893,0.021052618,0.03157893,0,0.03157893,0.021052618,0.021052618,0.021052618,0.021052618,0.021052618,0.021052618,0.021052618,0.021052618,0.021052618,0.021052618,0.021052618,0.021052618,0.021052618,0.021052618,0.021052618,0.021052618,0.021052618,0.021052618,0.021052618,0.021052618,0,0.021052618,0.021052618,0.021052618,0.021052618,0.021052618,0.021052618,0.021052618],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.5555556,0,0,0,0,0,0,0,0,0,0,0.22222224,0.22222224,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.4878049,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,-1,0],"full_search":true}
-{"board_hash":11017040397588797821,"move_num":45,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.067415744,0,0,0,0,0,0,0,0,0,0.022471916,0.05617979,0.022471916,0.022471916,0.022471916,0.022471916,0.022471916,0.022471916,0,0,0.022471916,0.022471916,0.022471916,0.022471916,0.022471916,0.022471916,0.022471916,0.022471916,0.022471916,0.022471916,0.022471916,0.022471916,0.05617979,0.022471916,0.022471916,0.022471916,0.022471916,0.022471916,0.022471916,0.022471916,0.04494383,0,0.05617979,0.022471916,0.022471916,0.022471916,0.022471916,0.022471916,0.022471916],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.16666667,0,0,0,0,0,0,0,0,0,0.33333334,0,0,0,0,0,0,0,0.5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[0,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.5487805,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,-1,0],"full_search":true}
-{"board_hash":16833147790530866624,"move_num":53,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.027397256,0.027397256,0,0,0,0,0,0,0.054794513,0.027397256,0.027397256,0.027397256,0.027397256,0.027397256,0.027397256,0.027397256,0.027397256,0.027397256,0.027397256,0.06849314,0.06849314,0.027397256,0.027397256,0.027397256,0.027397256,0.027397256,0.027397256,0.08219177,0,0,0.06849314,0.027397256,0.027397256,0.027397256,0.027397256,0.027397256,0.027397256],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.33333334,0,0.6666667,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_spatial":[0,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.64634144,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,-1,0],"full_search":true}
-{"board_hash":8671656805783558742,"move_num":56,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.1,0,0,0,0,0,0,0,0.028571429,0.028571429,0.057142857,0.057142857,0.057142857,0.057142857,0.057142857,0.057142857,0.042857144,0.028571429,0,0.028571429,0.028571429,0.028571429,0.028571429,0.028571429,0.028571429,0.028571429,0.028571429,0,0,0,0.028571429,0.028571429,0.028571429,0.028571429,0.028571429,0.028571429,0.028571429],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.25,0.25,0.25,0,0,0,0,0,0,0,0,0,0.25,0,0,0,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.68292683,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,-1,0],"full_search":true}
-{"board_hash":11960083049360124393,"move_num":57,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.046153847,0.06153846,0.03076923,0.03076923,0.03076923,0.03076923,0.03076923,0.03076923,0.03076923,0.03076923,0,0.12307692,0.07692308,0.03076923,0.03076923,0.03076923,0.03076923,0.03076923,0.03076923,0,0,0,0.07692308,0.03076923,0.03076923,0.03076923,0.03076923,0.03076923,0.03076923],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.1,0,0,0,0,0,0,0,0.028571429,0.028571429,0.057142857,0.057142857,0.057142857,0.057142857,0.057142857,0.057142857,0.042857144,0.028571429,0,0.028571429,0.028571429,0.028571429,0.028571429,0.028571429,0.028571429,0.028571429,0.028571429,0,0,0,0.028571429,0.028571429,0.028571429,0.028571429,0.028571429,0.028571429,0.028571429],"features_spatial":[0,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.69512194,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,-1,0],"full_search":true}
-{"board_hash":16728415660369175333,"move_num":66,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.063829795,0,0,0,0,0.17021278,0.10638299,0.08510639,0.10638299,0.042553194,0,0,0,0,0.042553194,0.042553194,0.042553194,0.042553194,0.042553194,0,0,0,0,0,0.042553194,0.042553194,0.042553194,0.042553194,0.042553194],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.2857143,0,0,0,0,0,0,0,0,0,0,0,0,0,0.2857143,0,0,0,0,0,0,0,0,0.42857146,0,0,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0],"features_global":[0.65,0.80487806,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,-1,0],"full_search":true}
-{"board_hash":10348204372337157898,"move_num":67,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.11363635,0,0,0,0,0,0.045454543,0.045454543,0.045454543,0.045454543,0,0,0,0,0.20454544,0.045454543,0.045454543,0.045454543,0.045454543,0,0,0,0,0,0.13636363,0.045454543,0.045454543,0.045454543,0.045454543],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.063829795,0,0,0,0,0.17021278,0.10638299,0.08510639,0.10638299,0.042553194,0,0,0,0,0.042553194,0.042553194,0.042553194,0.042553194,0.042553194,0,0,0,0,0,0.042553194,0.042553194,0.042553194,0.042553194,0.042553194],"features_spatial":[0,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.81707317,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,-1,0],"full_search":true}
-{"board_hash":17079955496743466980,"move_num":72,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.09375,0,0,0,0,0,0,0,0.25,0.0625,0,0,0,0,0,0,0.125,0.0625,0.0625,0,0,0,0,0,0,0.15625,0.0625,0.0625,0.0625],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.6666667,0.33333334,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0],"features_global":[0.65,0.8780488,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,-1,0],"full_search":true}
-{"board_hash":13146271094165590439,"move_num":73,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.15625,0,0,0,0,0,0,0,0,0.0625,0,0,0,0,0,0,0.1875,0.0625,0.125,0,0,0,0,0,0,0.21875,0.0625,0.0625,0.0625],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.09375,0,0,0,0,0,0,0,0.25,0.0625,0,0,0,0,0,0,0.125,0.0625,0.0625,0,0,0,0,0,0,0.15625,0.0625,0.0625,0.0625],"features_spatial":[0,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.8902439,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,-1,0],"full_search":true}
-{"board_hash":17934187545818247909,"move_num":74,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.10344829,0,0,0,0,0,0,0,0,0.24137934,0,0,0,0,0,0,0.068965524,0.31034485,0.068965524,0,0,0,0,0,0,0,0.068965524,0.068965524,0.068965524],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.15625,0,0,0,0,0,0,0,0,0.0625,0,0,0,0,0,0,0.1875,0.0625,0.125,0,0,0,0,0,0,0.21875,0.0625,0.0625,0.0625],"features_spatial":[1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0],"features_global":[0.65,0.902439,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,-1,0],"full_search":true}
-{"board_hash":6085888670644699313,"move_num":76,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.11538461,0,0,0,0,0,0,0,0,0.3076923,0,0,0,0,0,0,0,0,0.3076923,0,0,0,0,0,0,0,0.11538461,0.07692307,0.07692307],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.5,0,0.5,0,0,0,0,0,0,0,0,0,0],"features_spatial":[1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.9268293,1,0],"to_play":1,"value":-1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,-1,0],"full_search":true}
-{"board_hash":2738138609425902476,"move_num":77,"policy":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.13043477,0,0,0,0,0,0,0,0.6086956,0.17391303,0.086956516],"policy_opp":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.11538461,0,0,0,0,0,0,0,0,0.3076923,0,0,0,0,0,0,0,0,0.3076923,0,0,0,0,0,0,0,0.11538461,0.07692307,0.07692307],"features_spatial":[0,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"features_global":[0.65,0.9390244,0,1],"to_play":2,"value":1,"komi":6.5,"ownership":[1,-1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,1,1,1,-1,-1,-1,-1,1,1,1,1,1,1,-1,-1,-1,1,1,1,1,1,1,1,-1,-1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,-1,-1,-1,-1,-1,-1,-1,1,1,-1,-1,-1,-1,-1,-1,-1,-1,0],"full_search":true}
diff --git a/training/tests/__init__.py b/training/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/training/tests/fixtures/shard.npz b/training/tests/fixtures/shard.npz
new file mode 100644
index 0000000..388ff8e
Binary files /dev/null and b/training/tests/fixtures/shard.npz differ
diff --git a/training/tests/test_training.py b/training/tests/test_training.py
new file mode 100644
index 0000000..212520b
--- /dev/null
+++ b/training/tests/test_training.py
@@ -0,0 +1,98 @@
+"""Tests for the training package. Run from the repo root: python -m pytest training"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import numpy as np
+import pytest
+import torch
+
+from training import elo
+from training.data import Sampler, load_shard, load_window, symmetrize
+from training.export import export
+from training.features import NUM_GLOBALS, NUM_PLANES
+from training.model import GoferNet, ModelConfig
+from training.train import TrainConfig, evaluate, make_optimizer, train_steps
+
+FIXTURE = Path(__file__).parent / "fixtures" / "shard.npz"
+
+
+def test_shard_written_by_go_engine_loads():
+ rows = load_shard(FIXTURE)
+ assert rows.spatial.dtype == np.uint8 and rows.spatial.shape[1:] == (NUM_PLANES, 9, 9)
+ assert rows.glob.shape[1] == NUM_GLOBALS
+ np.testing.assert_allclose(rows.policy.sum(1), 1, atol=1e-5)
+ assert set(np.unique(rows.value)) <= {-1.0, 0.0, 1.0}
+ # on-board plane is all ones
+ assert (rows.spatial[:, 2] == 1).all()
+
+
+@pytest.mark.parametrize("sym", range(8))
+def test_symmetry_moves_stones_and_policy_together(sym):
+ s = 9
+ spatial = torch.zeros(1, NUM_PLANES, s, s)
+ policy = torch.zeros(1, s * s + 1)
+ own = torch.zeros(1, s * s)
+ y, x = 1, 6
+ spatial[0, 0, y, x] = 1
+ policy[0, y * s + x] = 1
+ own[0, y * s + x] = 1
+ sp, pol, ow = symmetrize(spatial, policy, own, sym)
+ stone = int(sp[0, 0].flatten().argmax())
+ assert int(pol[0, :-1].argmax()) == stone == int(ow[0].argmax())
+ assert pol.sum() == 1
+
+
+def test_symmetries_are_distinct():
+ s = 9
+ spatial = torch.arange(s * s, dtype=torch.float32).reshape(1, 1, s, s).repeat(1, NUM_PLANES, 1, 1)
+ seen = set()
+ for sym in range(8):
+ sp, _, _ = symmetrize(spatial, torch.zeros(1, s * s + 1), torch.zeros(1, s * s), sym)
+ seen.add(tuple(sp[0, 0].flatten().tolist()))
+ assert len(seen) == 8
+
+
+def test_model_shapes_and_training_reduces_loss():
+ torch.manual_seed(0)
+ rows = load_window([FIXTURE], 10_000)
+ model = GoferNet(ModelConfig(blocks=2, channels=16, head_channels=8))
+ cfg = TrainConfig(batch_size=32, lr=3e-3, warmup_steps=1)
+ opt = make_optimizer(model, cfg)
+ before = evaluate(model, rows)
+ train_steps(model, opt, rows, 150, cfg, cfg.lr, 0, seed=0, log=lambda *_: None)
+ after = evaluate(model, rows)
+ assert after["policy"] < before["policy"] and after["value"] < before["value"]
+ p, v, o = model(torch.zeros(3, NUM_PLANES, 9, 9), torch.zeros(3, NUM_GLOBALS))
+ assert p.shape == (3, 82) and v.shape == (3,) and o.shape == (3, 81)
+
+
+def test_export_matches_pytorch(tmp_path):
+ model = GoferNet(ModelConfig(blocks=2, channels=16, head_channels=8))
+ # Non-trivial BatchNorm statistics catch exports traced in training mode.
+ model.train()
+ with torch.no_grad():
+ model(torch.rand(64, NUM_PLANES, 9, 9) * 3, torch.rand(64, NUM_GLOBALS))
+ export(model, tmp_path / "m.onnx") # raises if ONNX and PyTorch disagree
+ assert not model.training
+
+
+def test_sampler_batches():
+ rows = load_shard(FIXTURE)
+ batch = Sampler(rows, 16, q_weight=0.5, seed=1).batch(torch.device("cpu"))
+ spatial, glob, policy, value, own, own_w = batch
+ assert spatial.shape == (16, NUM_PLANES, 9, 9) and policy.shape == (16, 82)
+ torch.testing.assert_close(policy.sum(1), torch.ones(16))
+
+
+def test_elo_fit_recovers_ordering():
+ results = [
+ {"a": "b", "b": "a", "wins": 30, "losses": 10, "draws": 0},
+ {"a": "c", "b": "b", "wins": 30, "losses": 10, "draws": 0},
+ ]
+ r = elo.fit(results, anchor="a")
+ assert r["a"]["elo"] == 0
+ assert 0 < r["b"]["elo"] < r["c"]["elo"]
+ # 75% score is about +190 Elo
+ assert 140 < r["b"]["elo"] < 230
diff --git a/training/train.py b/training/train.py
new file mode 100644
index 0000000..7aec60c
--- /dev/null
+++ b/training/train.py
@@ -0,0 +1,147 @@
+"""Optimization: losses, training steps and checkpoints."""
+
+from __future__ import annotations
+
+import math
+import time
+from dataclasses import dataclass
+from pathlib import Path
+
+import torch
+import torch.nn.functional as F
+
+from training.data import Rows, Sampler
+from training.model import GoferNet, ModelConfig
+
+
+@dataclass
+class TrainConfig:
+ batch_size: int = 256
+ lr: float = 2e-3
+ weight_decay: float = 1e-4
+ value_weight: float = 1.0
+ ownership_weight: float = 0.15
+ q_weight: float = 0.0 # blend of root search value into the value target
+ grad_clip: float = 5.0
+ warmup_steps: int = 200
+
+
+def device() -> torch.device:
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
+
+
+def losses(model: GoferNet, batch) -> dict[str, torch.Tensor]:
+ spatial, glob, policy, value, own, own_w = batch
+ logits, v, o = model(spatial, glob)
+ p_loss = -(policy * F.log_softmax(logits, dim=1)).sum(1).mean()
+ # entropy of the target: the floor of the policy loss (reported, not optimized)
+ p_entropy = -(policy * torch.log(policy.clamp_min(1e-12))).sum(1).mean()
+ v_loss = F.mse_loss(v, value)
+ o_loss = ((o - own) ** 2).mean(1)
+ o_loss = (o_loss * own_w).sum() / own_w.sum().clamp_min(1.0)
+ return {"policy": p_loss, "policy_entropy": p_entropy, "value": v_loss, "ownership": o_loss}
+
+
+def make_optimizer(model: GoferNet, cfg: TrainConfig) -> torch.optim.Optimizer:
+ decay, no_decay = [], []
+ for name, p in model.named_parameters():
+ (no_decay if p.ndim == 1 or "bn" in name else decay).append(p)
+ return torch.optim.AdamW(
+ [{"params": decay, "weight_decay": cfg.weight_decay}, {"params": no_decay, "weight_decay": 0.0}],
+ lr=cfg.lr,
+ )
+
+
+def train_steps(
+ model: GoferNet,
+ opt: torch.optim.Optimizer,
+ rows: Rows,
+ steps: int,
+ cfg: TrainConfig,
+ lr: float,
+ global_step: int,
+ seed: int,
+ log=print,
+) -> tuple[dict[str, float], int]:
+ """Run `steps` minibatch updates; returns mean metrics and the new global step."""
+ dev = next(model.parameters()).device
+ sampler = Sampler(rows, cfg.batch_size, cfg.q_weight, seed)
+ use_amp = dev.type == "cuda"
+ scaler = torch.amp.GradScaler("cuda", enabled=use_amp)
+ model.train()
+ sums: dict[str, float] = {}
+ t0 = time.time()
+ for i in range(steps):
+ warm = min(1.0, (global_step + 1) / max(cfg.warmup_steps, 1))
+ for g in opt.param_groups:
+ g["lr"] = lr * warm
+ batch = sampler.batch(dev)
+ with torch.autocast(dev.type, enabled=use_amp):
+ parts = losses(model, batch)
+ loss = parts["policy"] + cfg.value_weight * parts["value"] + cfg.ownership_weight * parts["ownership"]
+ opt.zero_grad(set_to_none=True)
+ scaler.scale(loss).backward()
+ scaler.unscale_(opt)
+ torch.nn.utils.clip_grad_norm_(model.parameters(), cfg.grad_clip)
+ scaler.step(opt)
+ scaler.update()
+ global_step += 1
+ for k, v in parts.items():
+ sums[k] = sums.get(k, 0.0) + v.item()
+ if (i + 1) % 500 == 0:
+ log(f" step {i + 1}/{steps} policy {sums['policy'] / (i + 1):.3f} value {sums['value'] / (i + 1):.3f}")
+ metrics = {k: v / max(steps, 1) for k, v in sums.items()}
+ metrics["steps"] = steps
+ metrics["seconds"] = time.time() - t0
+ metrics["samples_per_second"] = steps * cfg.batch_size / max(metrics["seconds"], 1e-9)
+ return metrics, global_step
+
+
+@torch.no_grad()
+def evaluate(model: GoferNet, rows: Rows, batch_size: int = 1024) -> dict[str, float]:
+ """Loss on held-out rows (no augmentation)."""
+ dev = next(model.parameters()).device
+ model.eval()
+ sums: dict[str, float] = {}
+ n = len(rows)
+ for s in range(0, n, batch_size):
+ sl = slice(s, min(n, s + batch_size))
+ batch = (
+ torch.from_numpy(rows.spatial[sl].astype("float32")).to(dev),
+ torch.from_numpy(rows.glob[sl]).to(dev),
+ torch.from_numpy(rows.policy[sl]).to(dev),
+ torch.from_numpy(rows.value[sl]).to(dev),
+ torch.from_numpy(rows.ownership[sl].astype("float32")).to(dev),
+ torch.from_numpy(rows.ownership_weight[sl]).to(dev),
+ )
+ parts = losses(model, batch)
+ w = sl.stop - sl.start
+ for k, v in parts.items():
+ sums[k] = sums.get(k, 0.0) + v.item() * w
+ return {k: v / max(n, 1) for k, v in sums.items()}
+
+
+def save_checkpoint(path: Path, model: GoferNet, opt: torch.optim.Optimizer, step: int, extra: dict | None = None) -> None:
+ tmp = path.with_suffix(".tmp")
+ torch.save(
+ {"model_config": model.cfg.to_dict(), "model": model.state_dict(), "optimizer": opt.state_dict(), "step": step, **(extra or {})},
+ tmp,
+ )
+ tmp.replace(path)
+
+
+def load_checkpoint(path: Path, cfg: TrainConfig, dev: torch.device) -> tuple[GoferNet, torch.optim.Optimizer, int]:
+ ck = torch.load(path, map_location=dev, weights_only=False)
+ model = GoferNet(ModelConfig(**ck["model_config"])).to(dev)
+ model.load_state_dict(ck["model"])
+ opt = make_optimizer(model, cfg)
+ opt.load_state_dict(ck["optimizer"])
+ return model, opt, int(ck["step"])
+
+
+def cosine_lr(base: float, gen: int, total_gens: int, floor: float = 0.1) -> float:
+ """Per-generation learning rate: cosine from base down to floor*base."""
+ if total_gens <= 1:
+ return base
+ t = min(gen / (total_gens - 1), 1.0)
+ return base * (floor + (1 - floor) * 0.5 * (1 + math.cos(math.pi * t)))
diff --git a/training/train_bootstrap.py b/training/train_bootstrap.py
deleted file mode 100644
index 9509d9e..0000000
--- a/training/train_bootstrap.py
+++ /dev/null
@@ -1,213 +0,0 @@
-"""Train bootstrap 9x9 net from self-play JSONL."""
-
-from __future__ import annotations
-
-import argparse
-import random
-from contextlib import nullcontext
-from dataclasses import dataclass
-from pathlib import Path
-
-import torch
-import torch.nn.functional as F
-from torch.utils.data import DataLoader, Subset
-
-from dataset import SampleDataset
-from model import GoferBootstrapNet
-
-SPLIT_SEED = 42
-# Ownership is an auxiliary regularizer, not the objective; keep its weight modest.
-OWNERSHIP_LOSS_WEIGHT = 0.15
-
-
-def training_device() -> torch.device:
- """CUDA-compatible device when available (ROCm reports via torch.cuda)."""
- if torch.cuda.is_available():
- return torch.device("cuda")
- return torch.device("cpu")
-
-
-@dataclass
-class TrainOptions:
- resume: Path | None = None
- init_from: Path | None = None
- val_split: float = 0.1
- patience: int = 5
-
-
-@dataclass
-class TrainJob:
- data: Path
- epochs: int
- lr: float
- out_dir: Path
- options: TrainOptions
-
-
-def split_indices(n: int, val_split: float, seed: int = SPLIT_SEED) -> tuple[list[int], list[int]]:
- idx = list(range(n))
- rng = random.Random(seed)
- rng.shuffle(idx)
- if n < 2:
- return idx, []
- n_val = max(1, int(round(n * val_split)))
- n_val = min(n_val, n - 1)
- return idx[n_val:], idx[:n_val]
-
-
-def load_weights(net: GoferBootstrapNet, path: Path) -> None:
- state = torch.load(path, map_location="cpu", weights_only=True)
- if isinstance(state, dict) and "state_dict" in state:
- state = state["state_dict"]
- net.load_state_dict(state)
-
-
-def make_loaders(ds: SampleDataset, val_split: float) -> tuple[DataLoader, DataLoader | None]:
- train_idx, val_idx = split_indices(len(ds), val_split)
- train_loader = DataLoader(Subset(ds, train_idx), batch_size=min(64, len(train_idx)), shuffle=True)
- if not val_idx:
- return train_loader, None
- val_loader = DataLoader(Subset(ds, val_idx), batch_size=min(64, len(val_idx)), shuffle=False)
- return train_loader, val_loader
-
-
-def make_net(options: TrainOptions, device: torch.device) -> GoferBootstrapNet:
- net = GoferBootstrapNet()
- if options.resume and options.resume.exists():
- load_weights(net, options.resume)
- elif options.init_from and options.init_from.exists():
- load_weights(net, options.init_from)
- return net.to(device)
-
-
-def save_last(path: Path, state: dict) -> None:
- torch.save(state, path)
-
-
-def run_epoch(
- net: GoferBootstrapNet,
- loader: DataLoader,
- opt: torch.optim.Optimizer | None,
- *,
- train: bool,
- device: torch.device,
-) -> float:
- if train:
- net.train()
- else:
- net.eval()
- total = 0.0
- n_batches = 0
- grad_ctx = nullcontext() if train else torch.no_grad()
- with grad_ctx:
- for spatial, globals_, policy, value, ownership in loader:
- spatial = spatial.to(device)
- globals_ = globals_.to(device)
- policy = policy.to(device)
- value = value.to(device)
- ownership = ownership.to(device)
- if train and opt is not None:
- opt.zero_grad()
- logits, pred_v, pred_own = net(spatial, globals_)
- target = policy / policy.sum(dim=1, keepdim=True).clamp(min=1e-8)
- loss_p = -(target * torch.log_softmax(logits, dim=1)).sum(dim=1).mean()
- loss_v = F.mse_loss(pred_v, value)
- loss_own = F.mse_loss(pred_own, ownership)
- loss = loss_p + loss_v + OWNERSHIP_LOSS_WEIGHT * loss_own
- if train and opt is not None:
- loss.backward()
- opt.step()
- total += loss.item()
- n_batches += 1
- return total / max(n_batches, 1)
-
-
-def train(job: TrainJob) -> Path:
- device = training_device()
- print(f"device: {device}" + (f" ({torch.cuda.get_device_name(0)})" if device.type == "cuda" else ""))
-
- ds = SampleDataset(job.data)
- train_loader, val_loader = make_loaders(ds, job.options.val_split)
- net = make_net(job.options, device)
-
- opt = torch.optim.SGD(net.parameters(), lr=job.lr, momentum=0.9)
- job.out_dir.mkdir(parents=True, exist_ok=True)
- best_path = job.out_dir / "best.pt"
- last_path = job.out_dir / "last.pt"
-
- best_val = float("inf")
- best_epoch = 0
- stale = 0
-
- for epoch in range(job.epochs):
- train_loss = run_epoch(net, train_loader, opt, train=True, device=device)
- val_loss = (
- run_epoch(net, val_loader, None, train=False, device=device)
- if val_loader
- else train_loss
- )
- print(
- f"epoch {epoch + 1}/{job.epochs} train_loss={train_loss:.4f} val_loss={val_loss:.4f}"
- )
-
- save_last(
- last_path,
- {
- "state_dict": net.state_dict(),
- "epoch": epoch + 1,
- "train_loss": train_loss,
- "val_loss": val_loss,
- },
- )
-
- if val_loss < best_val:
- best_val = val_loss
- best_epoch = epoch + 1
- stale = 0
- torch.save(net.state_dict(), best_path)
- else:
- stale += 1
- if stale >= job.options.patience:
- print(f"early stop at epoch {epoch + 1} (patience={job.options.patience})")
- break
-
- if not best_path.exists():
- torch.save(net.state_dict(), best_path)
-
- print(f"best.pt epoch={best_epoch} val_loss={best_val:.4f}")
- return best_path
-
-
-def main() -> None:
- p = argparse.ArgumentParser()
- p.add_argument("--data", default="training/data/samples.jsonl")
- p.add_argument("--epochs", type=int, default=25)
- p.add_argument("--lr", type=float, default=0.01)
- p.add_argument("--out-dir", default="training/checkpoints")
- p.add_argument("--resume", default="", help="load weights from checkpoint if exists")
- p.add_argument("--init-from", default="", help="one-time seed weights (e.g. cycle2)")
- p.add_argument("--val-split", type=float, default=0.1)
- p.add_argument("--patience", type=int, default=5)
- args = p.parse_args()
-
- resume = Path(args.resume) if args.resume else None
- init_from = Path(args.init_from) if args.init_from else None
- best = train(
- TrainJob(
- data=Path(args.data),
- epochs=args.epochs,
- lr=args.lr,
- out_dir=Path(args.out_dir),
- options=TrainOptions(
- resume=resume,
- init_from=init_from,
- val_split=args.val_split,
- patience=args.patience,
- ),
- )
- )
- print(f"checkpoint: {best}")
-
-
-if __name__ == "__main__":
- main()
diff --git a/web/index.html b/web/index.html
index 39e7e4a..0c9418a 100644
--- a/web/index.html
+++ b/web/index.html
@@ -4,10 +4,10 @@
Gofer — a Go engine that teaches itself
-
+
-
+
@@ -15,7 +15,7 @@
-
+
@@ -23,7 +23,7 @@
@@ -127,14 +127,14 @@
Gofer
- v2.7.1
+ v3.0
@@ -152,7 +152,7 @@
The Loop
Terminal
Benchmarks
- Decisions
+ Paper trail
Quickstart
GitHub ↗
@@ -169,13 +169,13 @@
MCTS · GTP · ONNX · self-play
- Four thousand years of Go. Six thousand lines of Go.
+ Four thousand years of Go. Four thousand lines of Go.
- Gofer is a complete Go engine — Chinese rules, PUCT Monte-Carlo search, GTP, and a
- self-play loop where every new network must beat the reigning champion
- to earn promotion. No hand-waving: Wilson confidence intervals, bit-exact ONNX parity,
- and a benchmark gate in CI.
+ Gofer is an AlphaZero-style Go engine — a bitset board, PUCT search, batched ONNX
+ inference and GTP, in Go — plus a PyTorch loop that taught it 9×9 Go from random play.
+ 3.6 hours on four CPU cores later, it beats GNU Go 66–34.
+ Every number on this page is measured: Wilson intervals, an Elo ladder, GNU Go as the yardstick.
@@ -185,7 +185,7 @@ Quickstart →
-
make build → bin/gofer · the heuristic engine needs nothing but Go
+
scripts/setup.sh → bin/gofer · rules, search, GTP and arena build in pure Go
@@ -238,7 +238,7 @@
- The “ear-reddening” game — Honinbo Shusaku vs Inoue Gennan Inseki, 1846 . One of 95,894 pro games in Gofer’s warm-start corpus.
+ The “ear-reddening” game — Honinbo Shusaku vs Inoue Gennan Inseki, 1846 . Gofer itself never saw a human game.
@@ -248,24 +248,24 @@
-
3.52ns
-
to apply a stone
-
0 allocs/op · BenchmarkApplyStone
+
66–34
+
against GNU Go 3.8, level 10
+
100 games · 400 visits · Elo +115 [+44, +187]
-
7 allocs
-
per legal-move gen — was 1,158
-
scratch buffers, undo over clone
+
3.6h
+
from random play, on 4 vCPUs
+
10,200 self-play games · no GPU · no human data
-
1,721
-
positions bit-exact vs PyTorch
-
Go ORT ↔ Python parity harness
+
20–0
+
against the v2 engine
+
at a quarter of its search budget
-
45,243
-
positions in the replay buffer
-
training cycle 7 · runs on a 2 GiB box
+
490k
+
playouts per second of pure search
+
4 vCPUs · ~4 µs per playout · was ~520 µs
@@ -274,44 +274,44 @@
Inside the engine
Everything a Go engine needs. Nothing it doesn't.
- One binary plays, analyzes, referees, generates training data, and gates its own successors. The design choices are written down — every major one has an ADR.
+ One binary plays, analyzes, referees and generates training data; Python only trains. The design is written down in docs/architecture.md .
Rules that hold up
-
Chinese area scoring and Tromp-Taylor, positional superko, suicide ban, SGF import and export. Scoring symmetry and arena-bias invariants are tested, not assumed.
-
chinese_scoring_test.go · score_symmetry_test.go
+
Chains and exact liberties kept incrementally in bitsets, simple ko, positional superko from per-chain Zobrist hashes, Tromp-Taylor area scoring. Differential-tested against a naive reference on random games from 5×5 to 19×19.
+
internal/game · 0.7 µs per move incl. legal moves
-
PUCT search
-
Monte-Carlo tree search with policy priors, a transposition table, tree reuse between moves, root-parallel playouts — plus forced root playouts and policy-target pruning from the KataGo paper.
-
AdvanceTree · playout-cap randomization
+
PUCT search, done right
+
One documented sign convention, virtual-loss leaf batching, Dirichlet root noise, tree reuse, exact scoring of finished games, and a pass rule that keeps an untrained net from ending games at random.
+
internal/mcts · regression test for every v2 defect
-
Two brains
-
A hand-tuned heuristic evaluator — stones, liberties, territory, move priors — and a 6-block residual net running through in-process ONNX Runtime at ~0.9 ms per position. HTTP sidecar as fallback.
-
-eval heuristic|onnx · 1.2M params · 4.6 MB
+
Batched inference
+
Concurrent searches feed one evaluator; worker sessions run ONNX Runtime on every CPU core, or CUDA / TensorRT on a GPU. Random board symmetry per evaluation and a position cache.
+
-device cpu|cuda|tensorrt · 6×64 net · 477k params
Speaks GTP
-
Drop it into Sabaki or Lizzie as an engine. Think-time or fixed-playout budgets, time_left handling, and an SGF of the game written on quit.
-
-gtp -size 9 -think-time 5s -o game.sgf
+
Drop it into Sabaki or Lizzie. Live lz-analyze overlays, time_settings /time_left budgets, undo, final_score, resignation.
+
bin/gofer gtp -model models/gofer-9x9.onnx
A data factory
-
Self-play exports JSONL rows the trainer consumes directly: visit-count policy targets, opponent policy, ownership labels, 8 spatial feature planes and 4 globals per position.
-
policy · policy_opp · ownership · full_search
+
Self-play with KataGo's playout-cap randomization writes compact NumPy shards: 13 feature planes, visit-count policy, game result, search value, final ownership. Resignation with measured false-positive rate.
+
gofer selfplay → .npz · ~1.2 games/s on 4 vCPUs
-
A merciless arena
-
Champion versus challenger under identical configs. Wilson confidence intervals, early rejection when the gate is mathematically out of reach, and a config hash stamped on every report.
-
promote only if win rate clears 0.55
+
An honest arena
+
Any two players — networks, rollout MCTS, GNU Go or any GTP engine. Paired openings with colors swapped, GNU Go dead-stone adjudication, Wilson intervals, Elo, and SPRT for gating.
+
gofer arena -a nn:model.onnx -b gnugo,level=10
@@ -319,12 +319,12 @@ A merciless arena
The training loop
- Nothing gets promoted without a fight.
- Every cycle: the champion plays itself, a network trains on the games, and the result walks into an arena against the very champion that generated its data. Lose, and it's discarded. Win past the Wilson gate, and it takes the throne — after the old champion is archived.
+ Random play to GNU Go, in an afternoon.
+ Every generation: the newest network plays itself, a replay window of recent games trains the next one with all eight board symmetries, the export is checked against PyTorch, and the result is rated against its predecessor and against GNU Go. Stop it anywhere — the same command resumes.
-
+
@@ -340,8 +340,8 @@ THE LOOP
- cycle 7
- 45,243 positions and counting
+ generation 17
+ 10,200 games · 197k positions
@@ -349,38 +349,38 @@
Self-play
- champion vs champion → JSONL
+ newest net vs itself → .npz
- Replay buffer
- persistent · capped 50k rows
+ Replay window
+ grows 60k → 400k rows
Train
- PyTorch · resume from best.pt
+ PyTorch · 8 symmetries · AdamW
Export ONNX
- 6×64 resnet · 4.6 MB · bit-exact parity
+ 6×64 resnet · 1.9 MB · verified vs PyTorch
- Arena gate
- 200 games vs champion · Wilson CI
+ Evaluate
+ vs previous gen + GNU Go
- Promote
- old champion archived first
+ Elo ladder
+ Bradley–Terry fit, gen 0 = 0
@@ -388,26 +388,26 @@
- Self-play
champion vs champion → JSONL
- Replay buffer
persistent · capped 50k rows
- Train
PyTorch · resume from best.pt
- Export ONNX
6×64 resnet · 4.6 MB
- Arena gate
200 games vs champion · Wilson CI
- Promote — and the loop restarts
old champion archived first
+ Self-play
newest net vs itself → .npz
+ Replay window
grows 60k → 400k rows
+ Train
PyTorch · 8 symmetries · AdamW
+ Export ONNX
6×64 resnet · 1.9 MB · verified
+ Evaluate
vs previous gen + GNU Go
+ Elo ladder — and the loop restarts
Bradley–Terry fit, gen 0 = 0
-
The gate
-
A challenger needs a win rate whose Wilson interval clears 0.55 over up to 200 equal-config games — minimum 100 before promotion, early rejection the moment the bound becomes unreachable.
+
No gate
+
Like AlphaZero and KataGo, the newest network always plays the next games. Evaluation is measurement, not a toll booth — v2 spent five times more compute gating than generating data. SPRT is there if you want a gate.
-
The proof
-
Training runs in PyTorch; play runs in Go. A parity harness holds them together: 1,721 positions, bit-exact between Go ONNX Runtime and the Python reference session.
+
The yardstick
+
Strength is measured against an engine nobody tuned for: GNU Go 3.8 — 66–34 at its default level 10, 78–22 at level 1, over 100 games each with colors swapped. GNU Go judges dead stones at the end of every game.
The honest part
-
The net is young. The current champion took the title in cycle 3; challengers since have peaked at a 30.5% arena win rate. The loop is the point — the strength is earned, cycle by cycle, on a 2 GiB cloud box.
+
v2's search backed values up with the wrong sign and skipped the search on fresh trees, so its training never had a chance. The audit lists every defect, with measurements and a regression test.
@@ -418,12 +418,12 @@
One binary
Meet it in the terminal.
- Play it, pipe GTP at it, farm training data out of it, or read the loop's ledger. Every session below is real, captured output — including the engine's questionable taste in opening moves. It's cycle 7. It will learn.
+ Play it, pipe GTP at it, farm training data out of it, or referee a match against GNU Go. Every session below is real, captured output from the generation-17 network.
- -play
- -gtp
- -selfplay
- the loop
+ play
+ gtp
+ selfplay
+ arena
@@ -433,11 +433,11 @@
- bin/gofer -play -size 9 -color b
+ bin/gofer play -model models/gofer-9x9.onnx
- captured from bin/gofer v2.7.1 — not a mock-up
+ captured from bin/gofer v3 — not a mock-up
@@ -446,10 +446,10 @@
-
Hot path discipline
-
The board never allocates.
-
MCTS lives and dies by the inner loop. Gofer mutates one board and undoes, instead of cloning per playout — a measured decision (undo: 23 ns, clone: 130 ns), recorded in ADR 0002 . Legal-move generation dropped from 1,158 heap allocations to 7 with reusable scratch buffers.
-
And it stays that way: make bench-check compares every run against a committed baseline, in CI, on every push.
+
Where the time goes
+
The network is the only bottleneck.
+
v2 spent about as long in Go code as in the network for every evaluation, and ran the network on one core. v3 keeps chains and liberties incrementally, so a whole search step costs ~4 µs, and spreads inference over every core — or a GPU.
+
Measured on a 4-vCPU cloud VM with go test -bench and gofer bench .
@@ -457,21 +457,21 @@
benchmark
- ns/op
- allocs/op
+ v3
+ v2
- ApplyStone 3.52 0
- HashUpdate 3.44 0
- Undo 23.4 0
- Clone (the road not taken) 129.5 1
- LegalMoves — 7 ▼ from 1,158
- SGFReplay (full game) — 45
+ Random 9×9 game (legal moves every ply) 134 µs —
+ Board copy 86 ns 130 ns
+ Go-side cost per network eval ~4 µs ~520 µs
+ Pure search, 4 vCPUs 490k/s —
+ Network evals, 6×64, 4 vCPUs 6,000/s 1,130/s
+ Cores used by self-play 4 of 4 1.2 of 4
-
from .tectonix/reports/bench-regression.json — the file CI gates against
+
v2 network figure is its own (larger) 6×64 net; see docs/v2-audit.md
@@ -479,38 +479,38 @@
Paper trail
- Every reversal, written down first.
- Five architecture decision records, in order. Including the ones where the answer was "tried it, reverted it" — that's the part most projects don't commit to git.
+ Every defect, written down.
+ v3 started with an audit of v2. The findings, the design that replaced it, and the run that proved it are all in the repo — including v2's original decision records.
-
- ADR 0001
- Inference backend
- Mock queue → HTTP sidecar → in-process ORT. Each hop earned by a measured constraint, not swapped for its own sake.
+
+ AUDIT
+ Why v2 could not learn
+ Inverted backup sign, searches that never ran, near-uniform policy targets, one-core inference, truncated games — 13 defects, each measured.
-
- ADR 0002
- LegalMoves allocation
- Undo beat clone, 23 ns vs 130 ns. Measured on the reference laptop first, then written down.
+
+ DESIGN
+ Architecture
+ Packages, the search's sign convention, batching, feature schema, network and data format.
-
- ADR 0003
- Iterative training loop
- Win rate used to bounce — 33% → 58% → 35% — under weekly from-scratch retraining. Persistent state and monotonic promotion fixed it.
+
+ TRAIN
+ Training guide
+ The loop, its settings, throughput by network size, and how to run it on a GPU.
-
- ADR 0004
- In-process ONNX inference
- Cut the HTTP hop — CGO + ORT in the same binary. Production target is a 2 GiB Lightsail box, not a GPU.
+
+ RESULTS
+ The run
+ Every generation: self-play speed, held-out losses, Elo, and results against GNU Go.
-
- ADR 0005
- Net-size ablation
- Tried smaller nets down to 4×32. Not adopted — would've forfeited --resume warm-start from the champion. Still 6×64.
+
+ ADR
+ v2 decision records
+ The original five ADRs, kept for the record.
@@ -519,7 +519,7 @@
Quickstart
- Playing in two minutes.
+ Playing in five minutes.
@@ -536,40 +536,40 @@
2
-
$ make build && make test
-
+
$ scripts/setup.sh && make test
+
-
pure Go — no CGO, no Python, no GPU required
+
ONNX Runtime, GNU Go, Python venv, bin/gofer
3
-
$ bin/gofer -play -size 9 -color b -o game.sgf
-
+
$ bin/gofer play -model models/gofer-9x9.onnx
+
-
you're Black — beat it while you still can
+
you're Black — type hint if you get stuck
4
-
$ bin/gofer -gtp -size 9 -think-time 5s -o demo.sgf
-
+
$ bin/gofer gtp -model models/gofer-9x9.onnx
+
-
register as an engine in Sabaki or Lizzie
+
register as an engine in Sabaki or Lizzie — lz-analyze works
5
-
$ bash scripts/train-loop-v3.sh
-
+
$ .venv/bin/python -m training.loop --run runs/9x9 --gofer bin/gofer
+
-
the whole loop: self-play → train → export → arena → gate
+
the whole loop, resumable: self-play → train → export → evaluate
@@ -577,20 +577,20 @@
What you need
- ● Go 1.22+ — that's it, for the engine
+ ● Go 1.22+ and a C compiler for the engine
+ ○ ONNX Runtime — fetched by scripts/install-ort.sh (CPU or GPU)
○ Python + PyTorch — only to train networks
- ○ CGO + make build-onnx — only for in-process neural inference (ORT 1.26.0)
-
Default playouts when -playouts 0 :
+
Default visits per move:
@@ -611,17 +611,17 @@
Gofer
Not KataGo. Yet.
- 6,089 lines of Go · 103 tests · 18 benchmarks · built with Go, PyTorch & ONNX Runtime
- solo project · 56 commits since Jun 2026
+ 4,315 lines of Go · Go + Python test suites · end-to-end CI · built with Go, PyTorch & ONNX Runtime
+ solo project · v3 rewrite Oct 2026
- © 2026 DevomB · every number on this page comes from a committed report, a CI baseline, or a captured session — nothing is made up.
+ © 2026 DevomB · every number on this page comes from a committed result, a benchmark, or a captured session — nothing is made up.
@@ -1089,7 +1089,7 @@
fpUpdate('');
} else {
$('board-label').textContent = '1846 · Shusaku vs Gennan Inseki';
- $('board-caption').innerHTML = 'The “ear-reddening” game — Honinbo Shusaku vs Inoue Gennan Inseki, 1846 . One of 95,894 pro games in Gofer’s warm-start corpus.';
+ $('board-caption').innerHTML = 'The “ear-reddening” game — Honinbo Shusaku vs Inoue Gennan Inseki, 1846 . Gofer itself never saw a human game.';
replaySet(replayN, false);
}
hoverIdx = -1;
@@ -1120,93 +1120,316 @@
' 3 . . . . . . . . .', ' 2 . . . . . . . . .', ' 1 . . . . . . . . .',
];
const SESSIONS = {
- play: {
- title: 'bin/gofer -play -size 9 -color b',
- lines: [
- { t: 'cmd', s: 'bin/gofer -play -size 9 -color b' },
- { t: 'out', s: 'Gofer 9x9 komi=6.5 — you are Black' },
- { t: 'out', s: 'Commands: board | play | genmove | analyze | score | undo | quit' },
- ...B9.map(s => ({ t: 'dim', s })),
- { t: 'out', s: 'Black to move' },
- { t: 'out', s: '82 legal moves | your turn — play ' },
- { t: 'in', s: 'play E5' },
- { t: 'out', s: 'White to move' },
- { t: 'out', s: '81 legal moves | engine turn — genmove' },
- { t: 'in', s: 'genmove' },
- { t: 'gold', s: '> engine A9' },
- { t: 'dim', s: ' A B C D E F G H J' },
- { t: 'dim', s: ' 9 O . . . . . . . .' },
- { t: 'dim', s: ' 8 . . . . . . . . .' },
- { t: 'dim', s: ' 7 . . . . . . . . .' },
- { t: 'dim', s: ' 6 . . . . . . . . .' },
- { t: 'dim', s: ' 5 . . . . X . . . .' },
- { t: 'dim', s: ' 4 . . . . . . . . .' },
- { t: 'dim', s: ' 3 . . . . . . . . .' },
- { t: 'dim', s: ' 2 . . . . . . . . .' },
- { t: 'dim', s: ' 1 . . . . . . . . .' },
- { t: 'out', s: 'Black to move' },
- { t: 'com', s: '# yes, it answered E5 with A9. cycle 7. it will learn.' },
+ "play": {
+ "title": "bin/gofer play -model models/gofer-9x9.onnx",
+ "lines": [
+ {
+ "t": "cmd",
+ "s": "bin/gofer play -model models/gofer-9x9.onnx"
+ },
+ {
+ "t": "out",
+ "s": "Moves: D4, pass, resign, hint, quit"
+ },
+ {
+ "t": "in",
+ "s": "E5"
+ },
+ {
+ "t": "gold",
+ "s": "Gofer plays D4 (win 54%, pv D4 D6 F4 E4 E3)"
+ },
+ {
+ "t": "dim",
+ "s": " A B C D E F G H J"
+ },
+ {
+ "t": "dim",
+ "s": " 9 . . . . . . . . . 9"
+ },
+ {
+ "t": "dim",
+ "s": " 8 . . . . . . . . . 8"
+ },
+ {
+ "t": "dim",
+ "s": " 7 . . . . . . . . . 7"
+ },
+ {
+ "t": "dim",
+ "s": " 6 . . . . . . . . . 6"
+ },
+ {
+ "t": "dim",
+ "s": " 5 . . . . X . . . . 5"
+ },
+ {
+ "t": "dim",
+ "s": " 4 . . . O). . . . . 4"
+ },
+ {
+ "t": "dim",
+ "s": " 3 . . . . . . . . . 3"
+ },
+ {
+ "t": "dim",
+ "s": " 2 . . . . . . . . . 2"
+ },
+ {
+ "t": "dim",
+ "s": " 1 . . . . . . . . . 1"
+ },
+ {
+ "t": "in",
+ "s": "hint"
+ },
+ {
+ "t": "out",
+ "s": "hint: D6 (win 45%)"
+ },
+ {
+ "t": "in",
+ "s": "C6"
+ },
+ {
+ "t": "dim",
+ "s": " A B C D E F G H J"
+ },
+ {
+ "t": "dim",
+ "s": " 9 . . . . . . . . . 9"
+ },
+ {
+ "t": "dim",
+ "s": " 8 . . . . . . . . . 8"
+ },
+ {
+ "t": "dim",
+ "s": " 7 . . . . . . . . . 7"
+ },
+ {
+ "t": "dim",
+ "s": " 6 . . X). . . . . . 6"
+ },
+ {
+ "t": "dim",
+ "s": " 5 . . . . X . . . . 5"
+ },
+ {
+ "t": "dim",
+ "s": " 4 . . . O . . . . . 4"
+ },
+ {
+ "t": "dim",
+ "s": " 3 . . . . . . . . . 3"
+ },
+ {
+ "t": "dim",
+ "s": " 2 . . . . . . . . . 2"
+ },
+ {
+ "t": "dim",
+ "s": " 1 . . . . . . . . . 1"
+ },
+ {
+ "t": "gold",
+ "s": "Gofer plays F4 (win 54%, pv F4 F5 E3 G4 G3 H4)"
+ }
]
},
- gtp: {
- title: 'bin/gofer -gtp -size 9 -gtp-playouts 400',
- lines: [
- { t: 'cmd', s: 'bin/gofer -gtp -size 9 -gtp-playouts 400' },
- { t: 'in', s: 'name' },
- { t: 'gold', s: '= Gofer' },
- { t: 'out', s: '' },
- { t: 'in', s: 'version' },
- { t: 'gold', s: '= 1.0' },
- { t: 'out', s: '' },
- { t: 'in', s: 'boardsize 9' },
- { t: 'gold', s: '=' },
- { t: 'out', s: '' },
- { t: 'in', s: 'genmove b' },
- { t: 'gold', s: '= A9' },
- { t: 'out', s: '' },
- { t: 'in', s: 'quit' },
- { t: 'gold', s: '=' },
- { t: 'com', s: '# same protocol Sabaki & Lizzie speak — add it as an engine' },
+ "gtp": {
+ "title": "bin/gofer gtp -model models/gofer-9x9.onnx",
+ "lines": [
+ {
+ "t": "cmd",
+ "s": "bin/gofer gtp -model models/gofer-9x9.onnx"
+ },
+ {
+ "t": "in",
+ "s": "boardsize 9"
+ },
+ {
+ "t": "out",
+ "s": "= "
+ },
+ {
+ "t": "in",
+ "s": "clear_board"
+ },
+ {
+ "t": "out",
+ "s": "= "
+ },
+ {
+ "t": "in",
+ "s": "komi 7.5"
+ },
+ {
+ "t": "out",
+ "s": "= "
+ },
+ {
+ "t": "in",
+ "s": "play B E5"
+ },
+ {
+ "t": "out",
+ "s": "= "
+ },
+ {
+ "t": "in",
+ "s": "genmove W"
+ },
+ {
+ "t": "gold",
+ "s": "= D4"
+ },
+ {
+ "t": "in",
+ "s": "play B C4"
+ },
+ {
+ "t": "out",
+ "s": "= "
+ },
+ {
+ "t": "in",
+ "s": "gofer-analyze 800"
+ },
+ {
+ "t": "gold",
+ "s": "= value +0.125 winrate 56.2% visits 800"
+ },
+ {
+ "t": "gold",
+ "s": "D5 visits 459 winrate 58.4% prior 13.4%"
+ },
+ {
+ "t": "out",
+ "s": "C3 visits 62 winrate 55.4% prior 8.5%"
+ },
+ {
+ "t": "out",
+ "s": "C5 visits 58 winrate 54.1% prior 11.3%"
+ },
+ {
+ "t": "out",
+ "s": "E3 visits 24 winrate 53.1% prior 6.4%"
+ },
+ {
+ "t": "out",
+ "s": "E4 visits 20 winrate 55.4% prior 2.9%"
+ },
+ {
+ "t": "out",
+ "s": "F4 visits 17 winrate 52.1% prior 5.0%"
+ },
+ {
+ "t": "out",
+ "s": "D3 visits 14 winrate 52.5% prior 3.8%"
+ },
+ {
+ "t": "out",
+ "s": "F5 visits 8 winrate 54.6% prior 1.4%"
+ },
+ {
+ "t": "out",
+ "s": "pv D5 D6 C5 E4 E3 D3 C3 D2"
+ }
]
},
- selfplay: {
- title: 'bin/gofer -selfplay',
- lines: [
- { t: 'cmd', s: 'bin/gofer -selfplay -games 3 -size 9 -playouts 1500 \\' },
- { t: 'cmd2', s: ' -selfplay-eval onnx -sgf-dir games/ -o samples.jsonl' },
- { t: 'out', s: 'wrote 3 SGF files to games/' },
- { t: 'com', s: '# warm-start too: -convert-sgf turns 95,894 pro SGFs into JSONL —' },
- { t: 'com', s: '# the 1846 Shusaku game replaying up top is Shusaku/126.sgf' },
- { t: 'cmd', s: 'head -2 samples.jsonl | tail -1 | jq keys' },
- { t: 'out', s: '[' },
- { t: 'out', s: ' "board_hash", "features_global",' },
- { t: 'out', s: ' "features_spatial", "full_search",' },
- { t: 'out', s: ' "komi", "move_num",' },
- { t: 'out', s: ' "ownership", "policy",' },
- { t: 'out', s: ' "policy_opp", "to_play",' },
- { t: 'out', s: ' "value"' },
- { t: 'out', s: ']' },
- { t: 'com', s: '# visit-count policy targets, ownership labels,' },
- { t: 'com', s: '# 8 spatial planes + 4 globals — straight into the trainer' },
+ "selfplay": {
+ "title": "bin/gofer selfplay",
+ "lines": [
+ {
+ "t": "cmd",
+ "s": "bin/gofer selfplay -model runs/9x9/models/gen-0016.onnx -games 600 \\"
+ },
+ {
+ "t": "cmd2",
+ "s": " -full-visits 160 -fast-visits 32 -full-prob 0.25 -out runs/9x9/data/gen-0016"
+ },
+ {
+ "t": "out",
+ "s": "selfplay: 50/600 games, 1.0 games/s, 841 rows, black wins 38.0%"
+ },
+ {
+ "t": "out",
+ "s": "selfplay: 150/600 games, 1.2 games/s, 2717 rows, black wins 44.0%"
+ },
+ {
+ "t": "out",
+ "s": "selfplay: 250/600 games, 1.2 games/s, 4527 rows, black wins 46.8%"
+ },
+ {
+ "t": "out",
+ "s": "selfplay: 350/600 games, 1.2 games/s, 6336 rows, black wins 44.9%"
+ },
+ {
+ "t": "out",
+ "s": "selfplay: 450/600 games, 1.2 games/s, 8168 rows, black wins 45.3%"
+ },
+ {
+ "t": "dim",
+ "s": "…"
+ },
+ {
+ "t": "gold",
+ "s": "selfplay: 600 games, 10992 rows in 470s (1.28 games/s), black won 44.5%, resigned 440, false resigns 1/53"
+ },
+ {
+ "t": "com",
+ "s": "# 25% of moves get a full 160-visit search and become training rows;"
+ },
+ {
+ "t": "com",
+ "s": "# the rest get 32 visits and are only played (playout-cap randomization)."
+ }
]
},
- loop: {
- title: 'scripts/train-loop-v3.sh',
- lines: [
- { t: 'cmd', s: 'bash scripts/train-loop-v3.sh' },
- { t: 'com', s: '# self-play → replay buffer → train → export → arena → gate …' },
- { t: 'cmd', s: 'cat .tectonix/manifest.json' },
- { t: 'out', s: '{' },
- { t: 'out', s: ' "version": 3,' },
- { t: 'out', s: ' "cycle": 7,' },
- { t: 'gold', s: ' "best_win_rate": 0.305,' },
- { t: 'out', s: ' "best_cycle": 3,' },
- { t: 'gold', s: ' "replay_rows": 45243,' },
- { t: 'out', s: ' "last_promoted_at": "2026-07-02T20:46:46Z",' },
- { t: 'out', s: ' "seed": "fresh"' },
- { t: 'out', s: '}' },
- { t: 'com', s: '# the champion still holds the gate. that’s the ledger —' },
- { t: 'com', s: '# a challenger has to clear a 0.55 Wilson bound to rewrite it.' },
+ "loop": {
+ "title": "bin/gofer arena",
+ "lines": [
+ {
+ "t": "cmd",
+ "s": "bin/gofer arena -a nn:models/gofer-9x9.onnx,visits=400 -b gnugo,level=10 -games 100"
+ },
+ {
+ "t": "gold",
+ "s": "gen-0017@400 vs gnugo-l10: 66-34-0 score 0.660 [0.563, 0.745] elo +115 [+44, +187]"
+ },
+ {
+ "t": "cmd",
+ "s": "bin/gofer arena -a nn:models/gofer-9x9.onnx,visits=400 -b gnugo,level=1 -games 100"
+ },
+ {
+ "t": "gold",
+ "s": "gen-0017@400 vs gnugo-l1: 78-22-0 score 0.780 [0.689, 0.850] elo +220 [+138, +301]"
+ },
+ {
+ "t": "com",
+ "s": "# paired random openings, colors swapped, GNU Go judges dead stones."
+ },
+ {
+ "t": "cmd",
+ "s": "grep eval: runs/9x9/log.txt | tail -4"
+ },
+ {
+ "t": "out",
+ "s": "eval: gen-0016 vs gen-0015: 25-15-0 elo +89 [-21, +198]"
+ },
+ {
+ "t": "out",
+ "s": "eval: gen-0016 vs rollout: 40-0-0 score 1.00"
+ },
+ {
+ "t": "out",
+ "s": "eval: gen-0016 vs gnugo-l1: 26-14-0 score 0.65"
+ },
+ {
+ "t": "out",
+ "s": "eval: gen-0016 vs gnugo-l6: 23-17-0 score 0.57"
+ }
]
}
};