From f997cc8b156fe6045b96bb0006b93596ccd2d88c Mon Sep 17 00:00:00 2001
From: Jon Binney
Date: Sun, 24 May 2026 19:35:16 -0400
Subject: [PATCH 01/30] Update B9W10 config
---
.../2026_05_23_jon_b9w10_performance/config.yaml | 11 +++++------
1 file changed, 5 insertions(+), 6 deletions(-)
diff --git a/experiments/2026_05_23_jon_b9w10_performance/config.yaml b/experiments/2026_05_23_jon_b9w10_performance/config.yaml
index 9a8a214c..b0cd8d87 100644
--- a/experiments/2026_05_23_jon_b9w10_performance/config.yaml
+++ b/experiments/2026_05_23_jon_b9w10_performance/config.yaml
@@ -22,7 +22,7 @@ wandb:
self_play:
program: rust
num_processes: 1
- mcts_worker_threads: 12
+ mcts_worker_threads: 18
games_per_process: 128
eval_batch_size: 1024
leaf_parallelism: 16
@@ -32,8 +32,9 @@ self_play:
eval_max_wait_ms: 1
alphazero:
mcts_noise_epsilon: 0.25
+ temperature: 3.0
training:
- games_per_training_step: 50
+ games_per_training_step: 100
learning_rate: 0.001
batch_size: 2048
weight_decay: 0.0001
@@ -52,14 +53,12 @@ benchmarks:
- random
- greedy:p_random=0.3,nick=greedy-03
- greedy
- - simple:branching_factor=16,nick=simple-bf16
-- every: 1 models
+- every: 10 models
jobs:
- type: tournament
- prefix: full
+ prefix: ""
times: 10
opponents:
- random
- greedy:p_random=0.3,nick=greedy-03
- greedy
- - simple:branching_factor=16,nick=simple-bf16
From 250c2c609d2c4bb12308981d368343e367a1f553 Mon Sep 17 00:00:00 2001
From: Jon Binney
Date: Tue, 26 May 2026 20:38:44 -0400
Subject: [PATCH 02/30] vibe: add self-play MCTS metrics design spec
Rust self-play exposes no visibility into MCTS behavior during a run.
Spec adds per-model-version diagnostics (terminal/truncation sim
fraction, tree depth, root-visit spread, nodes/branching, unique games)
written by Rust to JSON records and logged to W&B by a new Python
process in the same run group, reset on each model update.
---
...2026-05-26-selfplay-mcts-metrics-design.md | 154 ++++++++++++++++++
1 file changed, 154 insertions(+)
create mode 100644 docs/superpowers/specs/2026-05-26-selfplay-mcts-metrics-design.md
diff --git a/docs/superpowers/specs/2026-05-26-selfplay-mcts-metrics-design.md b/docs/superpowers/specs/2026-05-26-selfplay-mcts-metrics-design.md
new file mode 100644
index 00000000..3ba18f1d
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-26-selfplay-mcts-metrics-design.md
@@ -0,0 +1,154 @@
+# Self-play MCTS metrics → W&B — design
+
+**Date:** 2026-05-26
+**Branch:** `jdb/b9w10-performance`
+**Status:** approved (design), pending implementation plan
+
+## Problem
+
+The Rust self-play binary (`deep_quoridor/rust`, driven by `train_v2.py`) runs leaf-parallel
+MCTS to generate training games, but exposes almost no visibility into *how* the search is
+behaving during a training run. We want diagnostics — terminal-hit rate, tree depth, how
+"spread out" the search is, and how many distinct games are being produced — surfaced to
+Weights & Biases in the **same W&B group as the other workers** (`train`, `benchmark`,
+`ai_report`), and **reset each time the model is updated** so each metric reflects a single
+model version.
+
+## Constraint that shapes everything
+
+The Rust self-play runs as a **subprocess** (`subprocess.Popen` in `train_v2.py:112`) with **no
+W&B client** — only the Python processes call `wandb.init(group=config.run_id, …)`. There is
+currently no self-play W&B run at all. So a Rust→Python bridge is required: Rust writes metric
+records to files; a new Python process logs them to W&B. This reuses the existing file-based
+Rust→Python channel already used for replay games.
+
+## Architecture & data flow
+
+```
+Rust selfplay subprocess(es) Python (spawned by train_v2.py)
+┌──────────────────────────────┐ ┌─────────────────────────────┐
+│ per-search SearchStats ──┐ │ JSON │ run_selfplay_metrics(config)│
+│ per-game hashes ─────────┤ │ records │ • polls metrics dir │
+│ SelfPlayMetrics accumulator │ ──────► │ • aggregates across pids │
+│ (keyed by model_version) │ files │ • wandb.log(group=run_id) │
+│ flush+reset on version change│ │ x-axis = "Model version" │
+└──────────────────────────────┘ └─────────────────────────────┘
+```
+
+Rust accumulates raw metric aggregates for the current `model_version`. The existing
+model-reload poll task (`selfplay.rs:679-688`) already detects version changes and is the
+natural flush+reset point. On a version change (and on shutdown) Rust writes one JSON record
+per `(model_version, pid)` to a metrics directory and clears the accumulator. A new Python
+process — spawned by `train_v2.py` like the benchmark processes — polls that directory and logs
+each completed version to W&B once.
+
+## Metrics (all per model version; x-axis = `Model version`)
+
+| W&B metric | Definition | Version aggregation |
+|---|---|---|
+| `selfplay/terminal_sim_frac` | fraction of MCTS simulations whose selected leaf is a **win** terminal | Σ terminal-win sims / Σ sims |
+| `selfplay/truncation_sim_frac` | fraction of simulations whose leaf hit the `max_steps` cap (code already distinguishes win vs truncation in `selfplay_mcts.rs:180-197`) | Σ trunc sims / Σ sims |
+| `selfplay/max_tree_depth` | deepest selection path (`path.len()` from `select_leaf_with_vl`) | max |
+| `selfplay/mean_tree_depth` | mean selection-path depth per sim | Σ depth / Σ sims |
+| `selfplay/root_visit_entropy` | entropy (nats) of root child visit distribution | mean over moves |
+| `selfplay/root_visit_perplexity` | `exp(entropy)` = effective number of moves considered | mean over moves |
+| `selfplay/top_move_visit_frac` | max child visits / total root visits | mean over moves |
+| `selfplay/mean_nodes_per_search` | arena node count per search | Σ nodes / moves |
+| `selfplay/mean_branching` | tree edges / internal (expanded) nodes | pooled: (Σ nodes − Σ moves) / Σ internal_nodes |
+| `selfplay/games_generated` | games completed this version | sum |
+| `selfplay/unique_games_full` / `selfplay/unique_frac_full` | distinct full move-sequences; and unique/total | per-process dedup, summed (see Decisions) |
+| `selfplay/unique_games_opening` / `selfplay/unique_frac_opening` | distinct first-K-ply prefixes; and unique/total | per-process dedup, summed |
+
+"Simulation" = one leaf selection/backprop iteration of MCTS. "Move" = one root search (one
+played move). Entropy/perplexity/top-frac are computed per move from the root child visit
+counts, then averaged over the moves in the version.
+
+## Rust side
+
+- **`SearchStats`** — `search()` (`selfplay_mcts.rs:102`) returns this alongside its existing
+ `(children, root_value)`. Fields: `sims`, `terminal_wins`, `truncations`, `max_depth`,
+ `sum_depth`, `nodes`, `internal_nodes`, `root_visit_entropy`, `top_move_visit_frac`. All are
+ derived from data the search already has: per-sim `path` length (depth), the existing
+ terminal/truncation branch, the arena node count, and the root `children` visit counts.
+- **Per-game folding** — `play_game_async` folds each move's `SearchStats` into a per-game
+ total and, at game end, computes a full-game move-sequence hash and a first-K-ply hash.
+- **`SelfPlayMetrics` accumulator** — one per process, `Arc>`, keyed by
+ `model_version`. Holds running raw aggregates: summed counters, running `max_depth`, sums and
+ counts for the per-move means, `games_generated`, and two `HashSet` (full + opening
+ hashes) for within-process uniqueness. Game tasks fold their per-game results in under the
+ current `model_version`.
+- **Flush + reset** — at the version-change point in the poll task, and on shutdown: serialize
+ the raw aggregates for the just-finished version to
+ `/v{version}_pid{pid}.json`, then clear the accumulator. Writing raw aggregates
+ (not final metrics) lets Python combine multiple processes correctly.
+- **CLI** — new `--metrics-dir ` arg. When absent, metric collection is disabled entirely
+ (no accumulator, no overhead) so the binary stays usable standalone. `train_v2.py` always
+ passes it for Rust self-play.
+
+### JSON record schema (one file per `(version, pid)`)
+```json
+{
+ "model_version": 42, "pid": 700653,
+ "sims": 12830000, "terminal_wins": 410000, "truncations": 90000,
+ "max_depth": 37, "sum_depth": 41000000,
+ "moves": 9800, "sum_root_entropy": 18000.0, "sum_top_move_frac": 6100.0,
+ "sum_nodes": 9100000, "sum_internal_nodes": 5200000,
+ "games_generated": 96,
+ "unique_full": 96, "unique_opening": 71
+}
+```
+(Per-move sums are divided by `moves` on the Python side; `sum_depth` is divided by `sims`.)
+
+## Python side
+
+- **`run_selfplay_metrics(config)`** — a new function/module spawned by `train_v2.py` as an
+ `mp.Process`, alongside `create_benchmark_processes`, only when `config.self_play.program ==
+ "rust"`. It `wandb.init(project=config.wandb.project, group=config.run_id,
+ job_type="selfplay", name=f"{config.run_id}-selfplay", id=f"{config.run_id}-selfplay",
+ resume="allow")` and `wandb.define_metric("*", "Model version")` (matching `benchmarks.py`).
+- **Poll loop** — every few seconds: scan the metrics dir. A version `V` is **complete** when a
+ record file for some version `> V` exists, or `ShutdownSignal` is set. For each complete,
+ not-yet-logged `V`: read all `v{V}_pid*.json`, combine (sum the sums/counters, max the maxes,
+ sum the unique/total counts), compute final metrics, `wandb.log({…metrics…, "Model version":
+ V})`, and record `V` as logged. Exits on `ShutdownSignal`.
+- Aggregation lives in a small pure helper (`aggregate_records(list[dict]) -> dict[str,float]`)
+ so it is unit-testable without W&B or the filesystem.
+
+## Decisions
+
+- **Opening length `K = 8` plies** (4 moves each).
+- **Uniqueness: per-process dedup, summed across processes by Python.** With `num_processes=1`
+ (current setup) this is exact. With >1 it can slightly *over*count uniques (a game produced by
+ two processes counts twice), which is acceptable for a collapse-detection diagnostic and
+ avoids shipping large hash lists in the JSON.
+- **No periodic heartbeat** — flush only at version boundaries and shutdown, one definitive
+ record per version, logged once. Model updates are frequent (≈ every training step), so the
+ in-progress version becoming visible only at the next update is acceptable.
+- **Disabled unless `--metrics-dir` is passed**, so standalone/benchmark runs of the binary are
+ unaffected and there is zero overhead when off.
+
+## Edge cases
+
+- A version that produces zero games/searches before the next update: skip (don't log an empty
+ record, or log with zeros — implementation will log with whatever was accumulated; a version
+ with `moves == 0` is skipped to avoid divide-by-zero).
+- Shutdown mid-version: the shutdown flush writes the final (partial) version so it isn't lost.
+- Multiple processes desynced across versions: Python only finalizes `V` once a `> V` record
+ exists from *any* process; late records for an already-logged `V` are ignored (logged once).
+- Metrics dir must be created by the Rust side on startup (like `tmp_dir`).
+
+## Testing
+
+- **Rust unit test**: drive `search()` with the existing stub coordinator and assert
+ `SearchStats` is sane (`sims == n`, `max_depth ≥ 1`, `top_move_visit_frac ∈ (0,1]`,
+ `entropy ≥ 0`). A game-hash test: two identical forced move-sequences hash equal; two
+ different ones do not.
+- **Python unit test**: feed synthetic per-pid JSON records to `aggregate_records` and assert
+ weighted means, max-of-maxes, and summed unique/total counts are correct.
+
+## Out of scope (YAGNI)
+
+- Exact cross-process unique-game dedup (hash union).
+- Periodic in-version heartbeat logging.
+- Per-game or per-move time-series (only per-version aggregates).
+- Logging from the legacy Python self-play path (`v2/self_play.py`) — Rust path only.
From be190938c34edc996922631ea32fafb64fce5aef Mon Sep 17 00:00:00 2001
From: Jon Binney
Date: Tue, 26 May 2026 20:50:15 -0400
Subject: [PATCH 03/30] vibe: add self-play MCTS metrics implementation plan
Seven-task TDD plan: SearchStats from search(), per-game GameMetrics
plus move hashing, a SelfPlayAccumulator that flushes per-version JSON
records, wiring into the continuous loop's reload/shutdown points, and
a Python aggregator + W&B logger process spawned by train_v2.
---
.../plans/2026-05-26-selfplay-mcts-metrics.md | 1153 +++++++++++++++++
1 file changed, 1153 insertions(+)
create mode 100644 docs/superpowers/plans/2026-05-26-selfplay-mcts-metrics.md
diff --git a/docs/superpowers/plans/2026-05-26-selfplay-mcts-metrics.md b/docs/superpowers/plans/2026-05-26-selfplay-mcts-metrics.md
new file mode 100644
index 00000000..14aebda9
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-26-selfplay-mcts-metrics.md
@@ -0,0 +1,1153 @@
+# Self-play MCTS Metrics → W&B Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Surface per-model-version MCTS diagnostics from Rust self-play (terminal/truncation sim fraction, tree depth, root-visit spread, nodes/branching, unique games) to a W&B run in the training group, reset on each model update.
+
+**Architecture:** Rust `search()` returns lightweight `SearchStats`; `play_game_async` folds them per game and hashes the move sequence; a per-process `SelfPlayAccumulator` aggregates by model version and flushes a JSON record to a metrics dir on each model-version change and on shutdown. A new Python process spawned by `train_v2.py` polls the dir, aggregates per-version records across processes, and logs to W&B (`group=run_id`, x-axis `Model version`).
+
+**Tech Stack:** Rust (edition 2024, `serde_json`, `clap`, `tokio`), Python (`wandb`, `pydantic` config), pytest, cargo test.
+
+**Spec:** `docs/superpowers/specs/2026-05-26-selfplay-mcts-metrics-design.md`
+
+---
+
+## File structure
+
+- `deep_quoridor/rust/src/agents/alphazero/selfplay_mcts.rs` — add `SearchStats`, return it from `search()`. (modify)
+- `deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs` — add `GameMetrics` + move-sequence hashing; `play_game_async` returns `(GameResult, GameMetrics)`. (modify)
+- `deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs` — `SelfPlayAccumulator` (fold + flush JSON). (create)
+- `deep_quoridor/rust/src/agents/alphazero/mod.rs` — register new module. (modify)
+- `deep_quoridor/rust/Cargo.toml` — add `serde_json` to the `binary` feature. (modify)
+- `deep_quoridor/rust/src/bin/selfplay.rs` — `--metrics-dir` arg; create accumulator; fold per game; flush on version change + shutdown. (modify)
+- `deep_quoridor/src/v2/selfplay_metrics.py` — `aggregate_records()` + `run_selfplay_metrics()`. (create)
+- `deep_quoridor/src/v2/__init__.py` — export `run_selfplay_metrics`. (modify)
+- `deep_quoridor/src/train_v2.py` — pass `--metrics-dir`, spawn the metrics process. (modify)
+- `deep_quoridor/test/test_selfplay_metrics.py` — Python aggregator unit tests. (create)
+
+Build/test env note: Rust builds/tests that touch the eval pipeline need `--features binary,gpu` and, at runtime, the GPU env vars. For the tests in this plan that use the **stub coordinator** (no real ONNX), `--features binary` is enough and **no** env vars are needed. Run Rust commands with the sandbox disabled and long timeouts. Commit style (AGENTS.md): `vibe: ` imperative subject ≤50 chars; separate functional vs formatting commits; run `cargo fmt` before committing Rust.
+
+---
+
+## Task 1: `SearchStats` from `search()`
+
+**Files:**
+- Modify: `deep_quoridor/rust/src/agents/alphazero/selfplay_mcts.rs`
+
+- [ ] **Step 1: Add the `SearchStats` struct**
+
+At the top of `selfplay_mcts.rs`, after the `LeafParallelConfig` struct (around line 30), add:
+
+```rust
+/// Lightweight per-search diagnostics, accumulated cheaply during one `search()`.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct SearchStats {
+ /// Number of MCTS simulations (selected leaves) this search.
+ pub sims: u32,
+ /// Simulations whose selected leaf was a win-terminal game state.
+ pub terminal_wins: u32,
+ /// Simulations whose selected leaf hit the max_steps cap.
+ pub truncations: u32,
+ /// Deepest selection path length (nodes from root to leaf, inclusive).
+ pub max_depth: u32,
+ /// Sum of selection-path lengths (divide by `sims` for mean depth).
+ pub sum_depth: u64,
+ /// Total nodes in the arena at search end.
+ pub nodes: u32,
+ /// Arena nodes that have at least one child (internal/expanded nodes).
+ pub internal_nodes: u32,
+ /// Entropy (nats) of the root child visit distribution.
+ pub root_visit_entropy: f64,
+ /// Fraction of root visits on the single most-visited child.
+ pub top_move_visit_frac: f64,
+}
+```
+
+- [ ] **Step 2: Change `search()` signature and instrument it**
+
+In `selfplay_mcts.rs`, change the `search` return type (around line 102-107) from:
+
+```rust
+ pub async fn search(
+ &mut self,
+ root_data: CompactState,
+ mechanics: &QGameMechanics,
+ visited_states: &HashSet,
+ ) -> Result<(Vec, f32)> {
+```
+
+to:
+
+```rust
+ pub async fn search(
+ &mut self,
+ root_data: CompactState,
+ mechanics: &QGameMechanics,
+ visited_states: &HashSet,
+ ) -> Result<(Vec, f32, SearchStats)> {
+```
+
+Immediately before the `let mut iters_done: u32 = 0;` line (around line 147), add:
+
+```rust
+ let mut stats = SearchStats::default();
+```
+
+Inside the selection loop `for _ in 0..outer { ... }`, right after `let leaf_data = arena.get(leaf_idx).data;` (around line 177), add depth/sim accounting:
+
+```rust
+ let depth = path.len() as u32;
+ stats.sims += 1;
+ stats.sum_depth += depth as u64;
+ if depth > stats.max_depth {
+ stats.max_depth = depth;
+ }
+```
+
+In the same loop, in the win-terminal branch (where `Item::Terminal { path, value: v }` with `v = 1.0` is pushed for `mechanics.winner(...).is_some()`), increment after pushing:
+
+```rust
+ items.push(Item::Terminal { path, value: v });
+ if v > 0.0 {
+ stats.terminal_wins += 1;
+ } else {
+ stats.truncations += 1;
+ }
+ continue;
+```
+
+And in the explicit max_steps branch (`items.push(Item::Terminal { path, value: 0.0 });` under `if let Some(max) = self.cfg.max_steps`), add `stats.truncations += 1;` before its `continue;`.
+
+(Note: the win branch already computes `v` as `1.0` for a winner else `0.0`; the `v > 0.0` test above classifies win vs draw-terminal. The separate max_steps branch is always a truncation.)
+
+- [ ] **Step 3: Compute spread + node stats and return them**
+
+Replace the children-extraction tail of `search()` (around line 275-300) — the block that builds `children` and ends with `Ok((children, computed_root_value))` — so it computes the spread/node stats and returns the 3-tuple:
+
+```rust
+ // Extract children info.
+ let bs = mechanics.repr().board_size() as i32;
+ let root = arena.get(0);
+ let computed_root_value = if root.visit_count > 0 {
+ -(root.value_sum / root.visit_count as f64) as f32
+ } else {
+ root_value
+ };
+ let children: Vec = root
+ .children
+ .iter()
+ .map(|&ci| {
+ let c = arena.get(ci);
+ let ai = c.action_index.expect("child node must have action_index");
+ ChildInfo {
+ action: crate::actions::action_index_to_action(bs, ai),
+ action_index: ai,
+ visit_count: c.visit_count,
+ }
+ })
+ .collect();
+
+ // Root visit spread (entropy in nats + top-move fraction).
+ let total_visits: u64 = children.iter().map(|c| c.visit_count as u64).sum();
+ if total_visits > 0 {
+ let mut entropy = 0.0f64;
+ let mut max_v = 0u32;
+ for c in &children {
+ if c.visit_count > 0 {
+ let p = c.visit_count as f64 / total_visits as f64;
+ entropy -= p * p.ln();
+ if c.visit_count > max_v {
+ max_v = c.visit_count;
+ }
+ }
+ }
+ stats.root_visit_entropy = entropy;
+ stats.top_move_visit_frac = max_v as f64 / total_visits as f64;
+ }
+ stats.nodes = arena.len() as u32;
+ stats.internal_nodes = (0..arena.len())
+ .filter(|&i| !arena.get(i).children.is_empty())
+ .count() as u32;
+
+ // Stash the arena for tree reuse on the next call.
+ self.arena = Some(arena);
+
+ Ok((children, computed_root_value, stats))
+```
+
+- [ ] **Step 4: Update the 4 in-module tests to destructure the 3-tuple**
+
+In the `#[cfg(test)] mod tests` of `selfplay_mcts.rs`, the four `mcts.search(...).await.unwrap()` calls currently bind `(children, _root_value)` / `(children, _)`. Change each to add a third binding `_stats`:
+- `let (children, _root_value, _stats) = mcts.search(data, &mech, &visited).await.unwrap();` (test_leaf_parallel_k1...)
+- `let (children, _, _stats) = mcts.search(data, &mech, &visited).await.unwrap();` (test_leaf_parallel_k8...)
+- `let (children_1, _, _stats) = mcts.search(data, &mech, &visited).await.unwrap();` and `let (children_2, _, _stats) = mcts.search(next_state, &mech, &visited).await.unwrap();` (test_tree_reuse...)
+- `let _ = mcts.search(data, &mech, &visited).await.unwrap();` (test_note_model_version... — unchanged, already discards)
+
+- [ ] **Step 5: Add a `SearchStats` sanity test**
+
+Append this test inside the `mod tests` block of `selfplay_mcts.rs` (reuses the existing `spawn_stub_coordinator`):
+
+```rust
+ #[test]
+ fn test_search_stats_are_sane() {
+ let rt = tokio::runtime::Builder::new_multi_thread()
+ .worker_threads(2)
+ .enable_all()
+ .build()
+ .unwrap();
+ rt.block_on(async {
+ let mech = QGameMechanics::new(5, 0, 200);
+ let data = mech.create_initial_state();
+ let cache = Arc::new(EvalCache::new());
+ let (tx, rx) = tokio_mpsc::channel::(128);
+ let stub = spawn_stub_coordinator(rx, Arc::clone(&cache));
+
+ let mcts_cfg = MCTSConfig {
+ n: Some(40),
+ ucb_c: 1.4,
+ noise_epsilon: 0.0,
+ ..Default::default()
+ };
+ let lp_cfg = LeafParallelConfig {
+ leaf_parallelism: 4,
+ virtual_loss: 1,
+ enable_tree_reuse: false,
+ };
+ let mut mcts = LeafParallelMCTS::new(mcts_cfg, lp_cfg, tx.clone(), Arc::clone(&cache));
+ let visited = std::collections::HashSet::new();
+ let (_children, _v, stats) = mcts.search(data, &mech, &visited).await.unwrap();
+
+ assert_eq!(stats.sims, 40, "sims should equal mcts_n");
+ assert!(stats.max_depth >= 1, "max_depth must be >= 1");
+ assert!(stats.sum_depth >= stats.sims as u64, "each sim has depth >= 1");
+ assert!(stats.nodes >= 1);
+ assert!(stats.internal_nodes >= 1);
+ assert!(stats.root_visit_entropy >= 0.0);
+ assert!(
+ stats.top_move_visit_frac > 0.0 && stats.top_move_visit_frac <= 1.0,
+ "top_move_visit_frac in (0,1], got {}",
+ stats.top_move_visit_frac
+ );
+
+ drop(mcts);
+ drop(tx);
+ let _ = stub.await;
+ });
+ }
+```
+
+- [ ] **Step 6: Build + run the tests**
+
+Run:
+```bash
+cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo test --no-default-features --features binary selfplay_mcts -- --nocapture
+```
+Expected: all `selfplay_mcts::tests::*` pass, including `test_search_stats_are_sane`, printing no failures.
+
+- [ ] **Step 7: Commit**
+
+```bash
+cd /home/jbinney/ws/deep_rabbit_hole
+git add deep_quoridor/rust/src/agents/alphazero/selfplay_mcts.rs
+git commit -m "vibe: return per-search MCTS SearchStats"
+```
+
+---
+
+## Task 2: `GameMetrics` + move hashing in `play_game_async`
+
+**Files:**
+- Modify: `deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs`
+- Modify: `deep_quoridor/rust/src/bin/selfplay.rs` (two `play_game_async` call sites)
+
+- [ ] **Step 1: Add `GameMetrics`, the opening constant, and the hash helper**
+
+In `selfplay_game.rs`, after the `GameSettings` struct (around line 74), add:
+
+```rust
+/// Number of leading plies that define a game's "opening" for uniqueness.
+pub const OPENING_PLIES: usize = 8;
+
+/// Per-game MCTS diagnostics, summed over the game's searches, plus move-sequence hashes.
+#[derive(Debug, Clone, Default)]
+pub struct GameMetrics {
+ pub sims: u64,
+ pub terminal_wins: u64,
+ pub truncations: u64,
+ pub max_depth: u32,
+ pub sum_depth: u64,
+ pub moves: u64,
+ pub sum_root_entropy: f64,
+ pub sum_top_move_frac: f64,
+ pub sum_nodes: u64,
+ pub sum_internal_nodes: u64,
+ pub full_hash: u64,
+ pub opening_hash: u64,
+}
+
+/// Deterministic (within-process) hash of a move-index sequence.
+fn hash_actions(actions: &[usize]) -> u64 {
+ use std::hash::{Hash, Hasher};
+ let mut h = std::collections::hash_map::DefaultHasher::new();
+ actions.hash(&mut h);
+ h.finish()
+}
+```
+
+Add the `SearchStats` import to the existing `use` of `selfplay_mcts` at the top:
+
+```rust
+use crate::agents::alphazero::selfplay_mcts::{LeafParallelMCTS, SearchStats};
+```
+
+- [ ] **Step 2: Make `run_az_select` return its `SearchStats`**
+
+In `selfplay_game.rs`, change `run_az_select` to thread the stats out. Its first line becomes:
+
+```rust
+ let (children, _root_value, stats) = mcts.search(data, mechanics, visited).await?;
+```
+
+and its return type and final expression:
+
+```rust
+) -> Result<(usize, Vec, SearchStats)> {
+```
+...
+```rust
+ Ok((action_idx, policy, stats))
+```
+
+- [ ] **Step 3: Fold stats + record moves in `play_game_async`, return `(GameResult, GameMetrics)`**
+
+Change the `play_game_async` return type:
+
+```rust
+) -> Result<(GameResult, GameMetrics)> {
+```
+
+After `let mut winner: Option = None;` (around line 90), add:
+
+```rust
+ let mut gm = GameMetrics::default();
+ let mut actions: Vec = Vec::new();
+```
+
+Replace the action-selection block (the `let (action_idx, policy) = if current_player == 0 { ... } else { ... };`, around line 101-110) with a version that captures optional stats:
+
+```rust
+ let (action_idx, policy, stats) = if current_player == 0 {
+ let (a, p, s) =
+ run_az_select(p1, data, &mechanics, &visited, settings, step as usize).await?;
+ (a, p, Some(s))
+ } else {
+ match p2 {
+ P2::AlphaZero(m) => {
+ let (a, p, s) =
+ run_az_select(m, data, &mechanics, &visited, settings, step as usize)
+ .await?;
+ (a, p, Some(s))
+ }
+ P2::Random => {
+ let (a, p) = random_select(&mask);
+ (a, p, None)
+ }
+ }
+ };
+ if let Some(s) = stats {
+ gm.moves += 1;
+ gm.sims += s.sims as u64;
+ gm.terminal_wins += s.terminal_wins as u64;
+ gm.truncations += s.truncations as u64;
+ gm.max_depth = gm.max_depth.max(s.max_depth);
+ gm.sum_depth += s.sum_depth;
+ gm.sum_root_entropy += s.root_visit_entropy;
+ gm.sum_top_move_frac += s.top_move_visit_frac;
+ gm.sum_nodes += s.nodes as u64;
+ gm.sum_internal_nodes += s.internal_nodes as u64;
+ }
+ actions.push(action_idx);
+```
+
+- [ ] **Step 4: Single return point with hashes**
+
+Replace the win-branch early return (around line 143-157) so it sets the winner and `break`s instead of returning:
+
+```rust
+ if mechanics.check_win(data, current_player as usize) {
+ winner = Some(current_player);
+ break;
+ }
+```
+
+Then replace the function's tail (the final `Ok(GameResult { winner, num_turns: max_steps, replay_items })`, around line 159-163) with value assignment + hashing + a single tuple return:
+
+```rust
+ if let Some(w) = winner {
+ for item in replay_items.iter_mut() {
+ item.value = if item.player == w { 1.0 } else { -1.0 };
+ }
+ }
+ gm.full_hash = hash_actions(&actions);
+ gm.opening_hash = hash_actions(&actions[..actions.len().min(OPENING_PLIES)]);
+ let num_turns = if winner.is_some() {
+ actions.len() as i32
+ } else {
+ max_steps
+ };
+ Ok((
+ GameResult {
+ winner,
+ num_turns,
+ replay_items,
+ },
+ gm,
+ ))
+```
+
+(This preserves the prior behavior: on a win, `num_turns` is the number of moves played; on truncation, `max_steps`. Value targets are ±1 from the winner's perspective, identical to before.)
+
+- [ ] **Step 5: Update the two call sites in `selfplay.rs`**
+
+In `run_continuous_batched` (around line 659): change
+```rust
+ let result = play_game_async(&mut p1, &mut p2, settings, board_size, max_walls, max_steps).await?;
+ write_replay(&output_dir, Some(&tmp_dir), &result, mv, idx, pid)?;
+```
+to
+```rust
+ let (result, _game_metrics) = play_game_async(&mut p1, &mut p2, settings, board_size, max_walls, max_steps).await?;
+ write_replay(&output_dir, Some(&tmp_dir), &result, mv, idx, pid)?;
+```
+(The `_game_metrics` binding is replaced with real folding in Task 4; keep the underscore for now so this task builds independently.)
+
+In the batch-mode game loop inline in `main` (the non-`--continuous` path, around line 451): change
+```rust
+ let result = play_game_async(&mut p1, &mut p2, settings, board_size, max_walls, max_steps).await?;
+```
+to
+```rust
+ let (result, _game_metrics) = play_game_async(&mut p1, &mut p2, settings, board_size, max_walls, max_steps).await?;
+```
+
+- [ ] **Step 6: Add a hashing unit test**
+
+Append to the bottom of `selfplay_game.rs` (create a `#[cfg(test)] mod tests` block if none exists):
+
+```rust
+#[cfg(test)]
+mod tests {
+ use super::{hash_actions, OPENING_PLIES};
+
+ #[test]
+ fn identical_sequences_hash_equal_different_differ() {
+ let a = vec![3usize, 7, 1, 9, 2, 4, 8, 0, 5, 6];
+ let b = a.clone();
+ let mut c = a.clone();
+ c[9] = 99; // differs only after the opening
+
+ assert_eq!(hash_actions(&a), hash_actions(&b), "identical games hash equal");
+ assert_ne!(hash_actions(&a), hash_actions(&c), "different full games differ");
+
+ // Same opening (first OPENING_PLIES), different tail -> opening hashes equal.
+ let open_a = hash_actions(&a[..a.len().min(OPENING_PLIES)]);
+ let open_c = hash_actions(&c[..c.len().min(OPENING_PLIES)]);
+ assert_eq!(open_a, open_c, "same opening hashes equal");
+ }
+}
+```
+
+- [ ] **Step 7: Build + test**
+
+Run:
+```bash
+cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo test --no-default-features --features binary selfplay_game -- --nocapture && cargo build --no-default-features --features binary --bin selfplay
+```
+Expected: the hashing test passes and the `selfplay` binary builds (confirms both call sites updated).
+
+- [ ] **Step 8: Commit**
+
+```bash
+cd /home/jbinney/ws/deep_rabbit_hole
+git add deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs deep_quoridor/rust/src/bin/selfplay.rs
+git commit -m "vibe: collect per-game MCTS metrics and move hashes"
+```
+
+---
+
+## Task 3: `SelfPlayAccumulator` + JSON flush
+
+**Files:**
+- Create: `deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs`
+- Modify: `deep_quoridor/rust/src/agents/alphazero/mod.rs`
+- Modify: `deep_quoridor/rust/Cargo.toml`
+
+- [ ] **Step 1: Add `serde_json` to the `binary` feature**
+
+In `deep_quoridor/rust/Cargo.toml`, under `[dependencies]` add (near the other optional deps):
+
+```toml
+serde_json = { version = "1", optional = true }
+```
+
+and add `"serde_json"` to the `binary` feature list, so it reads:
+
+```toml
+binary = ["clap", "ort", "serde_yaml", "serde_json", "ndarray-npy", "zip", "rand_distr", "tokio", "futures"]
+```
+
+- [ ] **Step 2: Create the accumulator module**
+
+Create `deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs`:
+
+```rust
+//! Per-process self-play metric accumulator.
+//!
+//! Folds `GameMetrics` for the current model version, then flushes a raw-aggregate
+//! JSON record per `(version, pid)` so the Python side can combine processes and
+//! compute final metrics. Reset happens on each model-version change and on shutdown.
+
+use std::collections::HashSet;
+
+use anyhow::{Context, Result};
+
+use crate::agents::alphazero::selfplay_game::GameMetrics;
+
+/// Running raw aggregates for one model version within one process.
+pub struct SelfPlayAccumulator {
+ version: i64,
+ sims: u64,
+ terminal_wins: u64,
+ truncations: u64,
+ max_depth: u32,
+ sum_depth: u64,
+ moves: u64,
+ sum_root_entropy: f64,
+ sum_top_move_frac: f64,
+ sum_nodes: u64,
+ sum_internal_nodes: u64,
+ games_generated: u64,
+ full_hashes: HashSet,
+ opening_hashes: HashSet,
+}
+
+impl SelfPlayAccumulator {
+ pub fn new(version: i64) -> Self {
+ Self {
+ version,
+ sims: 0,
+ terminal_wins: 0,
+ truncations: 0,
+ max_depth: 0,
+ sum_depth: 0,
+ moves: 0,
+ sum_root_entropy: 0.0,
+ sum_top_move_frac: 0.0,
+ sum_nodes: 0,
+ sum_internal_nodes: 0,
+ games_generated: 0,
+ full_hashes: HashSet::new(),
+ opening_hashes: HashSet::new(),
+ }
+ }
+
+ pub fn set_version(&mut self, v: i64) {
+ self.version = v;
+ }
+
+ pub fn fold_game(&mut self, gm: &GameMetrics) {
+ self.sims += gm.sims;
+ self.terminal_wins += gm.terminal_wins;
+ self.truncations += gm.truncations;
+ self.max_depth = self.max_depth.max(gm.max_depth);
+ self.sum_depth += gm.sum_depth;
+ self.moves += gm.moves;
+ self.sum_root_entropy += gm.sum_root_entropy;
+ self.sum_top_move_frac += gm.sum_top_move_frac;
+ self.sum_nodes += gm.sum_nodes;
+ self.sum_internal_nodes += gm.sum_internal_nodes;
+ self.games_generated += 1;
+ self.full_hashes.insert(gm.full_hash);
+ self.opening_hashes.insert(gm.opening_hash);
+ }
+
+ fn clear_counts(&mut self) {
+ self.sims = 0;
+ self.terminal_wins = 0;
+ self.truncations = 0;
+ self.max_depth = 0;
+ self.sum_depth = 0;
+ self.moves = 0;
+ self.sum_root_entropy = 0.0;
+ self.sum_top_move_frac = 0.0;
+ self.sum_nodes = 0;
+ self.sum_internal_nodes = 0;
+ self.games_generated = 0;
+ self.full_hashes.clear();
+ self.opening_hashes.clear();
+ }
+
+ fn to_json(&self, pid: u32) -> serde_json::Value {
+ serde_json::json!({
+ "model_version": self.version,
+ "pid": pid,
+ "sims": self.sims,
+ "terminal_wins": self.terminal_wins,
+ "truncations": self.truncations,
+ "max_depth": self.max_depth,
+ "sum_depth": self.sum_depth,
+ "moves": self.moves,
+ "sum_root_entropy": self.sum_root_entropy,
+ "sum_top_move_frac": self.sum_top_move_frac,
+ "sum_nodes": self.sum_nodes,
+ "sum_internal_nodes": self.sum_internal_nodes,
+ "games_generated": self.games_generated,
+ "unique_full": self.full_hashes.len(),
+ "unique_opening": self.opening_hashes.len(),
+ })
+ }
+
+ /// Write the current version's record to `/v{version}_pid{pid}.json` (atomic
+ /// tmp+rename), then clear counts. No-op (just clears) when nothing was accumulated.
+ pub fn flush_and_reset(&mut self, dir: &str, pid: u32) -> Result<()> {
+ if self.moves == 0 && self.games_generated == 0 {
+ self.clear_counts();
+ return Ok(());
+ }
+ let path = format!("{}/v{}_pid{}.json", dir, self.version, pid);
+ let tmp = format!("{}.tmp", path);
+ let bytes = serde_json::to_vec(&self.to_json(pid)).context("serialize metrics record")?;
+ std::fs::write(&tmp, &bytes).with_context(|| format!("write {}", tmp))?;
+ std::fs::rename(&tmp, &path).with_context(|| format!("rename to {}", path))?;
+ self.clear_counts();
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::agents::alphazero::selfplay_game::GameMetrics;
+
+ fn sample_game(full: u64, opening: u64) -> GameMetrics {
+ GameMetrics {
+ sims: 100,
+ terminal_wins: 5,
+ truncations: 2,
+ max_depth: 10,
+ sum_depth: 300,
+ moves: 20,
+ sum_root_entropy: 12.0,
+ sum_top_move_frac: 8.0,
+ sum_nodes: 500,
+ sum_internal_nodes: 250,
+ full_hash: full,
+ opening_hash: opening,
+ }
+ }
+
+ #[test]
+ fn flush_writes_expected_aggregates() {
+ let dir = std::env::temp_dir().join(format!("spm_test_{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let dir_s = dir.to_str().unwrap().to_string();
+
+ let mut acc = SelfPlayAccumulator::new(7);
+ acc.fold_game(&sample_game(1, 100)); // unique full=1, opening=100
+ acc.fold_game(&sample_game(2, 100)); // distinct full, same opening
+ acc.fold_game(&sample_game(2, 100)); // duplicate of the previous
+ acc.flush_and_reset(&dir_s, 4242).unwrap();
+
+ let path = format!("{}/v7_pid4242.json", dir_s);
+ let v: serde_json::Value =
+ serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
+ assert_eq!(v["model_version"], 7);
+ assert_eq!(v["games_generated"], 3);
+ assert_eq!(v["sims"], 300);
+ assert_eq!(v["unique_full"], 2); // hashes {1,2}
+ assert_eq!(v["unique_opening"], 1); // hashes {100}
+ assert_eq!(v["max_depth"], 10);
+
+ // After flush the accumulator is empty: a second flush writes nothing new.
+ std::fs::remove_file(&path).unwrap();
+ acc.flush_and_reset(&dir_s, 4242).unwrap();
+ assert!(!std::path::Path::new(&path).exists(), "empty flush writes nothing");
+
+ std::fs::remove_dir_all(&dir).ok();
+ }
+}
+```
+
+- [ ] **Step 3: Register the module**
+
+In `deep_quoridor/rust/src/agents/alphazero/mod.rs`, add alongside the other `pub mod` lines:
+
+```rust
+pub mod selfplay_metrics;
+```
+
+- [ ] **Step 4: Build + test**
+
+```bash
+cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo test --no-default-features --features binary selfplay_metrics -- --nocapture
+```
+Expected: `flush_writes_expected_aggregates` passes (downloads `serde_json` on first build).
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /home/jbinney/ws/deep_rabbit_hole
+git add deep_quoridor/rust/Cargo.toml deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs deep_quoridor/rust/src/agents/alphazero/mod.rs
+git commit -m "vibe: add SelfPlayAccumulator with JSON metric flush"
+```
+
+---
+
+## Task 4: Wire metrics into the continuous self-play loop
+
+**Files:**
+- Modify: `deep_quoridor/rust/src/bin/selfplay.rs`
+
+- [ ] **Step 1: Add the `--metrics-dir` CLI arg**
+
+In the `Cli` struct (`selfplay.rs`, after the `profile_counters` field around line 125), add:
+
+```rust
+ /// Directory to write per-model-version MCTS metric JSON records. When omitted,
+ /// metric collection is disabled.
+ #[arg(long)]
+ metrics_dir: Option,
+```
+
+- [ ] **Step 2: Create the accumulator before the game tasks spawn**
+
+In `run_continuous_batched`, inside the `rt.block_on(async move { ... })`, after `let pid = std::process::id();` (around line 595), add:
+
+```rust
+ use quoridor_rs::agents::alphazero::selfplay_metrics::SelfPlayAccumulator;
+ let metrics_dir = cli.metrics_dir.clone();
+ if let Some(ref d) = metrics_dir {
+ std::fs::create_dir_all(d)?;
+ }
+ let metrics = std::sync::Arc::new(std::sync::Mutex::new(SelfPlayAccumulator::new(
+ initial_version,
+ )));
+```
+
+- [ ] **Step 3: Clone the handle into the game-task closure and fold each game**
+
+In the `for _tid in 0..rust_cfg.games_per_process { ... }` loop, alongside the other `let ... = std::sync::Arc::clone(&...)` clones (around line 632-643), add:
+
+```rust
+ let metrics = std::sync::Arc::clone(&metrics);
+ let metrics_enabled = metrics_dir.is_some();
+```
+
+Then change the game body (the `_game_metrics` binding added in Task 2, around line 659) to fold when enabled:
+
+```rust
+ let (result, game_metrics) = play_game_async(&mut p1, &mut p2, settings, board_size, max_walls, max_steps).await?;
+ write_replay(&output_dir, Some(&tmp_dir), &result, mv, idx, pid)?;
+ if metrics_enabled {
+ metrics.lock().unwrap().fold_game(&game_metrics);
+ }
+```
+
+- [ ] **Step 4: Flush on version change and on shutdown in the poll task**
+
+Clone the handle for the poll task. Alongside the poll task's existing clones (around line 668-670), add:
+
+```rust
+ let metrics = std::sync::Arc::clone(&metrics);
+ let metrics_dir = metrics_dir.clone();
+ let pid_for_metrics = pid;
+```
+
+In the poll task body, in the new-model branch (right after `model_version.store(latest.version, ...)`, around line 685), flush the just-finished version then point the accumulator at the new one:
+
+```rust
+ if let Some(ref d) = metrics_dir {
+ let mut m = metrics.lock().unwrap();
+ if let Err(e) = m.flush_and_reset(d, pid_for_metrics) {
+ eprintln!("selfplay-metrics: flush failed: {:#}", e);
+ }
+ m.set_version(latest.version);
+ }
+```
+
+And in the shutdown branch (right after `shutdown.store(true, ...)`, around line 676), flush the final partial version:
+
+```rust
+ if let Some(ref d) = metrics_dir {
+ let mut m = metrics.lock().unwrap();
+ if let Err(e) = m.flush_and_reset(d, pid_for_metrics) {
+ eprintln!("selfplay-metrics: final flush failed: {:#}", e);
+ }
+ }
+```
+
+- [ ] **Step 5: Build**
+
+```bash
+cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo build --no-default-features --features binary --bin selfplay
+```
+Expected: builds with no errors or warnings.
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /home/jbinney/ws/deep_rabbit_hole
+git add deep_quoridor/rust/src/bin/selfplay.rs
+git commit -m "vibe: flush self-play MCTS metrics per model version"
+```
+
+---
+
+## Task 5: Python aggregator + logger process
+
+**Files:**
+- Create: `deep_quoridor/src/v2/selfplay_metrics.py`
+- Create: `deep_quoridor/test/test_selfplay_metrics.py`
+
+- [ ] **Step 1: Write the failing aggregator test**
+
+Create `deep_quoridor/test/test_selfplay_metrics.py`:
+
+```python
+import math
+
+from v2.selfplay_metrics import aggregate_records
+
+
+def _record(**kw):
+ base = dict(
+ model_version=3, pid=1, sims=0, terminal_wins=0, truncations=0,
+ max_depth=0, sum_depth=0, moves=0, sum_root_entropy=0.0,
+ sum_top_move_frac=0.0, sum_nodes=0, sum_internal_nodes=0,
+ games_generated=0, unique_full=0, unique_opening=0,
+ )
+ base.update(kw)
+ return base
+
+
+def test_aggregate_combines_two_processes():
+ r1 = _record(
+ sims=100, terminal_wins=10, truncations=5, max_depth=12, sum_depth=400,
+ moves=20, sum_root_entropy=20.0, sum_top_move_frac=10.0, sum_nodes=600,
+ sum_internal_nodes=300, games_generated=2, unique_full=2, unique_opening=1,
+ )
+ r2 = _record(
+ sims=300, terminal_wins=30, truncations=15, max_depth=18, sum_depth=1200,
+ moves=60, sum_root_entropy=66.0, sum_top_move_frac=36.0, sum_nodes=1800,
+ sum_internal_nodes=900, games_generated=6, unique_full=5, unique_opening=2,
+ )
+ agg = aggregate_records([r1, r2])
+
+ sims, moves = 400, 80
+ assert agg["selfplay/terminal_sim_frac"] == 40 / sims
+ assert agg["selfplay/truncation_sim_frac"] == 20 / sims
+ assert agg["selfplay/max_tree_depth"] == 18
+ assert agg["selfplay/mean_tree_depth"] == 1600 / sims
+ assert agg["selfplay/root_visit_entropy"] == 86.0 / moves
+ assert agg["selfplay/root_visit_perplexity"] == math.exp(86.0 / moves)
+ assert agg["selfplay/top_move_visit_frac"] == 46.0 / moves
+ assert agg["selfplay/mean_nodes_per_search"] == 2400 / moves
+ assert agg["selfplay/mean_branching"] == (2400 - moves) / 1200
+ assert agg["selfplay/games_generated"] == 8
+ assert agg["selfplay/unique_games_full"] == 7
+ assert agg["selfplay/unique_games_opening"] == 3
+ assert agg["selfplay/unique_frac_full"] == 7 / 8
+ assert agg["selfplay/unique_frac_opening"] == 3 / 8
+
+
+def test_aggregate_skips_empty():
+ assert aggregate_records([_record(moves=0, sims=0)]) is None
+```
+
+- [ ] **Step 2: Run the test to confirm it fails**
+
+```bash
+cd /home/jbinney/ws/deep_rabbit_hole && PYTHONPATH=$PYTHONPATH:$(pwd)/deep_quoridor/src .venv/bin/python -m pytest deep_quoridor/test/test_selfplay_metrics.py -q
+```
+Expected: FAIL with `ModuleNotFoundError: No module named 'v2.selfplay_metrics'`.
+
+- [ ] **Step 3: Implement the module**
+
+Create `deep_quoridor/src/v2/selfplay_metrics.py`:
+
+```python
+"""Self-play MCTS metrics: aggregate per-process Rust JSON records and log to W&B.
+
+The Rust self-play binary writes one raw-aggregate record per (model_version, pid)
+to a metrics directory. This module combines a version's records across processes,
+computes the final metrics, and logs them to a W&B run in the training group.
+"""
+import glob
+import json
+import math
+import os
+import re
+import time
+from typing import Optional
+
+import wandb
+
+from v2.common import MockWandb, ShutdownSignal
+from v2.config import Config
+
+_FILE_RE = re.compile(r"v(\d+)_pid\d+\.json$")
+
+
+def metrics_dir_for(config: Config) -> str:
+ """Directory shared by the Rust writer and this reader."""
+ return str(config.paths.run_dir / "selfplay_metrics")
+
+
+def aggregate_records(records: list[dict]) -> Optional[dict]:
+ """Combine per-process raw records for one model version into final metrics.
+
+ Returns None if no searches happened (nothing to log).
+ """
+ sims = sum(r["sims"] for r in records)
+ moves = sum(r["moves"] for r in records)
+ if moves == 0 or sims == 0:
+ return None
+
+ sum_entropy = sum(r["sum_root_entropy"] for r in records)
+ sum_nodes = sum(r["sum_nodes"] for r in records)
+ sum_internal = sum(r["sum_internal_nodes"] for r in records)
+ games = sum(r["games_generated"] for r in records)
+ unique_full = sum(r["unique_full"] for r in records)
+ unique_opening = sum(r["unique_opening"] for r in records)
+ mean_entropy = sum_entropy / moves
+
+ out = {
+ "selfplay/terminal_sim_frac": sum(r["terminal_wins"] for r in records) / sims,
+ "selfplay/truncation_sim_frac": sum(r["truncations"] for r in records) / sims,
+ "selfplay/max_tree_depth": max(r["max_depth"] for r in records),
+ "selfplay/mean_tree_depth": sum(r["sum_depth"] for r in records) / sims,
+ "selfplay/root_visit_entropy": mean_entropy,
+ "selfplay/root_visit_perplexity": math.exp(mean_entropy),
+ "selfplay/top_move_visit_frac": sum(r["sum_top_move_frac"] for r in records) / moves,
+ "selfplay/mean_nodes_per_search": sum_nodes / moves,
+ "selfplay/mean_branching": (sum_nodes - moves) / sum_internal if sum_internal else 0.0,
+ "selfplay/games_generated": games,
+ "selfplay/unique_games_full": unique_full,
+ "selfplay/unique_games_opening": unique_opening,
+ "selfplay/unique_frac_full": unique_full / games if games else 0.0,
+ "selfplay/unique_frac_opening": unique_opening / games if games else 0.0,
+ }
+ return out
+
+
+def _scan(metrics_dir: str) -> dict[int, list[str]]:
+ """Map model_version -> list of record file paths present on disk."""
+ by_version: dict[int, list[str]] = {}
+ for path in glob.glob(os.path.join(metrics_dir, "v*_pid*.json")):
+ m = _FILE_RE.search(os.path.basename(path))
+ if m:
+ by_version.setdefault(int(m.group(1)), []).append(path)
+ return by_version
+
+
+def _load(paths: list[str]) -> list[dict]:
+ records = []
+ for p in paths:
+ try:
+ with open(p) as f:
+ records.append(json.load(f))
+ except (OSError, json.JSONDecodeError):
+ continue # mid-write or transient; picked up on a later poll
+ return records
+
+
+def run_selfplay_metrics(config: Config, poll_seconds: float = 5.0):
+ """Poll the metrics dir and log each completed model version to W&B once."""
+ metrics_dir = metrics_dir_for(config)
+ os.makedirs(metrics_dir, exist_ok=True)
+
+ if config.wandb:
+ run_id = f"{config.run_id}-selfplay"
+ wandb_run = wandb.init(
+ project=config.wandb.project,
+ job_type="selfplay",
+ group=config.run_id,
+ name=run_id,
+ id=run_id,
+ resume="allow",
+ )
+ wandb.define_metric("Model version", hidden=True)
+ wandb.define_metric("*", "Model version")
+ else:
+ wandb_run = MockWandb()
+
+ logged: set[int] = set()
+
+ def flush(finalize_all: bool):
+ by_version = _scan(metrics_dir)
+ if not by_version:
+ return
+ max_version = max(by_version)
+ for version in sorted(by_version):
+ if version in logged:
+ continue
+ # A version is complete once a newer version exists (the writer moved on)
+ # or we're finalizing on shutdown.
+ if not finalize_all and version >= max_version:
+ continue
+ agg = aggregate_records(_load(by_version[version]))
+ if agg is not None:
+ agg["Model version"] = version
+ wandb_run.log(agg)
+ logged.add(version)
+
+ while not ShutdownSignal.is_set(config):
+ flush(finalize_all=False)
+ time.sleep(poll_seconds)
+
+ flush(finalize_all=True) # log the last in-progress version on shutdown
+```
+
+- [ ] **Step 4: Run the test to confirm it passes**
+
+```bash
+cd /home/jbinney/ws/deep_rabbit_hole && PYTHONPATH=$PYTHONPATH:$(pwd)/deep_quoridor/src .venv/bin/python -m pytest deep_quoridor/test/test_selfplay_metrics.py -q
+```
+Expected: `2 passed`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /home/jbinney/ws/deep_rabbit_hole
+git add deep_quoridor/src/v2/selfplay_metrics.py deep_quoridor/test/test_selfplay_metrics.py
+git commit -m "vibe: add self-play metrics aggregator and W&B logger"
+```
+
+---
+
+## Task 6: Spawn the logger and pass `--metrics-dir` from `train_v2.py`
+
+**Files:**
+- Modify: `deep_quoridor/src/v2/__init__.py`
+- Modify: `deep_quoridor/src/train_v2.py`
+
+- [ ] **Step 1: Export `run_selfplay_metrics` from the package**
+
+In `deep_quoridor/src/v2/__init__.py`, add to `__all__` (the list that includes `"create_benchmark_processes"`):
+
+```python
+ "run_selfplay_metrics",
+```
+
+and add the import (next to `from v2.benchmarks import create_benchmark_processes`):
+
+```python
+from v2.selfplay_metrics import metrics_dir_for, run_selfplay_metrics
+```
+
+Also add `"metrics_dir_for"` to `__all__`.
+
+- [ ] **Step 2: Import in `train_v2.py` and pass `--metrics-dir` to the Rust subprocess**
+
+In `deep_quoridor/src/train_v2.py`, update the `from v2 import (...)` line (line 8) to also import the two new names:
+
+```python
+from v2 import (
+ benchmarks,
+ check_ai_available,
+ load_config_and_setup_run,
+ metrics_dir_for,
+ run_ai_reporter,
+ run_selfplay_metrics,
+ self_play,
+ train,
+)
+```
+
+In the rust-spawn block (`if config.self_play.program == "rust":`, around line 93), before the `for i in range(...)` loop, compute the metrics dir:
+
+```python
+ selfplay_env = _selfplay_subprocess_env()
+ if selfplay_env is not None:
+ print(f"Self-play GPU env: ORT_DYLIB_PATH={selfplay_env['ORT_DYLIB_PATH']}")
+ metrics_dir = metrics_dir_for(config)
+ os.makedirs(metrics_dir, exist_ok=True)
+ config_file_path = str(config.paths.config_file)
+```
+
+and add the two args to `cmd` (inside the list, after the `--shutdown-file` pair):
+
+```python
+ "--shutdown-file",
+ str(ShutdownSignal.file_path(config)),
+ "--metrics-dir",
+ metrics_dir,
+```
+
+- [ ] **Step 3: Spawn the metrics logger process (rust path only)**
+
+Still inside the `if config.self_play.program == "rust":` block, after the `for` loop that starts the rust subprocesses (after the `print(f"Started Rust self-play process {proc.pid}")` line), add:
+
+```python
+ selfplay_metrics_process = mp.Process(target=run_selfplay_metrics, args=[config])
+ selfplay_metrics_process.start()
+ self_play_processes.append(selfplay_metrics_process)
+```
+
+(Appending to `self_play_processes` means the existing shutdown/`is_alive()` accounting at lines 128-129 already tracks and waits on it.)
+
+- [ ] **Step 4: Syntax check**
+
+```bash
+cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor && PYTHONPATH=$(pwd)/src ../.venv/bin/python -m py_compile src/train_v2.py src/v2/__init__.py src/v2/selfplay_metrics.py && echo PY_OK
+```
+Expected: `PY_OK`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /home/jbinney/ws/deep_rabbit_hole
+git add deep_quoridor/src/v2/__init__.py deep_quoridor/src/train_v2.py
+git commit -m "vibe: spawn self-play metrics logger from train_v2"
+```
+
+---
+
+## Task 7: Formatting commit (per AGENTS.md)
+
+- [ ] **Step 1: Format Rust and Python**
+
+```bash
+cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo fmt && cargo fmt --check && echo FMT_OK
+cd /home/jbinney/ws/deep_rabbit_hole && .venv/bin/ruff format deep_quoridor/src/v2/selfplay_metrics.py deep_quoridor/test/test_selfplay_metrics.py deep_quoridor/src/train_v2.py deep_quoridor/src/v2/__init__.py 2>/dev/null || true
+cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo build --no-default-features --features binary --bin selfplay
+```
+Expected: `FMT_OK`, build succeeds.
+
+- [ ] **Step 2: Commit only if formatting changed files**
+
+```bash
+cd /home/jbinney/ws/deep_rabbit_hole
+git status --short deep_quoridor/rust deep_quoridor/src
+# If any files changed:
+git add -u deep_quoridor/rust deep_quoridor/src
+git commit -m "vibe: cargo fmt + ruff format"
+```
+If nothing changed, skip the commit.
+
+---
+
+## Manual end-to-end verification (after all tasks)
+
+Not a unit test — a smoke check the implementer should run once with a real model and the GPU env exported (`ORT_DYLIB_PATH` + `LD_LIBRARY_PATH` as in prior work), building with `--features binary,gpu`:
+
+1. Start `train_v2.py` on the b9w10 config with a fresh `run_id` for a few minutes (enough for ≥2 model updates).
+2. Confirm JSON records appear under `runs//selfplay_metrics/` named `v{N}_pid{PID}.json`.
+3. Confirm a W&B run `${run_id}-selfplay` exists in group `${run_id}` with `selfplay/*` metrics plotted against `Model version`.
+4. Sanity-check values: `terminal_sim_frac > 0`, `max_tree_depth ≥ mean_tree_depth ≥ 1`, `top_move_visit_frac ∈ (0,1]`, `unique_frac_full` near 1.0 early on.
+
+## Self-review (completed during authoring)
+
+- **Spec coverage:** terminal/truncation frac, max+mean depth, root entropy/perplexity, top-move frac, nodes/branching (Task 1 + Task 5 aggregation); unique full+opening with K=8 (Task 2 `OPENING_PLIES`, per-process dedup in Task 3, summed in Task 5); reset-on-model-update + shutdown flush (Task 4); file-based bridge + Python logger in the run group, x-axis Model version (Tasks 3/5/6); disabled unless `--metrics-dir` (Task 4); testing (Tasks 1/2/3/5) — all mapped.
+- **Placeholder scan:** none — every step has concrete code/commands.
+- **Type consistency:** `SearchStats` (Task 1) fields are consumed exactly by `GameMetrics` folding (Task 2) and `SelfPlayAccumulator.fold_game` (Task 3); JSON keys written by `to_json` (Task 3) match those read by `aggregate_records` (Task 5) and the test record (Task 5); `metrics_dir_for` defined in Task 5 is used in Task 6; `play_game_async`'s `(GameResult, GameMetrics)` return is consistently destructured at both call sites (Task 2) and folded (Task 4).
From c5befa0050d945b6cad1488834c099fcdd7f7957 Mon Sep 17 00:00:00 2001
From: Jon Binney
Date: Tue, 26 May 2026 21:31:41 -0400
Subject: [PATCH 04/30] vibe: return per-search MCTS SearchStats
Co-Authored-By: Claude Sonnet 4.6
---
.../src/agents/alphazero/selfplay_game.rs | 2 +-
.../src/agents/alphazero/selfplay_mcts.rs | 118 +++++++++++++++++-
2 files changed, 113 insertions(+), 7 deletions(-)
diff --git a/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs b/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs
index 1044b9ac..af9402b2 100644
--- a/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs
+++ b/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs
@@ -171,7 +171,7 @@ async fn run_az_select(
settings: GameSettings,
step: usize,
) -> Result<(usize, Vec)> {
- let (children, _root_value) = mcts.search(data, mechanics, visited).await?;
+ let (children, _root_value, _stats) = mcts.search(data, mechanics, visited).await?;
let visit_counts: Vec = children.iter().map(|c| c.visit_count).collect();
let action_indices: Vec = children.iter().map(|c| c.action_index).collect();
let temperature = match settings.drop_t_on_step {
diff --git a/deep_quoridor/rust/src/agents/alphazero/selfplay_mcts.rs b/deep_quoridor/rust/src/agents/alphazero/selfplay_mcts.rs
index d7988caf..32277236 100644
--- a/deep_quoridor/rust/src/agents/alphazero/selfplay_mcts.rs
+++ b/deep_quoridor/rust/src/agents/alphazero/selfplay_mcts.rs
@@ -29,6 +29,29 @@ pub struct LeafParallelConfig {
pub enable_tree_reuse: bool,
}
+/// Lightweight per-search diagnostics, accumulated cheaply during one `search()`.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct SearchStats {
+ /// Number of MCTS simulations (selected leaves) this search.
+ pub sims: u32,
+ /// Simulations whose selected leaf was a win-terminal game state.
+ pub terminal_wins: u32,
+ /// Simulations whose selected leaf hit the max_steps cap.
+ pub truncations: u32,
+ /// Deepest selection path length (nodes from root to leaf, inclusive).
+ pub max_depth: u32,
+ /// Sum of selection-path lengths (divide by `sims` for mean depth).
+ pub sum_depth: u64,
+ /// Total nodes in the arena at search end.
+ pub nodes: u32,
+ /// Arena nodes that have at least one child (internal/expanded nodes).
+ pub internal_nodes: u32,
+ /// Entropy (nats) of the root child visit distribution.
+ pub root_visit_entropy: f64,
+ /// Fraction of root visits on the single most-visited child.
+ pub top_move_visit_frac: f64,
+}
+
/// One LeafParallelMCTS per game agent. Lives across moves so tree reuse can
/// preserve the subtree of the chosen child.
pub struct LeafParallelMCTS {
@@ -104,7 +127,7 @@ impl LeafParallelMCTS {
root_data: CompactState,
mechanics: &QGameMechanics,
visited_states: &HashSet,
- ) -> Result<(Vec, f32)> {
+ ) -> Result<(Vec, f32, SearchStats)> {
// Fresh arena unless we have a reusable one matching the root state.
let mut arena = match self.arena.take() {
Some(a) if a.get(0).data == root_data => a,
@@ -144,6 +167,7 @@ impl LeafParallelMCTS {
self.cfg.k.unwrap_or(10) * action_mask.iter().filter(|&&m| m).count() as u32
});
+ let mut stats = SearchStats::default();
let mut iters_done: u32 = 0;
let k = self.lp.leaf_parallelism.max(1);
let vl = self.lp.virtual_loss;
@@ -176,6 +200,13 @@ impl LeafParallelMCTS {
let leaf_idx = *path.last().unwrap();
let leaf_data = arena.get(leaf_idx).data;
+ let depth = path.len() as u32;
+ stats.sims += 1;
+ stats.sum_depth += depth as u64;
+ if depth > stats.max_depth {
+ stats.max_depth = depth;
+ }
+
// Terminal?
if mechanics.is_game_over(leaf_data) {
// Terminal value convention: matches the synchronous mcts::search reference.
@@ -187,11 +218,17 @@ impl LeafParallelMCTS {
0.0
};
items.push(Item::Terminal { path, value: v });
+ if v > 0.0 {
+ stats.terminal_wins += 1;
+ } else {
+ stats.truncations += 1;
+ }
continue;
}
if let Some(max) = self.cfg.max_steps {
if mechanics.repr().get_completed_steps(leaf_data) >= max as usize {
items.push(Item::Terminal { path, value: 0.0 });
+ stats.truncations += 1;
continue;
}
}
@@ -294,10 +331,32 @@ impl LeafParallelMCTS {
})
.collect();
+ // Root visit spread (entropy in nats + top-move fraction).
+ let total_visits: u64 = children.iter().map(|c| c.visit_count as u64).sum();
+ if total_visits > 0 {
+ let mut entropy = 0.0f64;
+ let mut max_v = 0u32;
+ for c in &children {
+ if c.visit_count > 0 {
+ let p = c.visit_count as f64 / total_visits as f64;
+ entropy -= p * p.ln();
+ if c.visit_count > max_v {
+ max_v = c.visit_count;
+ }
+ }
+ }
+ stats.root_visit_entropy = entropy;
+ stats.top_move_visit_frac = max_v as f64 / total_visits as f64;
+ }
+ stats.nodes = arena.len() as u32;
+ stats.internal_nodes = (0..arena.len())
+ .filter(|&i| !arena.get(i).children.is_empty())
+ .count() as u32;
+
// Stash the arena for tree reuse on the next call.
self.arena = Some(arena);
- Ok((children, computed_root_value))
+ Ok((children, computed_root_value, stats))
}
/// Run a single eval through the pipeline (used to seed root expansion).
@@ -407,7 +466,7 @@ mod tests {
LeafParallelMCTS::new(mcts_cfg.clone(), lp_cfg, tx.clone(), Arc::clone(&cache));
let visited = std::collections::HashSet::new();
- let (children, _root_value) = mcts.search(data, &mech, &visited).await.unwrap();
+ let (children, _root_value, _stats) = mcts.search(data, &mech, &visited).await.unwrap();
let total: u32 = children.iter().map(|c| c.visit_count).sum();
// Should be exactly n iterations of MCTS expansion under the root.
assert!(total >= 20, "expected ≥20 child visits, got {}", total);
@@ -448,7 +507,7 @@ mod tests {
let mut mcts = LeafParallelMCTS::new(mcts_cfg, lp_cfg, tx.clone(), Arc::clone(&cache));
let visited = std::collections::HashSet::new();
- let (children, _) = mcts.search(data, &mech, &visited).await.unwrap();
+ let (children, _, _stats) = mcts.search(data, &mech, &visited).await.unwrap();
let visited_top: u32 = children.iter().filter(|c| c.visit_count > 0).count() as u32;
assert!(
visited_top >= 2,
@@ -491,14 +550,14 @@ mod tests {
let visited = std::collections::HashSet::new();
// First search at the initial state.
- let (children_1, _) = mcts.search(data, &mech, &visited).await.unwrap();
+ let (children_1, _, _stats) = mcts.search(data, &mech, &visited).await.unwrap();
let chosen = children_1.iter().max_by_key(|c| c.visit_count).unwrap();
// Advance root and search again from the resulting state.
let mut next_state = data;
mech.apply_action_index(&mut next_state, chosen.action_index);
mcts.advance_root(chosen.action_index);
- let (children_2, _) = mcts.search(next_state, &mech, &visited).await.unwrap();
+ let (children_2, _, _stats) = mcts.search(next_state, &mech, &visited).await.unwrap();
assert!(!children_2.is_empty());
// Drop mcts (holds Sender clone) before tx, then await stub.
@@ -508,6 +567,53 @@ mod tests {
});
}
+ #[test]
+ fn test_search_stats_are_sane() {
+ let rt = tokio::runtime::Builder::new_multi_thread()
+ .worker_threads(2)
+ .enable_all()
+ .build()
+ .unwrap();
+ rt.block_on(async {
+ let mech = QGameMechanics::new(5, 0, 200);
+ let data = mech.create_initial_state();
+ let cache = Arc::new(EvalCache::new());
+ let (tx, rx) = tokio_mpsc::channel::(128);
+ let stub = spawn_stub_coordinator(rx, Arc::clone(&cache));
+
+ let mcts_cfg = MCTSConfig {
+ n: Some(40),
+ ucb_c: 1.4,
+ noise_epsilon: 0.0,
+ ..Default::default()
+ };
+ let lp_cfg = LeafParallelConfig {
+ leaf_parallelism: 4,
+ virtual_loss: 1,
+ enable_tree_reuse: false,
+ };
+ let mut mcts = LeafParallelMCTS::new(mcts_cfg, lp_cfg, tx.clone(), Arc::clone(&cache));
+ let visited = std::collections::HashSet::new();
+ let (_children, _v, stats) = mcts.search(data, &mech, &visited).await.unwrap();
+
+ assert_eq!(stats.sims, 40, "sims should equal mcts_n");
+ assert!(stats.max_depth >= 1, "max_depth must be >= 1");
+ assert!(stats.sum_depth >= stats.sims as u64, "each sim has depth >= 1");
+ assert!(stats.nodes >= 1);
+ assert!(stats.internal_nodes >= 1);
+ assert!(stats.root_visit_entropy >= 0.0);
+ assert!(
+ stats.top_move_visit_frac > 0.0 && stats.top_move_visit_frac <= 1.0,
+ "top_move_visit_frac in (0,1], got {}",
+ stats.top_move_visit_frac
+ );
+
+ drop(mcts);
+ drop(tx);
+ let _ = stub.await;
+ });
+ }
+
#[test]
fn test_note_model_version_clears_tree_on_change() {
let rt = tokio::runtime::Builder::new_multi_thread()
From eac37268722ba77d2b90dc023b29ad909aabe78c Mon Sep 17 00:00:00 2001
From: Jon Binney
Date: Tue, 26 May 2026 21:37:54 -0400
Subject: [PATCH 05/30] vibe: collect per-game MCTS metrics and move hashes
Co-Authored-By: Claude Sonnet 4.6
---
.../src/agents/alphazero/selfplay_game.rs | 127 ++++++++++++++----
deep_quoridor/rust/src/bin/selfplay.rs | 4 +-
2 files changed, 103 insertions(+), 28 deletions(-)
diff --git a/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs b/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs
index af9402b2..07a9bb27 100644
--- a/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs
+++ b/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs
@@ -9,7 +9,7 @@ use std::collections::HashSet;
use anyhow::Result;
use ndarray::Axis;
-use crate::agents::alphazero::selfplay_mcts::LeafParallelMCTS;
+use crate::agents::alphazero::selfplay_mcts::{LeafParallelMCTS, SearchStats};
use crate::compact::q_bit_repr::CompactState;
use crate::compact::q_game_mechanics::QGameMechanics;
use crate::game_runner::{GameResult, ReplayBufferItem};
@@ -65,6 +65,34 @@ fn sample_action(
action_indices[action_indices.len() - 1]
}
+/// Number of leading plies that define a game's "opening" for uniqueness.
+pub const OPENING_PLIES: usize = 8;
+
+/// Per-game MCTS diagnostics, summed over the game's searches, plus move-sequence hashes.
+#[derive(Debug, Clone, Default)]
+pub struct GameMetrics {
+ pub sims: u64,
+ pub terminal_wins: u64,
+ pub truncations: u64,
+ pub max_depth: u32,
+ pub sum_depth: u64,
+ pub moves: u64,
+ pub sum_root_entropy: f64,
+ pub sum_top_move_frac: f64,
+ pub sum_nodes: u64,
+ pub sum_internal_nodes: u64,
+ pub full_hash: u64,
+ pub opening_hash: u64,
+}
+
+/// Deterministic (within-process) hash of a move-index sequence.
+fn hash_actions(actions: &[usize]) -> u64 {
+ use std::hash::{Hash, Hasher};
+ let mut h = std::collections::hash_map::DefaultHasher::new();
+ actions.hash(&mut h);
+ h.finish()
+}
+
/// Per-game settings for action selection.
#[derive(Debug, Clone, Copy)]
pub struct GameSettings {
@@ -80,7 +108,7 @@ pub async fn play_game_async(
board_size: i32,
max_walls: i32,
max_steps: i32,
-) -> Result {
+) -> Result<(GameResult, GameMetrics)> {
let mechanics =
QGameMechanics::new(board_size as usize, max_walls as usize, max_steps as usize);
let mut data = mechanics.create_initial_state();
@@ -88,6 +116,8 @@ pub async fn play_game_async(
let mut replay_items: Vec = Vec::new();
let visited = HashSet::new();
let mut winner: Option = None;
+ let mut gm = GameMetrics::default();
+ let mut actions: Vec = Vec::new();
for step in 0..max_steps {
let current_player = mechanics.repr().get_current_player(data) as i32;
@@ -98,16 +128,37 @@ pub async fn play_game_async(
let resnet_input = compact_state_to_resnet_input(&mechanics, data);
- let (action_idx, policy) = if current_player == 0 {
- run_az_select(p1, data, &mechanics, &visited, settings, step as usize).await?
+ let (action_idx, policy, stats) = if current_player == 0 {
+ let (a, p, s) =
+ run_az_select(p1, data, &mechanics, &visited, settings, step as usize).await?;
+ (a, p, Some(s))
} else {
match p2 {
P2::AlphaZero(m) => {
- run_az_select(m, data, &mechanics, &visited, settings, step as usize).await?
+ let (a, p, s) =
+ run_az_select(m, data, &mechanics, &visited, settings, step as usize)
+ .await?;
+ (a, p, Some(s))
+ }
+ P2::Random => {
+ let (a, p) = random_select(&mask);
+ (a, p, None)
}
- P2::Random => random_select(&mask),
}
};
+ if let Some(s) = stats {
+ gm.moves += 1;
+ gm.sims += s.sims as u64;
+ gm.terminal_wins += s.terminal_wins as u64;
+ gm.truncations += s.truncations as u64;
+ gm.max_depth = gm.max_depth.max(s.max_depth);
+ gm.sum_depth += s.sum_depth;
+ gm.sum_root_entropy += s.root_visit_entropy;
+ gm.sum_top_move_frac += s.top_move_visit_frac;
+ gm.sum_nodes += s.nodes as u64;
+ gm.sum_internal_nodes += s.internal_nodes as u64;
+ }
+ actions.push(action_idx);
// Replay capture (current-player-downward frame).
let (stored_input_3d, stored_policy, stored_mask) = if current_player == 1 {
@@ -142,25 +193,29 @@ pub async fn play_game_async(
if mechanics.check_win(data, current_player as usize) {
winner = Some(current_player);
- for item in replay_items.iter_mut() {
- item.value = if item.player == current_player {
- 1.0
- } else {
- -1.0
- };
- }
- return Ok(GameResult {
- winner,
- num_turns: step + 1,
- replay_items,
- });
+ break;
}
}
- Ok(GameResult {
- winner,
- num_turns: max_steps,
- replay_items,
- })
+ if let Some(w) = winner {
+ for item in replay_items.iter_mut() {
+ item.value = if item.player == w { 1.0 } else { -1.0 };
+ }
+ }
+ gm.full_hash = hash_actions(&actions);
+ gm.opening_hash = hash_actions(&actions[..actions.len().min(OPENING_PLIES)]);
+ let num_turns = if winner.is_some() {
+ actions.len() as i32
+ } else {
+ max_steps
+ };
+ Ok((
+ GameResult {
+ winner,
+ num_turns,
+ replay_items,
+ },
+ gm,
+ ))
}
async fn run_az_select(
@@ -170,8 +225,8 @@ async fn run_az_select(
visited: &HashSet,
settings: GameSettings,
step: usize,
-) -> Result<(usize, Vec)> {
- let (children, _root_value, _stats) = mcts.search(data, mechanics, visited).await?;
+) -> Result<(usize, Vec, SearchStats)> {
+ let (children, _root_value, stats) = mcts.search(data, mechanics, visited).await?;
let visit_counts: Vec = children.iter().map(|c| c.visit_count).collect();
let action_indices: Vec = children.iter().map(|c| c.action_index).collect();
let temperature = match settings.drop_t_on_step {
@@ -194,7 +249,7 @@ async fn run_az_select(
policy[c.action_index] = c.visit_count as f32 / total_visits as f32;
}
}
- Ok((action_idx, policy))
+ Ok((action_idx, policy, stats))
}
fn random_select(mask: &[bool]) -> (usize, Vec) {
@@ -210,3 +265,23 @@ fn random_select(mask: &[bool]) -> (usize, Vec) {
p[idx] = 1.0;
(idx, p)
}
+
+#[cfg(test)]
+mod tests {
+ use super::{hash_actions, OPENING_PLIES};
+
+ #[test]
+ fn identical_sequences_hash_equal_different_differ() {
+ let a = vec![3usize, 7, 1, 9, 2, 4, 8, 0, 5, 6];
+ let b = a.clone();
+ let mut c = a.clone();
+ c[9] = 99; // differs only after the opening
+
+ assert_eq!(hash_actions(&a), hash_actions(&b), "identical games hash equal");
+ assert_ne!(hash_actions(&a), hash_actions(&c), "different full games differ");
+
+ let open_a = hash_actions(&a[..a.len().min(OPENING_PLIES)]);
+ let open_c = hash_actions(&c[..c.len().min(OPENING_PLIES)]);
+ assert_eq!(open_a, open_c, "same opening hashes equal");
+ }
+}
diff --git a/deep_quoridor/rust/src/bin/selfplay.rs b/deep_quoridor/rust/src/bin/selfplay.rs
index 81393c9c..2ebc05dc 100644
--- a/deep_quoridor/rust/src/bin/selfplay.rs
+++ b/deep_quoridor/rust/src/bin/selfplay.rs
@@ -448,7 +448,7 @@ fn run_batch_batched(
if idx >= num_games { break; }
p1.reset_tree();
if let P2::AlphaZero(m) = &mut p2 { m.reset_tree(); }
- let result = play_game_async(&mut p1, &mut p2, settings, board_size, max_walls, max_steps).await?;
+ let (result, _game_metrics) = play_game_async(&mut p1, &mut p2, settings, board_size, max_walls, max_steps).await?;
write_replay(&output_dir, None, &result, model_version, idx, pid)?;
let mut s = stats.lock().unwrap();
match result.winner {
@@ -656,7 +656,7 @@ fn run_continuous_batched(
p1.reset_tree();
if let P2::AlphaZero(m) = &mut p2 { m.reset_tree(); }
let idx = counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
- let result = play_game_async(&mut p1, &mut p2, settings, board_size, max_walls, max_steps).await?;
+ let (result, _game_metrics) = play_game_async(&mut p1, &mut p2, settings, board_size, max_walls, max_steps).await?;
write_replay(&output_dir, Some(&tmp_dir), &result, mv, idx, pid)?;
}
Ok::<(), anyhow::Error>(())
From 3ea932ceb4d9041c1453d55635e6f2450b605cd1 Mon Sep 17 00:00:00 2001
From: Jon Binney
Date: Tue, 26 May 2026 21:42:42 -0400
Subject: [PATCH 06/30] vibe: add SelfPlayAccumulator with JSON metric flush
---
deep_quoridor/rust/Cargo.toml | 3 +-
.../rust/src/agents/alphazero/mod.rs | 1 +
.../src/agents/alphazero/selfplay_metrics.rs | 174 ++++++++++++++++++
3 files changed, 177 insertions(+), 1 deletion(-)
create mode 100644 deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs
diff --git a/deep_quoridor/rust/Cargo.toml b/deep_quoridor/rust/Cargo.toml
index a99aade4..8d1777cf 100644
--- a/deep_quoridor/rust/Cargo.toml
+++ b/deep_quoridor/rust/Cargo.toml
@@ -39,6 +39,7 @@ clap = { version = "4.5", features = ["derive"], optional = true }
ort = { version = "=2.0.0-rc.12", optional = true }
anyhow = "1"
serde_yaml = { version = "0.9", optional = true }
+serde_json = { version = "1", optional = true }
ndarray-npy = { version = "0.9", optional = true }
zip = { version = "2", optional = true, default-features = false, features = ["deflate"] }
rand_distr = { version = "0.4", optional = true }
@@ -49,7 +50,7 @@ smallvec = "1.13"
[features]
default = ["python"]
python = ["pyo3", "numpy"]
-binary = ["clap", "ort", "serde_yaml", "ndarray-npy", "zip", "rand_distr", "tokio", "futures"]
+binary = ["clap", "ort", "serde_yaml", "serde_json", "ndarray-npy", "zip", "rand_distr", "tokio", "futures"]
gpu = ["binary", "ort/cuda", "ort/load-dynamic"]
[dev-dependencies]
diff --git a/deep_quoridor/rust/src/agents/alphazero/mod.rs b/deep_quoridor/rust/src/agents/alphazero/mod.rs
index 88c9e6d8..01aa6c59 100644
--- a/deep_quoridor/rust/src/agents/alphazero/mod.rs
+++ b/deep_quoridor/rust/src/agents/alphazero/mod.rs
@@ -7,6 +7,7 @@ pub mod eval_pipeline;
pub mod evaluator;
pub mod mcts;
pub mod selfplay_game;
+pub mod selfplay_metrics;
pub mod selfplay_mcts;
pub mod agent;
diff --git a/deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs b/deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs
new file mode 100644
index 00000000..3a79b78c
--- /dev/null
+++ b/deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs
@@ -0,0 +1,174 @@
+//! Per-process self-play metric accumulator.
+//!
+//! Folds `GameMetrics` for the current model version, then flushes a raw-aggregate
+//! JSON record per `(version, pid)` so the Python side can combine processes and
+//! compute final metrics. Reset happens on each model-version change and on shutdown.
+
+use std::collections::HashSet;
+
+use anyhow::{Context, Result};
+
+use crate::agents::alphazero::selfplay_game::GameMetrics;
+
+/// Running raw aggregates for one model version within one process.
+pub struct SelfPlayAccumulator {
+ version: i64,
+ sims: u64,
+ terminal_wins: u64,
+ truncations: u64,
+ max_depth: u32,
+ sum_depth: u64,
+ moves: u64,
+ sum_root_entropy: f64,
+ sum_top_move_frac: f64,
+ sum_nodes: u64,
+ sum_internal_nodes: u64,
+ games_generated: u64,
+ full_hashes: HashSet,
+ opening_hashes: HashSet,
+}
+
+impl SelfPlayAccumulator {
+ pub fn new(version: i64) -> Self {
+ Self {
+ version,
+ sims: 0,
+ terminal_wins: 0,
+ truncations: 0,
+ max_depth: 0,
+ sum_depth: 0,
+ moves: 0,
+ sum_root_entropy: 0.0,
+ sum_top_move_frac: 0.0,
+ sum_nodes: 0,
+ sum_internal_nodes: 0,
+ games_generated: 0,
+ full_hashes: HashSet::new(),
+ opening_hashes: HashSet::new(),
+ }
+ }
+
+ pub fn set_version(&mut self, v: i64) {
+ self.version = v;
+ }
+
+ pub fn fold_game(&mut self, gm: &GameMetrics) {
+ self.sims += gm.sims;
+ self.terminal_wins += gm.terminal_wins;
+ self.truncations += gm.truncations;
+ self.max_depth = self.max_depth.max(gm.max_depth);
+ self.sum_depth += gm.sum_depth;
+ self.moves += gm.moves;
+ self.sum_root_entropy += gm.sum_root_entropy;
+ self.sum_top_move_frac += gm.sum_top_move_frac;
+ self.sum_nodes += gm.sum_nodes;
+ self.sum_internal_nodes += gm.sum_internal_nodes;
+ self.games_generated += 1;
+ self.full_hashes.insert(gm.full_hash);
+ self.opening_hashes.insert(gm.opening_hash);
+ }
+
+ fn clear_counts(&mut self) {
+ self.sims = 0;
+ self.terminal_wins = 0;
+ self.truncations = 0;
+ self.max_depth = 0;
+ self.sum_depth = 0;
+ self.moves = 0;
+ self.sum_root_entropy = 0.0;
+ self.sum_top_move_frac = 0.0;
+ self.sum_nodes = 0;
+ self.sum_internal_nodes = 0;
+ self.games_generated = 0;
+ self.full_hashes.clear();
+ self.opening_hashes.clear();
+ }
+
+ fn to_json(&self, pid: u32) -> serde_json::Value {
+ serde_json::json!({
+ "model_version": self.version,
+ "pid": pid,
+ "sims": self.sims,
+ "terminal_wins": self.terminal_wins,
+ "truncations": self.truncations,
+ "max_depth": self.max_depth,
+ "sum_depth": self.sum_depth,
+ "moves": self.moves,
+ "sum_root_entropy": self.sum_root_entropy,
+ "sum_top_move_frac": self.sum_top_move_frac,
+ "sum_nodes": self.sum_nodes,
+ "sum_internal_nodes": self.sum_internal_nodes,
+ "games_generated": self.games_generated,
+ "unique_full": self.full_hashes.len(),
+ "unique_opening": self.opening_hashes.len(),
+ })
+ }
+
+ /// Write the current version's record to `/v{version}_pid{pid}.json` (atomic
+ /// tmp+rename), then clear counts. No-op (just clears) when nothing was accumulated.
+ pub fn flush_and_reset(&mut self, dir: &str, pid: u32) -> Result<()> {
+ if self.moves == 0 && self.games_generated == 0 {
+ self.clear_counts();
+ return Ok(());
+ }
+ let path = format!("{}/v{}_pid{}.json", dir, self.version, pid);
+ let tmp = format!("{}.tmp", path);
+ let bytes = serde_json::to_vec(&self.to_json(pid)).context("serialize metrics record")?;
+ std::fs::write(&tmp, &bytes).with_context(|| format!("write {}", tmp))?;
+ std::fs::rename(&tmp, &path).with_context(|| format!("rename to {}", path))?;
+ self.clear_counts();
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::agents::alphazero::selfplay_game::GameMetrics;
+
+ fn sample_game(full: u64, opening: u64) -> GameMetrics {
+ GameMetrics {
+ sims: 100,
+ terminal_wins: 5,
+ truncations: 2,
+ max_depth: 10,
+ sum_depth: 300,
+ moves: 20,
+ sum_root_entropy: 12.0,
+ sum_top_move_frac: 8.0,
+ sum_nodes: 500,
+ sum_internal_nodes: 250,
+ full_hash: full,
+ opening_hash: opening,
+ }
+ }
+
+ #[test]
+ fn flush_writes_expected_aggregates() {
+ let dir = std::env::temp_dir().join(format!("spm_test_{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let dir_s = dir.to_str().unwrap().to_string();
+
+ let mut acc = SelfPlayAccumulator::new(7);
+ acc.fold_game(&sample_game(1, 100));
+ acc.fold_game(&sample_game(2, 100));
+ acc.fold_game(&sample_game(2, 100));
+ acc.flush_and_reset(&dir_s, 4242).unwrap();
+
+ let path = format!("{}/v7_pid4242.json", dir_s);
+ let v: serde_json::Value =
+ serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
+ assert_eq!(v["model_version"], 7);
+ assert_eq!(v["games_generated"], 3);
+ assert_eq!(v["sims"], 300);
+ assert_eq!(v["unique_full"], 2);
+ assert_eq!(v["unique_opening"], 1);
+ assert_eq!(v["max_depth"], 10);
+
+ std::fs::remove_file(&path).unwrap();
+ acc.flush_and_reset(&dir_s, 4242).unwrap();
+ assert!(!std::path::Path::new(&path).exists(), "empty flush writes nothing");
+
+ std::fs::remove_dir_all(&dir).ok();
+ }
+}
From e936b69aed1ff48fc0c8b8482b045716883dadcf Mon Sep 17 00:00:00 2001
From: Jon Binney
Date: Tue, 26 May 2026 21:45:46 -0400
Subject: [PATCH 07/30] vibe: flush self-play MCTS metrics per model version
Co-Authored-By: Claude Sonnet 4.6
---
deep_quoridor/rust/src/bin/selfplay.rs | 37 +++++++++++++++++++++++++-
1 file changed, 36 insertions(+), 1 deletion(-)
diff --git a/deep_quoridor/rust/src/bin/selfplay.rs b/deep_quoridor/rust/src/bin/selfplay.rs
index 2ebc05dc..a4338792 100644
--- a/deep_quoridor/rust/src/bin/selfplay.rs
+++ b/deep_quoridor/rust/src/bin/selfplay.rs
@@ -123,6 +123,11 @@ struct Cli {
/// Periodically print pipeline counters (GPU time, batcher wait, postprocess time).
#[arg(long, default_value = "false")]
profile_counters: bool,
+
+ /// Directory to write per-model-version MCTS metric JSON records. When omitted,
+ /// metric collection is disabled.
+ #[arg(long)]
+ metrics_dir: Option,
}
/// Resolved runtime config (CLI overrides > YAML > defaults).
@@ -594,6 +599,15 @@ fn run_continuous_batched(
let shutdown = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let pid = std::process::id();
+ use quoridor_rs::agents::alphazero::selfplay_metrics::SelfPlayAccumulator;
+ let metrics_dir = cli.metrics_dir.clone();
+ if let Some(ref d) = metrics_dir {
+ std::fs::create_dir_all(d)?;
+ }
+ let metrics = std::sync::Arc::new(std::sync::Mutex::new(SelfPlayAccumulator::new(
+ initial_version,
+ )));
+
let print_task = if profile_counters {
let counters = std::sync::Arc::clone(&counters);
let shutdown = std::sync::Arc::clone(&shutdown);
@@ -641,6 +655,8 @@ fn run_continuous_batched(
let board_size = q.board_size;
let max_walls = q.max_walls;
let max_steps = q.max_steps as i32;
+ let metrics = std::sync::Arc::clone(&metrics);
+ let metrics_enabled = metrics_dir.is_some();
handles.push(tokio::spawn(async move {
let mut p1 = LeafParallelMCTS::new(mcts_cfg.clone(), lp_cfg, front_tx.clone(), std::sync::Arc::clone(&cache));
let mut p2: P2 = match p2_kind.as_deref() {
@@ -656,8 +672,11 @@ fn run_continuous_batched(
p1.reset_tree();
if let P2::AlphaZero(m) = &mut p2 { m.reset_tree(); }
let idx = counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
- let (result, _game_metrics) = play_game_async(&mut p1, &mut p2, settings, board_size, max_walls, max_steps).await?;
+ let (result, game_metrics) = play_game_async(&mut p1, &mut p2, settings, board_size, max_walls, max_steps).await?;
write_replay(&output_dir, Some(&tmp_dir), &result, mv, idx, pid)?;
+ if metrics_enabled {
+ metrics.lock().unwrap().fold_game(&game_metrics);
+ }
}
Ok::<(), anyhow::Error>(())
}));
@@ -668,12 +687,21 @@ fn run_continuous_batched(
let front_tx = front_tx.clone();
let shutdown = std::sync::Arc::clone(&shutdown);
let model_version = std::sync::Arc::clone(&model_version);
+ let metrics = std::sync::Arc::clone(&metrics);
+ let metrics_dir = metrics_dir.clone();
+ let pid_for_metrics = pid;
tokio::spawn(async move {
let mut current = initial_version;
loop {
if std::path::Path::new(&shutdown_path).exists() {
println!("Shutdown signal detected. Stopping workers...");
shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
+ if let Some(ref d) = metrics_dir {
+ let mut m = metrics.lock().unwrap();
+ if let Err(e) = m.flush_and_reset(d, pid_for_metrics) {
+ eprintln!("selfplay-metrics: final flush failed: {:#}", e);
+ }
+ }
break;
}
if let Ok(latest) = load_latest_model(&latest_yaml_path) {
@@ -683,6 +711,13 @@ fn run_continuous_batched(
println!("New model detected: version {} -> {} ({})", current, latest.version, new_path);
current = latest.version;
model_version.store(latest.version, std::sync::atomic::Ordering::Relaxed);
+ if let Some(ref d) = metrics_dir {
+ let mut m = metrics.lock().unwrap();
+ if let Err(e) = m.flush_and_reset(d, pid_for_metrics) {
+ eprintln!("selfplay-metrics: flush failed: {:#}", e);
+ }
+ m.set_version(latest.version);
+ }
let _ = front_tx.send(FrontMsg::Reload(new_path)).await;
}
}
From defa27b2f13e1945b51d9c53fa97969c0a692cde Mon Sep 17 00:00:00 2001
From: Jon Binney
Date: Tue, 26 May 2026 21:49:46 -0400
Subject: [PATCH 08/30] vibe: add self-play metrics aggregator and W&B logger
Co-Authored-By: Claude Sonnet 4.6
---
deep_quoridor/src/v2/selfplay_metrics.py | 130 ++++++++++++++++++++
deep_quoridor/test/test_selfplay_metrics.py | 48 ++++++++
2 files changed, 178 insertions(+)
create mode 100644 deep_quoridor/src/v2/selfplay_metrics.py
create mode 100644 deep_quoridor/test/test_selfplay_metrics.py
diff --git a/deep_quoridor/src/v2/selfplay_metrics.py b/deep_quoridor/src/v2/selfplay_metrics.py
new file mode 100644
index 00000000..3286d870
--- /dev/null
+++ b/deep_quoridor/src/v2/selfplay_metrics.py
@@ -0,0 +1,130 @@
+"""Self-play MCTS metrics: aggregate per-process Rust JSON records and log to W&B.
+
+The Rust self-play binary writes one raw-aggregate record per (model_version, pid)
+to a metrics directory. This module combines a version's records across processes,
+computes the final metrics, and logs them to a W&B run in the training group.
+"""
+import glob
+import json
+import math
+import os
+import re
+import time
+from typing import Optional
+
+import wandb
+
+from v2.common import MockWandb, ShutdownSignal
+from v2.config import Config
+
+_FILE_RE = re.compile(r"v(\d+)_pid\d+\.json$")
+
+
+def metrics_dir_for(config: Config) -> str:
+ """Directory shared by the Rust writer and this reader."""
+ return str(config.paths.run_dir / "selfplay_metrics")
+
+
+def aggregate_records(records: list[dict]) -> Optional[dict]:
+ """Combine per-process raw records for one model version into final metrics.
+
+ Returns None if no searches happened (nothing to log).
+ """
+ sims = sum(r["sims"] for r in records)
+ moves = sum(r["moves"] for r in records)
+ if moves == 0 or sims == 0:
+ return None
+
+ sum_entropy = sum(r["sum_root_entropy"] for r in records)
+ sum_nodes = sum(r["sum_nodes"] for r in records)
+ sum_internal = sum(r["sum_internal_nodes"] for r in records)
+ games = sum(r["games_generated"] for r in records)
+ unique_full = sum(r["unique_full"] for r in records)
+ unique_opening = sum(r["unique_opening"] for r in records)
+ mean_entropy = sum_entropy / moves
+
+ out = {
+ "selfplay/terminal_sim_frac": sum(r["terminal_wins"] for r in records) / sims,
+ "selfplay/truncation_sim_frac": sum(r["truncations"] for r in records) / sims,
+ "selfplay/max_tree_depth": max(r["max_depth"] for r in records),
+ "selfplay/mean_tree_depth": sum(r["sum_depth"] for r in records) / sims,
+ "selfplay/root_visit_entropy": mean_entropy,
+ "selfplay/root_visit_perplexity": math.exp(mean_entropy),
+ "selfplay/top_move_visit_frac": sum(r["sum_top_move_frac"] for r in records) / moves,
+ "selfplay/mean_nodes_per_search": sum_nodes / moves,
+ "selfplay/mean_branching": (sum_nodes - moves) / sum_internal if sum_internal else 0.0,
+ "selfplay/games_generated": games,
+ "selfplay/unique_games_full": unique_full,
+ "selfplay/unique_games_opening": unique_opening,
+ "selfplay/unique_frac_full": unique_full / games if games else 0.0,
+ "selfplay/unique_frac_opening": unique_opening / games if games else 0.0,
+ }
+ return out
+
+
+def _scan(metrics_dir: str) -> dict[int, list[str]]:
+ """Map model_version -> list of record file paths present on disk."""
+ by_version: dict[int, list[str]] = {}
+ for path in glob.glob(os.path.join(metrics_dir, "v*_pid*.json")):
+ m = _FILE_RE.search(os.path.basename(path))
+ if m:
+ by_version.setdefault(int(m.group(1)), []).append(path)
+ return by_version
+
+
+def _load(paths: list[str]) -> list[dict]:
+ records = []
+ for p in paths:
+ try:
+ with open(p) as f:
+ records.append(json.load(f))
+ except (OSError, json.JSONDecodeError):
+ continue # mid-write or transient; picked up on a later poll
+ return records
+
+
+def run_selfplay_metrics(config: Config, poll_seconds: float = 5.0):
+ """Poll the metrics dir and log each completed model version to W&B once."""
+ metrics_dir = metrics_dir_for(config)
+ os.makedirs(metrics_dir, exist_ok=True)
+
+ if config.wandb:
+ run_id = f"{config.run_id}-selfplay"
+ wandb_run = wandb.init(
+ project=config.wandb.project,
+ job_type="selfplay",
+ group=config.run_id,
+ name=run_id,
+ id=run_id,
+ resume="allow",
+ )
+ wandb.define_metric("Model version", hidden=True)
+ wandb.define_metric("*", "Model version")
+ else:
+ wandb_run = MockWandb()
+
+ logged: set[int] = set()
+
+ def flush(finalize_all: bool):
+ by_version = _scan(metrics_dir)
+ if not by_version:
+ return
+ max_version = max(by_version)
+ for version in sorted(by_version):
+ if version in logged:
+ continue
+ # A version is complete once a newer version exists (the writer moved on)
+ # or we're finalizing on shutdown.
+ if not finalize_all and version >= max_version:
+ continue
+ agg = aggregate_records(_load(by_version[version]))
+ if agg is not None:
+ agg["Model version"] = version
+ wandb_run.log(agg)
+ logged.add(version)
+
+ while not ShutdownSignal.is_set(config):
+ flush(finalize_all=False)
+ time.sleep(poll_seconds)
+
+ flush(finalize_all=True) # log the last in-progress version on shutdown
diff --git a/deep_quoridor/test/test_selfplay_metrics.py b/deep_quoridor/test/test_selfplay_metrics.py
new file mode 100644
index 00000000..8682809c
--- /dev/null
+++ b/deep_quoridor/test/test_selfplay_metrics.py
@@ -0,0 +1,48 @@
+import math
+
+from v2.selfplay_metrics import aggregate_records
+
+
+def _record(**kw):
+ base = dict(
+ model_version=3, pid=1, sims=0, terminal_wins=0, truncations=0,
+ max_depth=0, sum_depth=0, moves=0, sum_root_entropy=0.0,
+ sum_top_move_frac=0.0, sum_nodes=0, sum_internal_nodes=0,
+ games_generated=0, unique_full=0, unique_opening=0,
+ )
+ base.update(kw)
+ return base
+
+
+def test_aggregate_combines_two_processes():
+ r1 = _record(
+ sims=100, terminal_wins=10, truncations=5, max_depth=12, sum_depth=400,
+ moves=20, sum_root_entropy=20.0, sum_top_move_frac=10.0, sum_nodes=600,
+ sum_internal_nodes=300, games_generated=2, unique_full=2, unique_opening=1,
+ )
+ r2 = _record(
+ sims=300, terminal_wins=30, truncations=15, max_depth=18, sum_depth=1200,
+ moves=60, sum_root_entropy=66.0, sum_top_move_frac=36.0, sum_nodes=1800,
+ sum_internal_nodes=900, games_generated=6, unique_full=5, unique_opening=2,
+ )
+ agg = aggregate_records([r1, r2])
+
+ sims, moves = 400, 80
+ assert agg["selfplay/terminal_sim_frac"] == 40 / sims
+ assert agg["selfplay/truncation_sim_frac"] == 20 / sims
+ assert agg["selfplay/max_tree_depth"] == 18
+ assert agg["selfplay/mean_tree_depth"] == 1600 / sims
+ assert agg["selfplay/root_visit_entropy"] == 86.0 / moves
+ assert agg["selfplay/root_visit_perplexity"] == math.exp(86.0 / moves)
+ assert agg["selfplay/top_move_visit_frac"] == 46.0 / moves
+ assert agg["selfplay/mean_nodes_per_search"] == 2400 / moves
+ assert agg["selfplay/mean_branching"] == (2400 - moves) / 1200
+ assert agg["selfplay/games_generated"] == 8
+ assert agg["selfplay/unique_games_full"] == 7
+ assert agg["selfplay/unique_games_opening"] == 3
+ assert agg["selfplay/unique_frac_full"] == 7 / 8
+ assert agg["selfplay/unique_frac_opening"] == 3 / 8
+
+
+def test_aggregate_skips_empty():
+ assert aggregate_records([_record(moves=0, sims=0)]) is None
From 12f00c7d6364e397f1706dcd7236730bf4328661 Mon Sep 17 00:00:00 2001
From: Jon Binney
Date: Tue, 26 May 2026 21:53:49 -0400
Subject: [PATCH 09/30] vibe: spawn self-play metrics logger from train_v2
---
deep_quoridor/src/train_v2.py | 18 +++++++++++++++++-
deep_quoridor/src/v2/__init__.py | 3 +++
2 files changed, 20 insertions(+), 1 deletion(-)
diff --git a/deep_quoridor/src/train_v2.py b/deep_quoridor/src/train_v2.py
index f5a0b31c..9e5512be 100644
--- a/deep_quoridor/src/train_v2.py
+++ b/deep_quoridor/src/train_v2.py
@@ -5,7 +5,16 @@
import time
from pathlib import Path
-from v2 import benchmarks, check_ai_available, load_config_and_setup_run, run_ai_reporter, self_play, train
+from v2 import (
+ benchmarks,
+ check_ai_available,
+ load_config_and_setup_run,
+ metrics_dir_for,
+ run_ai_reporter,
+ run_selfplay_metrics,
+ self_play,
+ train,
+)
from v2.common import ShutdownSignal
# Prevents getting messages in the console every few lines telling you to install weave
@@ -95,6 +104,8 @@ def _selfplay_subprocess_env():
selfplay_env = _selfplay_subprocess_env()
if selfplay_env is not None:
print(f"Self-play GPU env: ORT_DYLIB_PATH={selfplay_env['ORT_DYLIB_PATH']}")
+ metrics_dir = metrics_dir_for(config)
+ os.makedirs(metrics_dir, exist_ok=True)
config_file_path = str(config.paths.config_file)
for i in range(config.self_play.num_processes):
cmd = [
@@ -108,10 +119,15 @@ def _selfplay_subprocess_env():
str(config.paths.latest_model_yaml),
"--shutdown-file",
str(ShutdownSignal.file_path(config)),
+ "--metrics-dir",
+ metrics_dir,
]
proc = subprocess.Popen(cmd, env=selfplay_env)
rust_subprocesses.append(proc)
print(f"Started Rust self-play process {proc.pid}")
+ selfplay_metrics_process = mp.Process(target=run_selfplay_metrics, args=[config])
+ selfplay_metrics_process.start()
+ self_play_processes.append(selfplay_metrics_process)
else:
for i in range(config.self_play.num_processes):
p = mp.Process(target=self_play, args=[config])
diff --git a/deep_quoridor/src/v2/__init__.py b/deep_quoridor/src/v2/__init__.py
index 296b58ee..2d52b649 100644
--- a/deep_quoridor/src/v2/__init__.py
+++ b/deep_quoridor/src/v2/__init__.py
@@ -13,10 +13,13 @@
"check_ai_available",
"run_ai_reporter",
"generate_on_demand_report",
+ "metrics_dir_for",
+ "run_selfplay_metrics",
]
from v2.ai_report import check_ai_available, generate_on_demand_report, run_ai_reporter
from v2.benchmarks import create_benchmark_processes
+from v2.selfplay_metrics import metrics_dir_for, run_selfplay_metrics
from v2.common import JobTrigger, MockWandb, ShutdownSignal, create_alphazero, upload_model
from v2.config import load_config_and_setup_run
from v2.self_play import self_play
From 9ed6c73e6e4a73baf84e7250e8f03121817e4f70 Mon Sep 17 00:00:00 2001
From: Jon Binney
Date: Tue, 26 May 2026 21:55:58 -0400
Subject: [PATCH 10/30] vibe: cargo fmt + ruff format
---
deep_quoridor/rust/src/agents/alphazero/mod.rs | 2 +-
.../rust/src/agents/alphazero/selfplay_game.rs | 14 +++++++++++---
.../rust/src/agents/alphazero/selfplay_mcts.rs | 5 ++++-
.../rust/src/agents/alphazero/selfplay_metrics.rs | 8 +++++---
4 files changed, 21 insertions(+), 8 deletions(-)
diff --git a/deep_quoridor/rust/src/agents/alphazero/mod.rs b/deep_quoridor/rust/src/agents/alphazero/mod.rs
index 01aa6c59..27e6fbae 100644
--- a/deep_quoridor/rust/src/agents/alphazero/mod.rs
+++ b/deep_quoridor/rust/src/agents/alphazero/mod.rs
@@ -7,8 +7,8 @@ pub mod eval_pipeline;
pub mod evaluator;
pub mod mcts;
pub mod selfplay_game;
-pub mod selfplay_metrics;
pub mod selfplay_mcts;
+pub mod selfplay_metrics;
pub mod agent;
pub use agent::{AlphaZeroAgent, AlphaZeroAgentConfig};
diff --git a/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs b/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs
index 07a9bb27..4b8b6556 100644
--- a/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs
+++ b/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs
@@ -268,7 +268,7 @@ fn random_select(mask: &[bool]) -> (usize, Vec) {
#[cfg(test)]
mod tests {
- use super::{hash_actions, OPENING_PLIES};
+ use super::{OPENING_PLIES, hash_actions};
#[test]
fn identical_sequences_hash_equal_different_differ() {
@@ -277,8 +277,16 @@ mod tests {
let mut c = a.clone();
c[9] = 99; // differs only after the opening
- assert_eq!(hash_actions(&a), hash_actions(&b), "identical games hash equal");
- assert_ne!(hash_actions(&a), hash_actions(&c), "different full games differ");
+ assert_eq!(
+ hash_actions(&a),
+ hash_actions(&b),
+ "identical games hash equal"
+ );
+ assert_ne!(
+ hash_actions(&a),
+ hash_actions(&c),
+ "different full games differ"
+ );
let open_a = hash_actions(&a[..a.len().min(OPENING_PLIES)]);
let open_c = hash_actions(&c[..c.len().min(OPENING_PLIES)]);
diff --git a/deep_quoridor/rust/src/agents/alphazero/selfplay_mcts.rs b/deep_quoridor/rust/src/agents/alphazero/selfplay_mcts.rs
index 32277236..859bd848 100644
--- a/deep_quoridor/rust/src/agents/alphazero/selfplay_mcts.rs
+++ b/deep_quoridor/rust/src/agents/alphazero/selfplay_mcts.rs
@@ -598,7 +598,10 @@ mod tests {
assert_eq!(stats.sims, 40, "sims should equal mcts_n");
assert!(stats.max_depth >= 1, "max_depth must be >= 1");
- assert!(stats.sum_depth >= stats.sims as u64, "each sim has depth >= 1");
+ assert!(
+ stats.sum_depth >= stats.sims as u64,
+ "each sim has depth >= 1"
+ );
assert!(stats.nodes >= 1);
assert!(stats.internal_nodes >= 1);
assert!(stats.root_visit_entropy >= 0.0);
diff --git a/deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs b/deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs
index 3a79b78c..bed5506f 100644
--- a/deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs
+++ b/deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs
@@ -156,8 +156,7 @@ mod tests {
acc.flush_and_reset(&dir_s, 4242).unwrap();
let path = format!("{}/v7_pid4242.json", dir_s);
- let v: serde_json::Value =
- serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
+ let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(v["model_version"], 7);
assert_eq!(v["games_generated"], 3);
assert_eq!(v["sims"], 300);
@@ -167,7 +166,10 @@ mod tests {
std::fs::remove_file(&path).unwrap();
acc.flush_and_reset(&dir_s, 4242).unwrap();
- assert!(!std::path::Path::new(&path).exists(), "empty flush writes nothing");
+ assert!(
+ !std::path::Path::new(&path).exists(),
+ "empty flush writes nothing"
+ );
std::fs::remove_dir_all(&dir).ok();
}
From dde24f3d9a65f897c1877114b62cb70186b60b9c Mon Sep 17 00:00:00 2001
From: Jon Binney
Date: Fri, 29 May 2026 11:33:03 -0400
Subject: [PATCH 11/30] vibe: add Quoridor play server design spec
A local web app for playing Quoridor against the project's Rust
AlphaZero agent. One rust binary using tiny_http (no framework) serves
embedded HTML/CSS/JS plus a small JSON API; each browser session gets
its own in-memory game vs the AI. The user picks the ONNX model from
/models/*.onnx (board config inferred from a sibling
config.yaml), the mcts_n slider, and who plays first.
---
.../2026-05-29-quoridor-play-server-design.md | 199 ++++++++++++++++++
1 file changed, 199 insertions(+)
create mode 100644 docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md
diff --git a/docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md b/docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md
new file mode 100644
index 00000000..ba26cd1a
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md
@@ -0,0 +1,199 @@
+# Quoridor play server — design
+
+**Date:** 2026-05-29
+**Branch:** `jdb/rust-self-play-logging`
+**Status:** approved (design), pending implementation plan
+
+## Problem
+
+We want a locally-hosted web app so the author and friends can play Quoridor
+against the project's AlphaZero agent in their browsers. Each person opens the
+URL in their browser and gets their own independent game vs the AI; multiple
+games run concurrently on the same server. Constraints:
+
+- Use the project's **Rust** AlphaZero agent (the agent that actually has the
+ trained models we want to play against).
+- Keep it as simple as possible — no large web frameworks, no Python in the
+ request path, no build step for the frontend.
+- The player can pick which ONNX model the AI uses from a directory of models.
+
+## Architecture
+
+A single new Rust binary `bin/play_server.rs` in the existing `quoridor_rs`
+crate:
+
+- Reads `/config.yaml` once at startup for `board_size`, `max_walls`,
+ `max_steps` (reusing the existing `selfplay_config` loader), and scans
+ `/models/*.onnx` for the selectable models.
+- Listens on TCP (default port 8080; default bind `127.0.0.1`, `0.0.0.0` for
+ LAN).
+- Serves a single `index.html` + `app.css` + `app.js` (embedded into the binary
+ via `include_str!`) plus a small JSON API.
+- Holds active games in `Arc>>>`. Each
+ `GameSession` owns its own `AlphaZeroAgent` (built from the chosen ONNX with
+ the chosen `mcts_n`, `temperature=0`, deterministic tie-break) and the current
+ `CompactState` / `QGameMechanics`.
+
+### User flow
+1. Friend opens `http://:8080` in their browser.
+2. Picks **model** (dropdown), **`mcts_n`** (slider), and **who goes first**
+ (toggle); clicks "New Game".
+3. The client POSTs `/api/games`; server creates a `GameSession`, returns a
+ `game_id` + initial `state`.
+4. On the human's turn, legal moves and legal wall slots are highlighted; the
+ user clicks one, the client POSTs `/api/games//move` with the chosen
+ action index, the server applies the human move and (if it's then the AI's
+ turn) the AI response, and returns the new `state` in the same round-trip.
+5. Repeat until `state.winner != null`. A game-over banner appears.
+
+### Why single Rust binary, not Python + pyo3
+"Use the Rust agent" + "no big frameworks" + "as simple as possible" point at
+one binary: no venv, no maturin step, no two-language tooling for a small
+feature. The only new dep is `tiny_http`, which is a small HTTP server library
+(not a framework — no routing macros, no extractors, no async runtime).
+
+## Folder layout consumed by the server
+
+The user passes a directory to `--play-dir`. The expected layout is:
+```
+/
+ config.yaml # board_size, max_walls, max_steps, alphazero.*
+ models/
+ *.onnx # selectable models
+```
+The server treats the single board config as authoritative for the session;
+every model in `models/` is assumed to match it. (Mixed-config setups are out
+of scope.)
+
+## CLI
+```
+play_server --play-dir
+ [--port 8080]
+ [--bind 127.0.0.1] # use 0.0.0.0 for LAN
+ [--default-mcts-n 400]
+```
+
+## HTTP API (small, JSON)
+
+- `GET /` → `index.html`
+- `GET /static/` → embedded `app.css`, `app.js`
+- `GET /api/config` →
+ ```json
+ { "board_size": 9, "max_walls": 10, "max_steps": 100,
+ "models": ["model_0.onnx", "model_100.onnx", ...],
+ "default_mcts_n": 400 }
+ ```
+- `POST /api/games` body `{ "model": "model_100.onnx", "mcts_n": 400, "human_player": 0 }`
+ → `{ "game_id": "8 hex chars", "state": }`
+- `GET /api/games/` → `{ "state": }`
+- `POST /api/games//move` body `{ "action_index": 17 }`
+ → `{ "state": }` (atomically applies the human move plus the AI's
+ response if it then becomes the AI's turn).
+
+**Errors:** invalid move → `400` with a `{ "error": "..." }` body; unknown
+`game_id` → `404`; bad model name → `400`; ORT load failure → `500` with the
+message; the server keeps running through any of these.
+
+## The `State` object
+
+Everything the client needs to render the board without knowing the action
+encoding:
+```json
+{
+ "board_size": 9, "max_walls": 10,
+ "current_player": 0,
+ "p1_pos": [0, 4], "p2_pos": [8, 4],
+ "p1_walls": 10, "p2_walls": 10,
+ "walls": [{ "row": 3, "col": 2, "orientation": "h" }, ...],
+ "legal_actions": [
+ { "index": 3, "kind": "move", "to": [4, 5] },
+ { "index": 17, "kind": "wall", "row": 3, "col": 2, "orientation": "h" }
+ ],
+ "completed_steps": 5,
+ "winner": null,
+ "human_player": 0,
+ "last_action": { "kind": "move", "to": [1, 4] },
+ "move_history": [3, 17, ...]
+}
+```
+`legal_actions` carrying the kind/coords is the key piece: the client never has
+to mirror the action-encoding logic. The wall list and `last_action` use the
+same shape so the frontend has one render path.
+
+## Frontend
+
+Vanilla HTML + CSS + JS, served from the binary's embedded strings. The board
+is a `(2N-1) × (2N-1)` CSS grid:
+
+- even row, even col → **pawn cell** (clickable if a `move` action lands here)
+- odd row, even col → **horizontal wall slot** (clickable if a horizontal wall
+ is legal here)
+- even row, odd col → **vertical wall slot** (clickable if a vertical wall is
+ legal here)
+- odd row, odd col → wall post / spacer
+
+To set up interactions, the client iterates `state.legal_actions`, attaches a
+click handler to the appropriate cell/slot keyed by the bare `index`, and on
+click POSTs `{ "action_index": index }` to the move endpoint. The same
+iteration produces the legal-move highlights. Walls and `last_action` render
+through the same mapping.
+
+A right-side panel shows: whose turn it is, walls remaining per player,
+completed steps, an "AI is thinking…" spinner while a move request is in
+flight, and a "New Game" button. The new-game form has the model `
-
You — walls left:—
-
AI — walls left:—
+
You — walls remaining:—
+
AI — walls remaining:—
Step:—
From 8b0ba1fc599fe19824b3cb2bcba257adb5ed0eda Mon Sep 17 00:00:00 2001
From: Jon Binney
Date: Fri, 29 May 2026 18:19:35 -0400
Subject: [PATCH 28/30] vibe: default mcts_n 1000; simpler who-goes-first
labels
- Bump the default --default-mcts-n CLI value from 400 to 1000 so the
slider lands somewhere more capable on startup.
- Drop the (P1)/(P2) suffixes from the who-goes-first radio labels;
they're noise in a strictly human-vs-AI app.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
deep_quoridor/rust/src/bin/play_server.rs | 2 +-
deep_quoridor/rust/src/play_server/assets/index.html | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/deep_quoridor/rust/src/bin/play_server.rs b/deep_quoridor/rust/src/bin/play_server.rs
index 4ec5c34d..4a120282 100644
--- a/deep_quoridor/rust/src/bin/play_server.rs
+++ b/deep_quoridor/rust/src/bin/play_server.rs
@@ -39,7 +39,7 @@ struct Cli {
bind: String,
/// Default MCTS simulations per move shown in the UI slider.
- #[arg(long, default_value_t = 400)]
+ #[arg(long, default_value_t = 1000)]
default_mcts_n: u32,
}
diff --git a/deep_quoridor/rust/src/play_server/assets/index.html b/deep_quoridor/rust/src/play_server/assets/index.html
index ed08b8e3..7b2667c7 100644
--- a/deep_quoridor/rust/src/play_server/assets/index.html
+++ b/deep_quoridor/rust/src/play_server/assets/index.html
@@ -31,8 +31,8 @@
Quoridor vs AlphaZero
From 5c0bdc0a5d1dc30d63e1f2e70cec2f875b81b435 Mon Sep 17 00:00:00 2001
From: Jon Binney
Date: Sun, 31 May 2026 15:27:59 -0400
Subject: [PATCH 29/30] vibe: silence cargo-test filename-collision warning
The lib was declared as both cdylib and rlib in Cargo.toml, which
triggers cargo issue #6313 (output filename collision) on every
cargo test invocation -- three warnings on each run, no errors, but
noise that crowds out anything else.
Drop cdylib from Cargo.toml and add a pyproject.toml that tells
maturin to use the pyo3 bindings; that backend implicitly compiles
with --crate-type cdylib for the wheel build, so plain 'maturin
build' still produces an importable .so without cdylib being a
default cargo output. Verified by building and importing the wheel.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
deep_quoridor/rust/Cargo.toml | 7 ++++++-
deep_quoridor/rust/pyproject.toml | 23 +++++++++++++++++++++++
2 files changed, 29 insertions(+), 1 deletion(-)
create mode 100644 deep_quoridor/rust/pyproject.toml
diff --git a/deep_quoridor/rust/Cargo.toml b/deep_quoridor/rust/Cargo.toml
index 14d0d1a7..90ba1531 100644
--- a/deep_quoridor/rust/Cargo.toml
+++ b/deep_quoridor/rust/Cargo.toml
@@ -9,7 +9,12 @@ edition = "2024"
[lib]
name = "quoridor_rs"
-crate-type = ["cdylib", "rlib"]
+# Only `rlib` is listed so `cargo test` doesn't emit a `.so` alongside the
+# `.rlib` and trigger cargo's "output filename collision" warning (cargo
+# issue #6313, surfaces when one target has multiple crate-types and the
+# binaries depend on the same lib). The python wheel build sets
+# `crate-type = ["cdylib"]` via `[tool.maturin]` in pyproject.toml.
+crate-type = ["rlib"]
[[bin]]
name = "create_policy_db"
diff --git a/deep_quoridor/rust/pyproject.toml b/deep_quoridor/rust/pyproject.toml
new file mode 100644
index 00000000..bbfb914b
--- /dev/null
+++ b/deep_quoridor/rust/pyproject.toml
@@ -0,0 +1,23 @@
+# Python build configuration for maturin.
+#
+# Cargo.toml declares `[lib] crate-type = ["rlib"]` so `cargo test` doesn't
+# emit overlapping cdylib outputs (cargo issue #6313). The Python wheel
+# needs a `cdylib` -- maturin reads `crate-type` from this file and passes
+# it through, so wheel builds still produce `libquoridor_rs.so`.
+
+[build-system]
+requires = ["maturin>=1.0,<2.0"]
+build-backend = "maturin"
+
+[project]
+name = "quoridor-rs"
+requires-python = ">=3.8"
+
+[tool.maturin]
+# `bindings = "pyo3"` is what makes maturin emit a cdylib even when
+# Cargo.toml's `[lib] crate-type` is only `rlib` -- cdylib is the format
+# Python loads, but we don't want it as a default cargo output (it would
+# collide with the rlib's filename when binaries are built; see cargo
+# issue #6313).
+bindings = "pyo3"
+features = ["pyo3/extension-module"]
From de6683c8d02ffd0c45a873016db5286a774b29a0 Mon Sep 17 00:00:00 2001
From: Jon Binney
Date: Sun, 31 May 2026 15:27:59 -0400
Subject: [PATCH 30/30] vibe: prefer filter().map() over bool::then in
filter_map
Clippy lint (clippy::bool_then_in_filter_map).
Co-Authored-By: Claude Opus 4.7 (1M context)
---
deep_quoridor/rust/src/play_server/state.rs | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/deep_quoridor/rust/src/play_server/state.rs b/deep_quoridor/rust/src/play_server/state.rs
index 8d2f9220..0b3e0c9a 100644
--- a/deep_quoridor/rust/src/play_server/state.rs
+++ b/deep_quoridor/rust/src/play_server/state.rs
@@ -101,7 +101,8 @@ pub fn enrich_action(board_size: i32, index: usize) -> EnrichedAction {
pub fn enrich_legal_actions(board_size: i32, mask: &[bool]) -> Vec {
mask.iter()
.enumerate()
- .filter_map(|(i, &legal)| legal.then(|| enrich_action(board_size, i)))
+ .filter(|&(_, legal)| *legal)
+ .map(|(i, _)| enrich_action(board_size, i))
.collect()
}