diff --git a/deep_quoridor/rust/Cargo.toml b/deep_quoridor/rust/Cargo.toml index a99aade4..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" @@ -21,6 +26,11 @@ name = "selfplay" path = "src/bin/selfplay.rs" required-features = ["binary"] +[[bin]] +name = "play_server" +path = "src/bin/play_server.rs" +required-features = ["binary"] + [dependencies] pyo3 = { version = "0.22", features = ["extension-module"], optional = true } numpy = { version = "0.22", optional = true } @@ -39,6 +49,8 @@ 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 } +tiny_http = { version = "0.12", 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,12 +61,17 @@ 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", "tiny_http"] gpu = ["binary", "ort/cuda", "ort/load-dynamic"] [dev-dependencies] tempfile = "3" tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros", "time"] } +ureq = { version = "2", default-features = false, features = ["json"] } +# Mirrored from optional `binary`-feature deps so integration tests can name +# these types directly (Cargo resolves a separate copy per target otherwise). +tiny_http = "0.12" +serde_json = "1" [profile.release] # Enable optimizations for better performance 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"] diff --git a/deep_quoridor/rust/src/agents/alphazero/mod.rs b/deep_quoridor/rust/src/agents/alphazero/mod.rs index 88c9e6d8..27e6fbae 100644 --- a/deep_quoridor/rust/src/agents/alphazero/mod.rs +++ b/deep_quoridor/rust/src/agents/alphazero/mod.rs @@ -8,6 +8,7 @@ pub mod evaluator; pub mod mcts; pub mod selfplay_game; 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 1044b9ac..4b8b6556 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) = 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,31 @@ fn random_select(mask: &[bool]) -> (usize, Vec) { p[idx] = 1.0; (idx, p) } + +#[cfg(test)] +mod tests { + use super::{OPENING_PLIES, hash_actions}; + + #[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/agents/alphazero/selfplay_mcts.rs b/deep_quoridor/rust/src/agents/alphazero/selfplay_mcts.rs index d7988caf..859bd848 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,56 @@ 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() 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..bed5506f --- /dev/null +++ b/deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs @@ -0,0 +1,176 @@ +//! 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(); + } +} diff --git a/deep_quoridor/rust/src/bin/play_server.rs b/deep_quoridor/rust/src/bin/play_server.rs new file mode 100644 index 00000000..4a120282 --- /dev/null +++ b/deep_quoridor/rust/src/bin/play_server.rs @@ -0,0 +1,70 @@ +//! Local web server for playing Quoridor against the AlphaZero agent. +//! +//! Architecture and HTTP API are documented in +//! `docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md`. +//! +//! Threading model: one `tiny_http::Server` accepts requests in the main +//! thread and dispatches each to a fresh worker thread that holds a clone of +//! the `Arc`-backed `GameRegistry`. Per-session locking keeps games +//! independent. + +use std::path::PathBuf; +use std::sync::Arc; +use std::thread; + +use anyhow::{Context, Result}; +use clap::Parser; +use tiny_http::Server; + +use quoridor_rs::play_server::config::ServerConfig; +use quoridor_rs::play_server::http::handle_request; +use quoridor_rs::play_server::session::GameRegistry; + +#[derive(Parser)] +#[command( + name = "play_server", + about = "Local Quoridor play server (browser vs AlphaZero)" +)] +struct Cli { + /// Directory containing `config.yaml` and `models/*.onnx`. + #[arg(long)] + play_dir: PathBuf, + + /// TCP port to listen on. + #[arg(long, default_value_t = 8080)] + port: u16, + + /// Bind address. Use `0.0.0.0` for LAN access. + #[arg(long, default_value = "127.0.0.1")] + bind: String, + + /// Default MCTS simulations per move shown in the UI slider. + #[arg(long, default_value_t = 1000)] + default_mcts_n: u32, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let cfg = Arc::new(ServerConfig::load(&cli.play_dir).context("loading server config")?); + let registry = GameRegistry::new(); + let bind = format!("{}:{}", cli.bind, cli.port); + let server = Server::http(&bind).map_err(|e| anyhow::anyhow!("failed to bind {bind}: {e}"))?; + eprintln!( + "play_server listening on http://{bind} (board {}x{}, {} model(s))", + cfg.board_size, + cfg.board_size, + cfg.models.len() + ); + + for request in server.incoming_requests() { + let cfg = Arc::clone(&cfg); + let registry = registry.clone(); + let default_mcts_n = cli.default_mcts_n; + thread::spawn(move || { + if let Err(e) = handle_request(request, &cfg, ®istry, default_mcts_n) { + eprintln!("request handler error: {e:#}"); + } + }); + } + Ok(()) +} diff --git a/deep_quoridor/rust/src/bin/selfplay.rs b/deep_quoridor/rust/src/bin/selfplay.rs index 81393c9c..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). @@ -448,7 +453,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 { @@ -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 = 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; } } diff --git a/deep_quoridor/rust/src/lib.rs b/deep_quoridor/rust/src/lib.rs index 9ff009fb..5a3855a2 100644 --- a/deep_quoridor/rust/src/lib.rs +++ b/deep_quoridor/rust/src/lib.rs @@ -32,6 +32,8 @@ mod python_consistency; #[cfg(feature = "binary")] pub mod game_runner; #[cfg(feature = "binary")] +pub mod play_server; +#[cfg(feature = "binary")] pub mod replay_writer; #[cfg(feature = "binary")] pub mod selfplay_config; diff --git a/deep_quoridor/rust/src/play_server/assets/app.css b/deep_quoridor/rust/src/play_server/assets/app.css new file mode 100644 index 00000000..12807987 --- /dev/null +++ b/deep_quoridor/rust/src/play_server/assets/app.css @@ -0,0 +1,311 @@ +/* Quoridor play server -- warm wood-and-stone palette. + * + * The board reads as three distinct surfaces: + * --cell pawn squares (lightest) + * --slot empty wall channels (mid-tone groove) + * --wall placed wall pieces (darkest, walnut) + * + * Keeping these three values clearly separated is what makes a placed + * vertical wall look like a single vertical bar rather than a cross at + * the post intersection -- the perpendicular empty channels stay + * groove-tan, not wall-walnut. + */ + +:root { + --pawn-size: 56px; + --post-size: 14px; + + --bg: #f1e7d3; + --cell: #ead7ad; + --slot: #c9b07a; + --post: #b59b65; + --wall: #3a2412; + --wall-preview: rgba(58, 36, 18, 0.55); + + --pawn-p1: #1e3a8a; + --pawn-p2: #b91c1c; + --legal-move: rgba(34, 113, 50, 0.65); + + --last-action: #d97706; + + --board-frame: #2b1a0c; + --card-bg: #fffaf1; + --card-border: #c9b48a; + --ink: #2a1f10; + --ink-muted: #6b5a3f; +} + +* { box-sizing: border-box; } + +body { + font-family: "Iowan Old Style", "Hoefler Text", Georgia, serif; + margin: 0; + background: + radial-gradient(circle at 20% 10%, rgba(255, 245, 220, 0.45), transparent 50%), + radial-gradient(circle at 85% 90%, rgba(120, 80, 30, 0.18), transparent 55%), + var(--bg); + color: var(--ink); + min-height: 100vh; +} + +header { + padding: 1.25rem 1.75rem; + background: var(--board-frame); + color: #f3e6c8; + border-bottom: 3px solid #1a0f06; + box-shadow: 0 2px 0 rgba(255, 255, 255, 0.04) inset; +} + +header h1 { + margin: 0; + font-family: "Iowan Old Style", "Hoefler Text", Georgia, serif; + font-weight: 600; + font-size: 1.6rem; + letter-spacing: 0.04em; +} + +main { + display: flex; + flex-direction: row; + gap: 2rem; + padding: 2rem; + align-items: flex-start; +} + +/* ---- Board ---- */ + +#board-wrapper { + position: relative; +} + +#board { + display: grid; + gap: 0; + border: 6px solid var(--board-frame); + border-radius: 3px; + background: var(--board-frame); + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.08), + 0 12px 28px rgba(40, 24, 8, 0.35), + 0 2px 4px rgba(40, 24, 8, 0.2); +} + +.cell { + position: relative; +} + +/* Surface colors per cell type */ +.pawn-cell { background: var(--cell); } +.wall-h-half, +.wall-v-half { background: var(--slot); } +.post { background: var(--post); } + +/* Subtle inset to suggest carved board */ +.pawn-cell::after { + content: ""; + position: absolute; + inset: 0; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.04); + pointer-events: none; +} + +/* Placed walls -- darkest surface, beat any other class. */ +.wall-placed-h, +.wall-placed-v, +.wall-placed-h.post, +.wall-placed-v.post { + background: var(--wall); +} + +/* Pawn pieces */ +.pawn { + position: absolute; + top: 12%; left: 12%; right: 12%; bottom: 12%; + border-radius: 50%; + border: 2px solid #fdf4d8; + box-shadow: + 0 2px 4px rgba(0, 0, 0, 0.35), + inset 0 -3px 4px rgba(0, 0, 0, 0.25), + inset 0 2px 3px rgba(255, 255, 255, 0.35); +} +.pawn.p1 { background: var(--pawn-p1); } +.pawn.p2 { background: var(--pawn-p2); } + +/* Last-action highlights */ +.last-move::after { + content: ""; + position: absolute; + inset: 4px; + border: 2px dashed var(--last-action); + border-radius: 4px; + pointer-events: none; +} +.last-wall { + outline: 2px solid var(--last-action); + outline-offset: -2px; +} + +/* Legal-move highlights (clickable pawn destinations) */ +.legal-move { + cursor: pointer; +} +.legal-move::before { + content: ""; + position: absolute; + inset: 32%; + border-radius: 50%; + background: var(--legal-move); + transition: inset 120ms ease-out; +} +.legal-move:hover::before { inset: 18%; } + +/* Legal wall anchors -- the click target for each legal wall. The class + * is only applied to the cell at the wall's display top-left (left half + * for H, top half for V). Hovering the anchor triggers JS that adds + * `.wall-hover` to all 3 cells of the wall. */ +.legal-wall-h, +.legal-wall-v { + cursor: pointer; +} + +/* Group-hover preview: all 3 cells of a legal wall pre-painted in a + * translucent walnut, so the player sees the full wall they're about to + * place. */ +.wall-hover { + background: var(--wall-preview) !important; +} + +/* ---- Side panel ---- */ + +#side-panel { + width: 300px; + display: flex; + flex-direction: column; + gap: 1.25rem; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +#side-panel fieldset { + border: 1px solid var(--card-border); + border-radius: 4px; + padding: 1rem; + background: var(--card-bg); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); +} + +#side-panel legend { + font-family: "Iowan Old Style", "Hoefler Text", Georgia, serif; + font-weight: 600; + padding: 0 0.4rem; + letter-spacing: 0.02em; + color: var(--ink); +} + +#side-panel label { + display: block; + margin: 0.5rem 0; + font-size: 0.95rem; +} + +#side-panel input[type="range"] { + width: 100%; + accent-color: var(--wall); +} +#side-panel select { + width: 100%; + padding: 0.4rem; + border: 1px solid var(--card-border); + border-radius: 3px; + background: white; + font: inherit; +} + +.who-goes-first label { + display: inline-block; + margin-right: 1rem; + font-weight: normal; +} + +#new-game-button { + margin-top: 0.6rem; + width: 100%; + padding: 0.7rem; + font-size: 1rem; + font-weight: 600; + letter-spacing: 0.03em; + background: var(--board-frame); + color: #f3e6c8; + border: none; + border-radius: 3px; + cursor: pointer; + transition: background 120ms ease-out; +} +#new-game-button:hover { background: #1f1207; } +#new-game-button:disabled { + background: #8a7a5e; + color: #ede1c1; + cursor: not-allowed; +} + +#status-panel { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 4px; + padding: 1rem; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); +} + +#status-panel h2 { + margin: 0 0 0.6rem; + font-family: "Iowan Old Style", "Hoefler Text", Georgia, serif; + font-size: 1.05rem; + font-weight: 600; + letter-spacing: 0.03em; +} + +#status-panel p { + margin: 0.35rem 0; + font-size: 0.95rem; +} + +.muted { color: var(--ink-muted); font-size: 0.85rem; margin: 0.6rem 0 0; } +.error { color: #9b1c1c; font-size: 0.9rem; } + +/* ---- Spinner ---- */ + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid var(--ink-muted); + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; + vertical-align: middle; + margin-left: 0.4em; +} +.spinner[hidden] { display: none; } + +@keyframes spin { to { transform: rotate(360deg); } } + +/* ---- Game-over banner ---- */ + +#game-over-banner { + display: none; + position: absolute; + top: 42%; + left: 50%; + transform: translate(-50%, -50%); + padding: 1.4rem 2.4rem; + background: rgba(20, 12, 4, 0.92); + color: #f3e6c8; + font-family: "Iowan Old Style", "Hoefler Text", Georgia, serif; + font-size: 1.7rem; + font-weight: 600; + letter-spacing: 0.05em; + border-radius: 4px; + pointer-events: none; + text-align: center; + border: 1px solid rgba(255, 230, 180, 0.2); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); +} diff --git a/deep_quoridor/rust/src/play_server/assets/app.js b/deep_quoridor/rust/src/play_server/assets/app.js new file mode 100644 index 00000000..4d08dc65 --- /dev/null +++ b/deep_quoridor/rust/src/play_server/assets/app.js @@ -0,0 +1,381 @@ +// Quoridor play server -- vanilla JS frontend. +// +// Coordinates: the server speaks absolute Quoridor coordinates with (0,0) +// at the top-left and player 0 starting on row 0. We always render the +// human's home row at the bottom, so when `human_player == 1` we mirror +// coordinates 180 deg before placing anything on the board grid. +// +// The board is a (2N-1) x (2N-1) CSS grid alternating pawn cells, wall +// slots, and wall posts. Server `legal_actions` already carry the +// kind/coords so the client never has to mirror Python's action-encoding +// logic. +// +// Move flow uses an optimistic update: when the human clicks an action, +// we immediately apply a local approximation of the new state (move the +// pawn, place the wall, flip the turn) so the UI reflects the click +// without waiting for the AI. When the server responds with the +// authoritative post-AI state, we replace the local state and re-render. + +const STATE = { + cfg: null, // /api/config response + gameId: null, + view: null, // last StateView (server- or optimistically-derived) + pending: false, +}; + +const $ = (sel) => document.querySelector(sel); + +function make(tag, attrs = {}, children = []) { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === "class") el.className = v; + else if (k === "text") el.textContent = v; + else if (k.startsWith("on") && typeof v === "function") { + el.addEventListener(k.slice(2), v); + } else { + el.setAttribute(k, v); + } + } + for (const c of children) el.appendChild(c); + return el; +} + +async function fetchJson(url, options) { + const resp = await fetch(url, options); + if (!resp.ok) { + let detail = resp.statusText; + try { + const body = await resp.json(); + if (body && body.error) detail = body.error; + } catch (_) { /* non-JSON error body */ } + throw new Error(detail); + } + return resp.json(); +} + +// ---- setup ---- + +async function init() { + try { + STATE.cfg = await fetchJson("/api/config"); + renderSetup(); + } catch (e) { + showError("Failed to load /api/config: " + e.message); + } +} + +function renderSetup() { + const sel = $("#model-select"); + sel.innerHTML = ""; + for (const name of STATE.cfg.models) { + sel.appendChild(make("option", { value: name, text: name })); + } + if (STATE.cfg.models.length === 0) { + sel.appendChild(make("option", { value: "", text: "(no models found)" })); + $("#new-game-button").disabled = true; + } + + const slider = $("#mcts-n"); + slider.value = STATE.cfg.default_mcts_n; + $("#mcts-n-display").textContent = slider.value; + + $("#board-size-display").textContent = + `${STATE.cfg.board_size}x${STATE.cfg.board_size}, ${STATE.cfg.max_walls} walls each`; +} + +async function startGame() { + const body = { + model: $("#model-select").value, + mcts_n: parseInt($("#mcts-n").value, 10), + human_player: parseInt( + document.querySelector('input[name="human-player"]:checked').value, + 10, + ), + }; + clearError(); + setPending(true); + try { + const data = await fetchJson("/api/games", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + STATE.gameId = data.game_id; + STATE.view = data.state; + render(); + } catch (e) { + showError("New game failed: " + e.message); + } finally { + setPending(false); + } +} + +// ---- optimistic update ---- + +// Apply a local approximation of `action` to `view` so the UI reflects +// the human's move immediately. The server's response will overwrite +// this with the authoritative state, which also includes the AI's reply. +// +// We do not attempt to recompute the post-action legal_actions; clicks +// are disabled while we wait on the server. +function applyOptimistic(view, action) { + const o = JSON.parse(JSON.stringify(view)); + const mover = o.current_player; + if (action.kind === "move") { + if (mover === 0) o.p1_pos = action.to; + else o.p2_pos = action.to; + // Detect immediate win: reaching the opposite home row ends the game. + const N = o.board_size; + const goalRow = mover === 0 ? N - 1 : 0; + if (action.to[0] === goalRow) o.winner = mover; + } else { + o.walls.push({ + row: action.row, + col: action.col, + orientation: action.orientation, + }); + if (mover === 0) o.p1_walls -= 1; + else o.p2_walls -= 1; + } + o.last_action = { ...action }; + o.move_history = [...o.move_history, action.index]; + o.current_player = 1 - mover; + o.completed_steps += 1; + o.legal_actions = []; + return o; +} + +async function sendMove(action) { + if (STATE.pending || !STATE.gameId) return; + clearError(); + + // Optimistic render: show the human's move now. + STATE.view = applyOptimistic(STATE.view, action); + render(); + setPending(true); + + try { + const data = await fetchJson(`/api/games/${STATE.gameId}/move`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action_index: action.index }), + }); + STATE.view = data.state; + render(); + } catch (e) { + showError("Move rejected: " + e.message); + // Roll back to the truth from the server. + try { + const fresh = await fetchJson(`/api/games/${STATE.gameId}`); + STATE.view = fresh.state; + render(); + } catch (_) { /* leave optimistic view; user will retry */ } + } finally { + setPending(false); + } +} + +// ---- ui-state helpers ---- + +function setPending(p) { + STATE.pending = p; + $("#spinner").hidden = !p; +} + +function showError(msg) { + const el = $("#error-display"); + el.textContent = msg; + el.hidden = false; +} +function clearError() { + const el = $("#error-display"); + el.textContent = ""; + el.hidden = true; +} + +// ---- coordinate transforms ---- +// +// We always render the human's home row at the bottom. Server P0 +// starts at server row 0; P1 starts at server row N-1. CSS-grid row 0 +// renders at the top of the screen, so: +// +// - human == P0 -> vertical flip (row N-1 - r), cols unchanged. +// - human == P1 -> no flip (P1 already at server row N-1 = bottom). +// +// Cols stay put so a wall's "left" half in server space stays the +// left half in display space; the anchor/extends-right rule the +// hover logic below relies on then needs no further inversion. + +function mirrorPawn(r, c) { + const N = STATE.view.board_size; + return STATE.view.human_player === 0 ? [N - 1 - r, c] : [r, c]; +} + +// Server walls are indexed by their (top, left) corner. After a +// vertical flip, what was server-top becomes display-bottom -- so the +// display (top, left) corner of the wall is at row N-2-r (one less +// than N-1-r because the wall spans two pawn rows). +function mirrorWall(r, c) { + const N = STATE.view.board_size; + return STATE.view.human_player === 0 ? [N - 2 - r, c] : [r, c]; +} + +// Return the (gr, gc) grid coordinates of the 3 cells that make up a +// wall at display (dr, dc): the two halves and the post between them. +function wallGroupCells(dr, dc, orientation) { + if (orientation === "h") { + return [ + [2 * dr + 1, 2 * dc], + [2 * dr + 1, 2 * dc + 1], + [2 * dr + 1, 2 * dc + 2], + ]; + } + return [ + [2 * dr, 2 * dc + 1], + [2 * dr + 1, 2 * dc + 1], + [2 * dr + 2, 2 * dc + 1], + ]; +} + +// ---- render ---- + +function render() { + const v = STATE.view; + const N = v.board_size; + const size = 2 * N - 1; + const board = $("#board"); + board.innerHTML = ""; + + // Alternating column/row sizes: pawn cell, post, pawn cell, post, ... + const tracks = Array.from({ length: size }, (_, i) => + i % 2 === 0 ? "var(--pawn-size)" : "var(--post-size)", + ).join(" "); + board.style.gridTemplateColumns = tracks; + board.style.gridTemplateRows = tracks; + + // Build the grid and remember each cell so we can decorate. + const cells = []; + for (let gr = 0; gr < size; gr++) { + cells.push([]); + for (let gc = 0; gc < size; gc++) { + const isRowEven = gr % 2 === 0; + const isColEven = gc % 2 === 0; + let cls = "post"; + if (isRowEven && isColEven) cls = "pawn-cell"; + else if (!isRowEven && isColEven) cls = "wall-h-half"; + else if (isRowEven && !isColEven) cls = "wall-v-half"; + const el = make("div", { class: `cell ${cls}` }); + cells[gr].push(el); + board.appendChild(el); + } + } + + // Pawns -- colors stay tied to the server player index so the + // walls-left counters in the side panel always match the pawn colors + // regardless of orientation. + const [p1r, p1c] = mirrorPawn(v.p1_pos[0], v.p1_pos[1]); + const [p2r, p2c] = mirrorPawn(v.p2_pos[0], v.p2_pos[1]); + cells[2 * p1r][2 * p1c].appendChild(make("div", { class: "pawn p1" })); + cells[2 * p2r][2 * p2c].appendChild(make("div", { class: "pawn p2" })); + + // Placed walls + for (const w of v.walls) { + const [dr, dc] = mirrorWall(w.row, w.col); + const placedCls = `wall-placed-${w.orientation}`; + for (const [gr, gc] of wallGroupCells(dr, dc, w.orientation)) { + cells[gr][gc].classList.add(placedCls); + } + } + + // Last-action highlight + if (v.last_action) { + const la = v.last_action; + if (la.kind === "move") { + const [dr, dc] = mirrorPawn(la.to[0], la.to[1]); + cells[2 * dr][2 * dc].classList.add("last-move"); + } else { + const [dr, dc] = mirrorWall(la.row, la.col); + for (const [gr, gc] of wallGroupCells(dr, dc, la.orientation)) { + cells[gr][gc].classList.add("last-wall"); + } + } + } + + // Click handlers on legal actions -- only when it's the human's turn. + // sendMove() ignores clicks while STATE.pending is true, so we don't + // need to also gate handler attachment on pending. + // + // Anchor-only attachment: each wall is interactive *only* on its + // display-top-left cell -- the left half for horizontal walls, the + // top half for vertical walls. This means: + // - Each grid cell triggers at most one wall, so hovering doesn't + // light up two overlapping walls at once. + // - Intersection posts (the small squares between four pawn cells) + // never trigger a wall, since they are never any wall's anchor. + // - The wall the user sees on hover is the one that "starts here + // and extends right (H) or down (V)" in display coordinates. + const humanTurn = v.winner === null && v.current_player === v.human_player; + if (humanTurn) { + for (const a of v.legal_actions) { + if (a.kind === "move") { + const [dr, dc] = mirrorPawn(a.to[0], a.to[1]); + const cell = cells[2 * dr][2 * dc]; + cell.classList.add("legal-move"); + cell.addEventListener("click", () => sendMove(a)); + } else { + const [dr, dc] = mirrorWall(a.row, a.col); + const group = wallGroupCells(dr, dc, a.orientation).map( + ([gr, gc]) => cells[gr][gc], + ); + const anchor = group[0]; // first cell is the display top-left + anchor.classList.add(`legal-wall-${a.orientation}`); + anchor.addEventListener("click", () => sendMove(a)); + anchor.addEventListener("mouseenter", () => { + for (const c of group) c.classList.add("wall-hover"); + }); + anchor.addEventListener("mouseleave", () => { + for (const c of group) c.classList.remove("wall-hover"); + }); + } + } + } + + // Status panel + const turnEl = $("#turn-display"); + if (v.winner !== null) { + turnEl.textContent = "Game over"; + } else if (v.current_player === v.human_player) { + turnEl.textContent = "Your move"; + } else { + turnEl.textContent = "AI thinking"; + } + // Map walls-left by role, not server index: when the human is P1 we + // want "You" to show p2_walls, not p1_walls. + const youWalls = v.human_player === 0 ? v.p1_walls : v.p2_walls; + const aiWalls = v.human_player === 0 ? v.p2_walls : v.p1_walls; + $("#walls-you").textContent = youWalls; + $("#walls-ai").textContent = aiWalls; + $("#completed-steps").textContent = `${v.completed_steps} / ${v.max_steps}`; + + // Game-over banner + const banner = $("#game-over-banner"); + if (v.winner === null) { + if (v.completed_steps >= v.max_steps) { + banner.style.display = "block"; + banner.textContent = "Draw (move limit reached)"; + } else { + banner.style.display = "none"; + } + } else { + banner.style.display = "block"; + banner.textContent = v.winner === v.human_player ? "You won!" : "AI won"; + } +} + +document.addEventListener("DOMContentLoaded", () => { + init(); + $("#mcts-n").addEventListener("input", (e) => { + $("#mcts-n-display").textContent = e.target.value; + }); + $("#new-game-button").addEventListener("click", startGame); +}); diff --git a/deep_quoridor/rust/src/play_server/assets/index.html b/deep_quoridor/rust/src/play_server/assets/index.html new file mode 100644 index 00000000..7b2667c7 --- /dev/null +++ b/deep_quoridor/rust/src/play_server/assets/index.html @@ -0,0 +1,62 @@ + + + + + Quoridor — vs AlphaZero + + + +
+

Quoridor vs AlphaZero

+
+
+
+
+
+
+ + +
+ + + + diff --git a/deep_quoridor/rust/src/play_server/config.rs b/deep_quoridor/rust/src/play_server/config.rs new file mode 100644 index 00000000..0c7adb35 --- /dev/null +++ b/deep_quoridor/rust/src/play_server/config.rs @@ -0,0 +1,134 @@ +//! Server-side configuration: derived from `/config.yaml` plus the +//! list of selectable models found in `/models/*.onnx`. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::Deserialize; + +/// What the server needs from the play directory. +#[derive(Debug, Clone)] +pub struct ServerConfig { + pub play_dir: PathBuf, + pub board_size: i32, + pub max_walls: i32, + pub max_steps: i32, + pub default_mcts_c_puct: f32, + /// Filenames (not full paths) of `*.onnx` files in `/models/`. + pub models: Vec, +} + +/// Subset of `config.yaml` we actually parse. Anything else is ignored. +#[derive(Debug, Deserialize)] +struct ConfigFile { + quoridor: QuoridorSection, + #[serde(default)] + alphazero: AlphaZeroSection, +} + +#[derive(Debug, Deserialize)] +struct QuoridorSection { + board_size: i32, + max_walls: i32, + max_steps: i32, +} + +#[derive(Debug, Deserialize, Default)] +struct AlphaZeroSection { + #[serde(default)] + mcts_c_puct: Option, +} + +impl ServerConfig { + pub fn load(play_dir: &Path) -> Result { + let cfg_path = play_dir.join("config.yaml"); + let raw = std::fs::read_to_string(&cfg_path) + .with_context(|| format!("reading {}", cfg_path.display()))?; + let file: ConfigFile = serde_yaml::from_str(&raw) + .with_context(|| format!("parsing {}", cfg_path.display()))?; + + let models_dir = play_dir.join("models"); + let mut models: Vec = std::fs::read_dir(&models_dir) + .with_context(|| format!("reading {}", models_dir.display()))? + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("onnx")) + .filter_map(|e| e.file_name().to_str().map(|s| s.to_string())) + .collect(); + models.sort(); + + Ok(Self { + play_dir: play_dir.to_path_buf(), + board_size: file.quoridor.board_size, + max_walls: file.quoridor.max_walls, + max_steps: file.quoridor.max_steps, + default_mcts_c_puct: file.alphazero.mcts_c_puct.unwrap_or(1.4), + models, + }) + } + + /// Full path to a chosen model file. Returns `None` if the name isn't in + /// the listed `models`. + pub fn model_path(&self, model: &str) -> Option { + if self.models.iter().any(|m| m == model) { + Some(self.play_dir.join("models").join(model)) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn make_play_dir(yaml: &str, model_files: &[&str]) -> PathBuf { + let dir = tempfile::Builder::new() + .prefix("playsrv_test_") + .tempdir() + .expect("tempdir") + .keep(); + fs::write(dir.join("config.yaml"), yaml).unwrap(); + fs::create_dir_all(dir.join("models")).unwrap(); + for f in model_files { + fs::write(dir.join("models").join(f), b"not really onnx").unwrap(); + } + dir + } + + #[test] + fn loads_minimal_config_and_lists_models_sorted() { + let dir = make_play_dir( + "quoridor:\n board_size: 5\n max_walls: 2\n max_steps: 50\n", + &["model_002.onnx", "model_000.onnx", "ignore.txt"], + ); + let cfg = ServerConfig::load(&dir).unwrap(); + assert_eq!(cfg.board_size, 5); + assert_eq!(cfg.max_walls, 2); + assert_eq!(cfg.max_steps, 50); + assert!((cfg.default_mcts_c_puct - 1.4).abs() < 1e-6); + assert_eq!(cfg.models, vec!["model_000.onnx", "model_002.onnx"]); + } + + #[test] + fn picks_up_alphazero_c_puct_when_present() { + let dir = make_play_dir( + "quoridor:\n board_size: 5\n max_walls: 2\n max_steps: 50\n\ + alphazero:\n mcts_c_puct: 1.7\n", + &["m.onnx"], + ); + let cfg = ServerConfig::load(&dir).unwrap(); + assert!((cfg.default_mcts_c_puct - 1.7).abs() < 1e-6); + } + + #[test] + fn model_path_returns_some_for_listed_and_none_for_unlisted() { + let dir = make_play_dir( + "quoridor:\n board_size: 5\n max_walls: 2\n max_steps: 50\n", + &["a.onnx", "b.onnx"], + ); + let cfg = ServerConfig::load(&dir).unwrap(); + assert!(cfg.model_path("a.onnx").is_some()); + assert!(cfg.model_path("c.onnx").is_none()); + } +} diff --git a/deep_quoridor/rust/src/play_server/handlers.rs b/deep_quoridor/rust/src/play_server/handlers.rs new file mode 100644 index 00000000..cd9b8744 --- /dev/null +++ b/deep_quoridor/rust/src/play_server/handlers.rs @@ -0,0 +1,284 @@ +//! Pure-function HTTP handlers for the play server. +//! +//! Each handler takes the inputs it needs (registry + parsed JSON) and returns +//! a `Result` where `T` is `serde::Serialize`. Task 6 wires +//! these into `tiny_http` requests and maps errors to status codes. + +use serde::{Deserialize, Serialize}; + +use crate::play_server::config::ServerConfig; +use crate::play_server::session::{GameRegistry, GameSession}; +use crate::play_server::state::StateView; + +/// Error kind exposed to the HTTP layer. Each variant maps to one status code. +#[derive(Debug)] +pub enum HandlerError { + /// 400: client sent something the server cannot honor (unknown model, + /// illegal move, malformed JSON the handler caught itself). + BadRequest(String), + /// 404: `game_id` not found in the registry. + NotFound(String), + /// 500: internal failure (ORT load, MCTS, IO). + Internal(String), +} + +impl HandlerError { + pub fn message(&self) -> &str { + match self { + HandlerError::BadRequest(m) | HandlerError::NotFound(m) | HandlerError::Internal(m) => { + m + } + } + } +} + +#[derive(Debug, Serialize)] +pub struct ConfigView { + pub board_size: i32, + pub max_walls: i32, + pub max_steps: i32, + pub models: Vec, + pub default_mcts_n: u32, +} + +#[derive(Deserialize)] +pub struct NewGameRequest { + pub model: String, + pub mcts_n: u32, + pub human_player: i32, +} + +#[derive(Debug, Serialize)] +pub struct NewGameResponse { + pub game_id: String, + pub state: StateView, +} + +#[derive(Deserialize)] +pub struct MoveRequest { + pub action_index: u32, +} + +#[derive(Debug, Serialize)] +pub struct StateResponse { + pub state: StateView, +} + +/// `GET /api/config` — server tells the client what board/models are available. +pub fn get_config(cfg: &ServerConfig, default_mcts_n: u32) -> ConfigView { + ConfigView { + board_size: cfg.board_size, + max_walls: cfg.max_walls, + max_steps: cfg.max_steps, + models: cfg.models.clone(), + default_mcts_n, + } +} + +/// `POST /api/games` — create a new game with the chosen model + mcts_n. +pub fn create_game( + cfg: &ServerConfig, + registry: &GameRegistry, + req: NewGameRequest, +) -> Result { + if req.human_player != 0 && req.human_player != 1 { + return Err(HandlerError::BadRequest(format!( + "human_player must be 0 or 1, got {}", + req.human_player + ))); + } + let model_path = cfg + .model_path(&req.model) + .ok_or_else(|| HandlerError::BadRequest(format!("unknown model: {}", req.model)))?; + let mut session = GameSession::new_from_onnx(cfg, &model_path, req.mcts_n, req.human_player) + .map_err(|e| HandlerError::Internal(format!("failed to create session: {e:#}")))?; + // If the AI moves first, take its move now so the initial state the client + // renders already shows it. + if session.human_player != session_current_player(&mut session) { + session + .ai_step() + .map_err(|e| HandlerError::Internal(format!("AI opening move failed: {e:#}")))?; + } + let state = session.view(); + let game_id = registry.insert(session); + Ok(NewGameResponse { game_id, state }) +} + +/// `GET /api/games/` — fetch the current state of an existing game. +pub fn get_game(registry: &GameRegistry, game_id: &str) -> Result { + let session_arc = registry + .get(game_id) + .ok_or_else(|| HandlerError::NotFound(format!("game {game_id} not found")))?; + let mut session = session_arc.lock().unwrap(); + Ok(StateResponse { + state: session.view(), + }) +} + +/// `POST /api/games//move` — apply the human's move, then (if it's +/// then the AI's turn) the AI's response in the same round-trip. +pub fn apply_move( + registry: &GameRegistry, + game_id: &str, + req: MoveRequest, +) -> Result { + let session_arc = registry + .get(game_id) + .ok_or_else(|| HandlerError::NotFound(format!("game {game_id} not found")))?; + let mut session = session_arc.lock().unwrap(); + session + .apply_action(req.action_index) + .map_err(|e| HandlerError::BadRequest(format!("{e:#}")))?; + // If after the human move it's now the AI's turn (and the game isn't + // over), run one AI step. + if session_should_ai_move(&mut session) { + session + .ai_step() + .map_err(|e| HandlerError::Internal(format!("AI step failed: {e:#}")))?; + } + Ok(StateResponse { + state: session.view(), + }) +} + +/// Helper: read current player without depending on private session API. +fn session_current_player(session: &mut GameSession) -> i32 { + session.view().current_player +} + +/// Helper: should the AI take its move right now? +fn session_should_ai_move(session: &mut GameSession) -> bool { + let v = session.view(); + v.winner.is_none() && v.current_player != v.human_player +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agents::alphazero::evaluator::UniformMockEvaluator; + use crate::play_server::session::GameSession; + use std::path::PathBuf; + + fn fake_cfg() -> ServerConfig { + ServerConfig { + play_dir: PathBuf::from("/tmp/does-not-matter"), + board_size: 5, + max_walls: 2, + max_steps: 50, + default_mcts_c_puct: 1.4, + models: vec!["a.onnx".to_string(), "b.onnx".to_string()], + } + } + + fn fresh_registry_with_session(human_player: i32) -> (GameRegistry, String) { + let reg = GameRegistry::new(); + let session = GameSession::new_with_evaluator( + 5, + 2, + 50, + Box::new(UniformMockEvaluator), + 8, + 1.4, + human_player, + ); + let id = reg.insert(session); + (reg, id) + } + + #[test] + fn get_config_returns_models_and_dimensions() { + let cfg = fake_cfg(); + let view = get_config(&cfg, 400); + assert_eq!(view.board_size, 5); + assert_eq!(view.max_walls, 2); + assert_eq!(view.max_steps, 50); + assert_eq!(view.models, vec!["a.onnx", "b.onnx"]); + assert_eq!(view.default_mcts_n, 400); + } + + #[test] + fn create_game_rejects_unknown_model() { + let cfg = fake_cfg(); + let reg = GameRegistry::new(); + let req = NewGameRequest { + model: "missing.onnx".to_string(), + mcts_n: 16, + human_player: 0, + }; + let err = create_game(&cfg, ®, req).unwrap_err(); + assert!(matches!(err, HandlerError::BadRequest(_))); + assert!(err.message().contains("unknown model")); + } + + #[test] + fn create_game_rejects_bad_human_player() { + let cfg = fake_cfg(); + let reg = GameRegistry::new(); + let req = NewGameRequest { + model: "a.onnx".to_string(), + mcts_n: 16, + human_player: 7, + }; + let err = create_game(&cfg, ®, req).unwrap_err(); + assert!(matches!(err, HandlerError::BadRequest(_))); + assert!(err.message().contains("human_player")); + } + + #[test] + fn get_game_returns_state_for_known_id() { + let (reg, id) = fresh_registry_with_session(0); + let resp = get_game(®, &id).unwrap(); + assert_eq!(resp.state.board_size, 5); + assert_eq!(resp.state.current_player, 0); + } + + #[test] + fn get_game_404s_for_unknown_id() { + let reg = GameRegistry::new(); + let err = get_game(®, "deadbeef").unwrap_err(); + assert!(matches!(err, HandlerError::NotFound(_))); + } + + #[test] + fn apply_move_rejects_illegal_action() { + let (reg, id) = fresh_registry_with_session(0); + // Pick an action index that is definitely not legal on a fresh + // 5x5/2-wall board: walls require board space and the initial mask + // disallows most wall slots. Use a far-out index. + let req = MoveRequest { + action_index: u32::MAX, + }; + let err = apply_move(®, &id, req).unwrap_err(); + assert!(matches!(err, HandlerError::BadRequest(_))); + } + + #[test] + fn apply_move_404s_for_unknown_id() { + let reg = GameRegistry::new(); + let req = MoveRequest { action_index: 0 }; + let err = apply_move(®, "deadbeef", req).unwrap_err(); + assert!(matches!(err, HandlerError::NotFound(_))); + } + + #[test] + fn apply_move_legal_human_action_advances_state() { + let (reg, id) = fresh_registry_with_session(0); + // Find a legal action from the current view. + let first_legal = { + let session_arc = reg.get(&id).unwrap(); + let mut s = session_arc.lock().unwrap(); + let v = s.view(); + v.legal_actions + .first() + .expect("at least one legal action") + .index() + }; + let req = MoveRequest { + action_index: first_legal, + }; + let resp = apply_move(®, &id, req).unwrap(); + // After the human moves it becomes the AI's turn — the handler then + // runs the AI step automatically — so move_history has at least 2. + assert!(resp.state.move_history.len() >= 2); + } +} diff --git a/deep_quoridor/rust/src/play_server/http.rs b/deep_quoridor/rust/src/play_server/http.rs new file mode 100644 index 00000000..96a65d23 --- /dev/null +++ b/deep_quoridor/rust/src/play_server/http.rs @@ -0,0 +1,127 @@ +//! HTTP routing for the play server. +//! +//! Lives in the library (not the binary) so both `bin/play_server.rs` and the +//! end-to-end integration test (`tests/play_server_e2e.rs`) can share one +//! source of truth for request dispatch + response shaping. + +use anyhow::{Context, Result}; +use serde_json::{Value, json}; +use tiny_http::{Header, Method, Request, Response}; + +use crate::play_server::config::ServerConfig; +use crate::play_server::handlers::{ + HandlerError, MoveRequest, NewGameRequest, apply_move, create_game, get_config, get_game, +}; +use crate::play_server::session::GameRegistry; + +pub const INDEX_HTML: &str = include_str!("assets/index.html"); +pub const APP_CSS: &str = include_str!("assets/app.css"); +pub const APP_JS: &str = include_str!("assets/app.js"); + +/// Route a single request to the appropriate handler and write the response. +pub fn handle_request( + mut req: Request, + cfg: &ServerConfig, + registry: &GameRegistry, + default_mcts_n: u32, +) -> Result<()> { + let method = req.method().clone(); + let url = req.url().to_string(); + // Strip query string if any. + let path = url.split('?').next().unwrap_or(&url).to_string(); + + let result: Result>>, HandlerError> = + (|| match (&method, path.as_str()) { + (&Method::Get, "/") => Ok(html_response(INDEX_HTML)), + (&Method::Get, "/static/app.css") => Ok(text_response("text/css", APP_CSS)), + (&Method::Get, "/static/app.js") => Ok(text_response("application/javascript", APP_JS)), + (&Method::Get, "/api/config") => { + let view = get_config(cfg, default_mcts_n); + Ok(json_response(serde_json::to_value(view).map_err(|e| { + HandlerError::Internal(format!("serializing config: {e}")) + })?)) + } + (&Method::Post, "/api/games") => { + let body = read_body(&mut req) + .map_err(|e| HandlerError::BadRequest(format!("reading body: {e}")))?; + let parsed: NewGameRequest = serde_json::from_str(&body) + .map_err(|e| HandlerError::BadRequest(format!("invalid JSON: {e}")))?; + let resp = create_game(cfg, registry, parsed)?; + Ok(json_response(serde_json::to_value(resp).map_err(|e| { + HandlerError::Internal(format!("serializing response: {e}")) + })?)) + } + (&Method::Post, p) if p.starts_with("/api/games/") && p.ends_with("/move") => { + let id = &p["/api/games/".len()..p.len() - "/move".len()]; + let body = read_body(&mut req) + .map_err(|e| HandlerError::BadRequest(format!("reading body: {e}")))?; + let parsed: MoveRequest = serde_json::from_str(&body) + .map_err(|e| HandlerError::BadRequest(format!("invalid JSON: {e}")))?; + let resp = apply_move(registry, id, parsed)?; + Ok(json_response(serde_json::to_value(resp).map_err(|e| { + HandlerError::Internal(format!("serializing response: {e}")) + })?)) + } + (&Method::Get, p) + if p.starts_with("/api/games/") + && !p[("/api/games/".len())..].is_empty() + && !p.ends_with("/move") => + { + let id = &p["/api/games/".len()..]; + let resp = get_game(registry, id)?; + Ok(json_response(serde_json::to_value(resp).map_err(|e| { + HandlerError::Internal(format!("serializing response: {e}")) + })?)) + } + _ => Err(HandlerError::NotFound(format!( + "no route for {} {}", + method, path + ))), + })(); + + let response = match result { + Ok(r) => r, + Err(e) => error_response(&e), + }; + req.respond(response).context("writing HTTP response") +} + +fn read_body(req: &mut Request) -> std::io::Result { + let mut buf = String::new(); + req.as_reader().read_to_string(&mut buf)?; + Ok(buf) +} + +fn html_response(body: &'static str) -> Response>> { + text_response("text/html; charset=utf-8", body) +} + +fn text_response(content_type: &str, body: &'static str) -> Response>> { + Response::from_string(body).with_header( + Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes()) + .expect("content-type header"), + ) +} + +fn json_response(value: Value) -> Response>> { + let body = value.to_string(); + Response::from_string(body).with_header( + Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]) + .expect("content-type header"), + ) +} + +fn error_response(err: &HandlerError) -> Response>> { + let status = match err { + HandlerError::BadRequest(_) => 400, + HandlerError::NotFound(_) => 404, + HandlerError::Internal(_) => 500, + }; + let body = json!({ "error": err.message() }).to_string(); + Response::from_string(body) + .with_status_code(status) + .with_header( + Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]) + .expect("content-type header"), + ) +} diff --git a/deep_quoridor/rust/src/play_server/mod.rs b/deep_quoridor/rust/src/play_server/mod.rs new file mode 100644 index 00000000..588efb8e --- /dev/null +++ b/deep_quoridor/rust/src/play_server/mod.rs @@ -0,0 +1,10 @@ +//! Local web server for playing Quoridor against the AlphaZero agent. +//! +//! Architecture overview is in +//! `docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md`. + +pub mod config; +pub mod handlers; +pub mod http; +pub mod session; +pub mod state; diff --git a/deep_quoridor/rust/src/play_server/session.rs b/deep_quoridor/rust/src/play_server/session.rs new file mode 100644 index 00000000..138162c3 --- /dev/null +++ b/deep_quoridor/rust/src/play_server/session.rs @@ -0,0 +1,380 @@ +//! Per-game state (`GameSession`) and the shared registry that the HTTP +//! handlers look up by `game_id`. +//! +//! Each session owns its own `AlphaZeroAgent` so games run independently. The +//! registry holds an `Arc>` per game; an HTTP handler takes +//! the outer `Mutex` briefly to look up the session and then holds the inner +//! `Mutex` for the duration of the move + AI response. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use anyhow::{Context, Result, anyhow}; +use rand::RngCore; + +use crate::agents::ActionSelector; +use crate::agents::alphazero::agent::{AlphaZeroAgent, AlphaZeroAgentConfig}; +#[cfg(test)] +use crate::agents::alphazero::evaluator::Evaluator; +use crate::agents::alphazero::mcts::MCTSConfig; +use crate::compact::q_bit_repr::{CompactState, WALL_HORIZONTAL, WALL_VERTICAL}; +use crate::compact::q_game_mechanics::QGameMechanics; +use crate::play_server::config::ServerConfig; +use crate::play_server::state::{ + EnrichedAction, StateView, WallEntry, WallOrientation, enrich_action, enrich_legal_actions, +}; + +pub type GameId = String; + +/// One running game: owns the agent and the board state. +pub struct GameSession { + pub mechanics: QGameMechanics, + pub state: CompactState, + pub agent: AlphaZeroAgent, + pub board_size: i32, + pub max_walls: i32, + pub max_steps: i32, + pub human_player: i32, + pub last_action: Option, + pub move_history: Vec, +} + +impl GameSession { + /// Construct an agent config matching the server's notion of "play-mode": + /// the requested `mcts_n`, temperature 0 (argmax visits), deterministic + /// tie-break, no Dirichlet noise, and the server's max_steps as the MCTS + /// search cap. + pub fn agent_config(mcts_n: u32, c_puct: f32, max_steps: i32) -> AlphaZeroAgentConfig { + AlphaZeroAgentConfig { + mcts: MCTSConfig { + n: Some(mcts_n), + k: None, + ucb_c: c_puct, + noise_epsilon: 0.0, + noise_alpha: None, + max_steps: Some(max_steps), + penalize_visited_states: false, + }, + temperature: 0.0, + drop_t_on_step: None, + penalize_visited_states: false, + deterministic_tie_break: true, + } + } + + /// Build a session that loads ONNX from disk. The session's mechanics + + /// initial state come from `cfg`. + pub fn new_from_onnx( + cfg: &ServerConfig, + model_path: &std::path::Path, + mcts_n: u32, + human_player: i32, + ) -> Result { + let mechanics = QGameMechanics::new( + cfg.board_size as usize, + cfg.max_walls as usize, + cfg.max_steps as usize, + ); + let state = mechanics.create_initial_state(); + let model_str = model_path + .to_str() + .ok_or_else(|| anyhow!("model path is not valid UTF-8"))?; + let agent_config = Self::agent_config(mcts_n, cfg.default_mcts_c_puct, cfg.max_steps); + let agent = + AlphaZeroAgent::new(model_str, agent_config).context("constructing AlphaZeroAgent")?; + Ok(Self { + mechanics, + state, + agent, + board_size: cfg.board_size, + max_walls: cfg.max_walls, + max_steps: cfg.max_steps, + human_player, + last_action: None, + move_history: Vec::new(), + }) + } + + /// Test-only variant that injects a fake evaluator instead of loading ORT. + #[cfg(test)] + pub fn new_with_evaluator( + board_size: i32, + max_walls: i32, + max_steps: i32, + evaluator: Box, + mcts_n: u32, + c_puct: f32, + human_player: i32, + ) -> Self { + let mechanics = + QGameMechanics::new(board_size as usize, max_walls as usize, max_steps as usize); + let state = mechanics.create_initial_state(); + let agent_config = Self::agent_config(mcts_n, c_puct, max_steps); + let agent = AlphaZeroAgent::with_evaluator(evaluator, agent_config); + Self { + mechanics, + state, + agent, + board_size, + max_walls, + max_steps, + human_player, + last_action: None, + move_history: Vec::new(), + } + } + + fn current_player(&self) -> i32 { + self.mechanics.repr().get_current_player(self.state) as i32 + } + + fn is_game_over(&self) -> bool { + self.mechanics.is_game_over(self.state) + || self.mechanics.repr().get_completed_steps(self.state) >= self.max_steps as usize + } + + fn legal_mask(&mut self) -> Vec { + self.mechanics.get_action_mask_immut(self.state) + } + + /// Apply one action (no matter whose turn). Records it in `last_action` + + /// `move_history`. Returns an error if the action is illegal. + pub fn apply_action(&mut self, action_index: u32) -> Result<()> { + if self.is_game_over() { + return Err(anyhow!("game is already over")); + } + let mask = self.legal_mask(); + let idx = action_index as usize; + if idx >= mask.len() || !mask[idx] { + return Err(anyhow!("action {action_index} is not legal")); + } + self.last_action = Some(enrich_action(self.board_size, idx)); + self.move_history.push(action_index); + self.mechanics.apply_action_index(&mut self.state, idx); + Ok(()) + } + + /// Run the AI for one move on the current state. Errors if it's actually + /// the human's turn or the game is over. + pub fn ai_step(&mut self) -> Result<()> { + if self.is_game_over() { + return Ok(()); + } + if self.current_player() == self.human_player { + return Err(anyhow!("not AI's turn")); + } + let mask = self.legal_mask(); + let (action_idx, _policy) = self + .agent + .select_action(self.state, &self.mechanics, &mask) + .context("AI MCTS selection")?; + self.last_action = Some(enrich_action(self.board_size, action_idx)); + self.move_history.push(action_idx as u32); + self.mechanics + .apply_action_index(&mut self.state, action_idx); + Ok(()) + } + + /// Build the JSON-facing snapshot the client renders from. + pub fn view(&mut self) -> StateView { + let mask = self.legal_mask(); + let legal_actions = enrich_legal_actions(self.board_size, &mask); + let repr = self.mechanics.repr(); + let (p1r, p1c) = repr.get_player_position(self.state, 0); + let (p2r, p2c) = repr.get_player_position(self.state, 1); + let p1w = repr.get_walls_remaining(self.state, 0) as i32; + let p2w = repr.get_walls_remaining(self.state, 1) as i32; + let completed_steps = repr.get_completed_steps(self.state) as i32; + let winner = if self.mechanics.check_win(self.state, 0) { + Some(0) + } else if self.mechanics.check_win(self.state, 1) { + Some(1) + } else { + None + }; + + StateView { + board_size: self.board_size, + max_walls: self.max_walls, + max_steps: self.max_steps, + current_player: self.current_player(), + p1_pos: [p1r as i32, p1c as i32], + p2_pos: [p2r as i32, p2c as i32], + p1_walls: p1w, + p2_walls: p2w, + walls: list_walls(&self.mechanics, self.state, self.board_size), + legal_actions, + completed_steps, + winner, + human_player: self.human_player, + last_action: self.last_action.clone(), + move_history: self.move_history.clone(), + } + } +} + +/// Iterate every potential wall slot (`(N-1)^2` for each orientation) and ask +/// the mechanics whether a wall is currently present at that location. +/// +/// The `QBitRepr::get_wall` API takes `orientation: usize` where +/// `WALL_VERTICAL=0` and `WALL_HORIZONTAL=1`. +fn list_walls(mechanics: &QGameMechanics, state: CompactState, board_size: i32) -> Vec { + let mut out = Vec::new(); + let wall_size = (board_size - 1) as usize; + for (orientation_const, orientation) in [ + (WALL_VERTICAL, WallOrientation::V), + (WALL_HORIZONTAL, WallOrientation::H), + ] { + for row in 0..wall_size { + for col in 0..wall_size { + if mechanics + .repr() + .get_wall(state, row, col, orientation_const) + { + out.push(WallEntry { + row: row as i32, + col: col as i32, + orientation, + }); + } + } + } + } + out +} + +/// Thread-safe map `game_id -> GameSession`. +#[derive(Clone, Default)] +pub struct GameRegistry { + inner: Arc>>>>, +} + +impl GameRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&self, session: GameSession) -> GameId { + let id = new_game_id(); + self.inner + .lock() + .unwrap() + .insert(id.clone(), Arc::new(Mutex::new(session))); + id + } + + pub fn get(&self, game_id: &str) -> Option>> { + self.inner.lock().unwrap().get(game_id).cloned() + } +} + +fn new_game_id() -> GameId { + let mut bytes = [0u8; 4]; + rand::thread_rng().fill_bytes(&mut bytes); + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agents::alphazero::evaluator::UniformMockEvaluator; + + fn mock_session(human_player: i32) -> GameSession { + GameSession::new_with_evaluator( + 5, + 2, + 50, + Box::new(UniformMockEvaluator), + 8, + 1.4, + human_player, + ) + } + + fn register(reg: &GameRegistry, s: GameSession) -> String { + reg.insert(s) + } + + #[test] + fn initial_view_has_pawns_on_home_rows_and_no_walls() { + let mut s = mock_session(0); + let v = s.view(); + assert_eq!(v.board_size, 5); + assert_eq!(v.max_walls, 2); + assert_eq!(v.current_player, 0); + assert_eq!(v.completed_steps, 0); + assert!(v.walls.is_empty()); + assert_eq!(v.p1_walls, 2); + assert_eq!(v.p2_walls, 2); + assert!(v.winner.is_none()); + assert_eq!(v.human_player, 0); + assert!(v.last_action.is_none()); + assert!(v.move_history.is_empty()); + let has_move = v + .legal_actions + .iter() + .any(|a| matches!(a, EnrichedAction::Move { .. })); + assert!(has_move); + } + + #[test] + fn apply_action_records_last_action_and_advances_player() { + let mut s = mock_session(0); + let mask = s.legal_mask(); + let first_legal_move = mask + .iter() + .enumerate() + .find(|&(_, &b)| b) + .map(|(i, _)| i as u32) + .expect("at least one legal action"); + s.apply_action(first_legal_move).unwrap(); + assert_eq!(s.move_history, vec![first_legal_move]); + assert!(s.last_action.is_some()); + assert_eq!(s.current_player(), 1); + } + + #[test] + fn apply_action_rejects_illegal_index() { + let mut s = mock_session(0); + let mask = s.legal_mask(); + let illegal = mask + .iter() + .enumerate() + .find(|&(_, &b)| !b) + .map(|(i, _)| i as u32) + .expect("at least one illegal action"); + let err = s.apply_action(illegal).unwrap_err(); + assert!(err.to_string().contains("not legal")); + } + + #[test] + fn ai_step_errors_when_its_human_turn() { + let mut s = mock_session(0); + let err = s.ai_step().unwrap_err(); + assert!(err.to_string().contains("not AI's turn")); + } + + #[test] + fn ai_step_runs_when_its_ai_turn() { + let mut s = mock_session(1); + s.ai_step().unwrap(); + assert_eq!(s.move_history.len(), 1); + assert!(s.last_action.is_some()); + assert_eq!(s.current_player(), 1); + } + + #[test] + fn registry_insert_and_get_round_trip() { + let reg = GameRegistry::new(); + let id = register(®, mock_session(0)); + assert!(reg.get(&id).is_some()); + assert!(reg.get("does-not-exist").is_none()); + } + + #[test] + fn game_id_is_8_hex_chars() { + let id = new_game_id(); + assert_eq!(id.len(), 8); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); + } +} diff --git a/deep_quoridor/rust/src/play_server/state.rs b/deep_quoridor/rust/src/play_server/state.rs new file mode 100644 index 00000000..0b3e0c9a --- /dev/null +++ b/deep_quoridor/rust/src/play_server/state.rs @@ -0,0 +1,215 @@ +//! Pure types and helpers for the play-server `state` JSON shape and for +//! enriching action indices with their semantic board coordinates. + +use serde::Serialize; + +use crate::actions::{ + ACTION_MOVE, ACTION_WALL_HORIZONTAL, ACTION_WALL_VERTICAL, action_index_to_action, +}; + +/// Single legal action carried over the wire. The client never needs to know +/// the action-index encoding; it just looks at `kind` and the coords. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum EnrichedAction { + Move { + index: u32, + to: [i32; 2], + }, + Wall { + index: u32, + row: i32, + col: i32, + orientation: WallOrientation, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum WallOrientation { + H, + V, +} + +/// Snapshot of a `GameSession` for the client to render. Built by +/// `session::GameSession::view()` from the underlying `QGameMechanics` + +/// `CompactState`. +#[derive(Debug, Clone, Serialize)] +pub struct StateView { + pub board_size: i32, + pub max_walls: i32, + pub max_steps: i32, + pub current_player: i32, + pub p1_pos: [i32; 2], + pub p2_pos: [i32; 2], + pub p1_walls: i32, + pub p2_walls: i32, + pub walls: Vec, + pub legal_actions: Vec, + pub completed_steps: i32, + pub winner: Option, + pub human_player: i32, + pub last_action: Option, + pub move_history: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct WallEntry { + pub row: i32, + pub col: i32, + pub orientation: WallOrientation, +} + +impl EnrichedAction { + /// The bare action index common to all variants. + pub fn index(&self) -> u32 { + match self { + EnrichedAction::Move { index, .. } => *index, + EnrichedAction::Wall { index, .. } => *index, + } + } +} + +/// Map an action index to its semantic enrichment (move dest / wall coords + +/// orientation). Matches the convention in `actions::action_index_to_action`: +/// indices < N*N are moves; the next (N-1)^2 are vertical walls; the +/// remaining (N-1)^2 are horizontal walls. +pub fn enrich_action(board_size: i32, index: usize) -> EnrichedAction { + let [row, col, action_type] = action_index_to_action(board_size, index); + match action_type { + ACTION_WALL_VERTICAL => EnrichedAction::Wall { + index: index as u32, + row, + col, + orientation: WallOrientation::V, + }, + ACTION_WALL_HORIZONTAL => EnrichedAction::Wall { + index: index as u32, + row, + col, + orientation: WallOrientation::H, + }, + ACTION_MOVE => EnrichedAction::Move { + index: index as u32, + to: [row, col], + }, + other => panic!("unexpected action type {other} for index {index}"), + } +} + +/// Apply `enrich_action` to every legal index in `mask`. +pub fn enrich_legal_actions(board_size: i32, mask: &[bool]) -> Vec { + mask.iter() + .enumerate() + .filter(|&(_, legal)| *legal) + .map(|(i, _)| enrich_action(board_size, i)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::actions::policy_size; + + #[test] + fn enriched_action_index_returns_bare_index() { + assert_eq!(enrich_action(5, 7).index(), 7); + assert_eq!(enrich_action(5, 25).index(), 25); // first vertical wall on 5x5 + } + + #[test] + fn enrich_move_action_round_trips_coords() { + // First action on a 5x5 board: move to (0, 0). + let a = enrich_action(5, 0); + assert_eq!( + a, + EnrichedAction::Move { + index: 0, + to: [0, 0] + } + ); + + // Index N*N - 1 on 5x5 = last move cell = (4, 4). + let a = enrich_action(5, 24); + assert_eq!( + a, + EnrichedAction::Move { + index: 24, + to: [4, 4] + } + ); + } + + #[test] + fn enrich_first_vertical_then_first_horizontal_wall() { + let n: i32 = 5; + let nn = (n * n) as usize; + let walls = ((n - 1) * (n - 1)) as usize; + + // First vertical wall is at index N*N. + let v = enrich_action(n, nn); + assert_eq!( + v, + EnrichedAction::Wall { + index: nn as u32, + row: 0, + col: 0, + orientation: WallOrientation::V + } + ); + + // First horizontal wall is at index N*N + (N-1)^2. + let h = enrich_action(n, nn + walls); + assert_eq!( + h, + EnrichedAction::Wall { + index: (nn + walls) as u32, + row: 0, + col: 0, + orientation: WallOrientation::H + } + ); + } + + #[test] + fn enrich_legal_actions_filters_by_mask() { + let n = 5; + let size = policy_size(n); + let mut mask = vec![false; size]; + mask[0] = true; + mask[(n * n) as usize] = true; // first vertical wall + + let actions = enrich_legal_actions(n, &mask); + assert_eq!(actions.len(), 2); + assert!(matches!(actions[0], EnrichedAction::Move { index: 0, .. })); + assert!(matches!( + actions[1], + EnrichedAction::Wall { + orientation: WallOrientation::V, + .. + } + )); + } + + #[test] + fn enriched_action_serializes_with_kind_tag() { + let m = EnrichedAction::Move { + index: 3, + to: [4, 5], + }; + let s = serde_json::to_string(&m).unwrap(); + assert_eq!(s, r#"{"kind":"move","index":3,"to":[4,5]}"#); + + let w = EnrichedAction::Wall { + index: 17, + row: 3, + col: 2, + orientation: WallOrientation::H, + }; + let s = serde_json::to_string(&w).unwrap(); + assert_eq!( + s, + r#"{"kind":"wall","index":17,"row":3,"col":2,"orientation":"h"}"# + ); + } +} diff --git a/deep_quoridor/rust/tests/play_server_e2e.rs b/deep_quoridor/rust/tests/play_server_e2e.rs new file mode 100644 index 00000000..e6bd78a8 --- /dev/null +++ b/deep_quoridor/rust/tests/play_server_e2e.rs @@ -0,0 +1,156 @@ +//! End-to-end test: boot the play server in-process, play a few moves +//! against the real B5W2 ONNX fixture, assert state transitions. +//! +//! Run: cargo test --no-default-features --features binary --test play_server_e2e -- --nocapture +#![cfg(feature = "binary")] + +use std::net::TcpStream; +use std::path::PathBuf; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use quoridor_rs::play_server::config::ServerConfig; +use quoridor_rs::play_server::http::handle_request; +use quoridor_rs::play_server::session::GameRegistry; + +fn fixture_onnx_path() -> Option { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fallback = root.join("fixtures").join("alphazero_B5W2_mv1.onnx"); + let p = std::env::var("DEEP_QUORIDOR_ONNX_MODEL") + .map(PathBuf::from) + .unwrap_or(fallback); + if p.exists() { Some(p) } else { None } +} + +#[test] +fn play_server_serves_a_few_moves_against_real_model() { + let onnx = match fixture_onnx_path() { + Some(p) => p, + None => { + eprintln!("skipping: B5W2 ONNX fixture not available"); + return; + } + }; + + // Build temp play_dir: config.yaml + models/alphazero_B5W2_mv1.onnx + let temp = tempfile::tempdir().expect("tempdir"); + let models_dir = temp.path().join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + let model_dst = models_dir.join("alphazero_B5W2_mv1.onnx"); + std::fs::copy(&onnx, &model_dst).expect("copy model"); + std::fs::write( + temp.path().join("config.yaml"), + "quoridor:\n board_size: 5\n max_walls: 2\n max_steps: 50\n", + ) + .unwrap(); + + let cfg = Arc::new(ServerConfig::load(temp.path()).expect("config load")); + let registry = GameRegistry::new(); + + // Bind to port 0; read assigned port. + let server = Arc::new(tiny_http::Server::http("127.0.0.1:0").expect("bind tiny_http")); + let addr = server.server_addr(); + let port = match addr { + tiny_http::ListenAddr::IP(sock) => sock.port(), + _ => panic!("expected IP listen addr"), + }; + + // Worker thread runs the handler loop until the server is unblocked. + let worker_server = Arc::clone(&server); + let worker_cfg = Arc::clone(&cfg); + let worker_registry = registry.clone(); + let worker = thread::spawn(move || { + for request in worker_server.incoming_requests() { + if let Err(e) = handle_request(request, &worker_cfg, &worker_registry, 8) { + eprintln!("test worker error: {e:#}"); + } + } + }); + + let base = format!("http://127.0.0.1:{port}"); + let http = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(30)) + .build(); + + // 1. GET /api/config + let cfg_view: serde_json::Value = http + .get(&format!("{base}/api/config")) + .call() + .expect("GET /api/config") + .into_json() + .expect("config json"); + assert_eq!(cfg_view["board_size"], 5); + assert_eq!(cfg_view["max_walls"], 2); + let models = cfg_view["models"].as_array().expect("models array"); + assert!( + models + .iter() + .any(|m| m.as_str() == Some("alphazero_B5W2_mv1.onnx")) + ); + + // 2. POST /api/games + let new_game: serde_json::Value = http + .post(&format!("{base}/api/games")) + .send_json(serde_json::json!({ + "model": "alphazero_B5W2_mv1.onnx", + "mcts_n": 8, + "human_player": 0, + })) + .expect("POST /api/games") + .into_json() + .expect("new-game json"); + let game_id = new_game["game_id"].as_str().expect("game_id").to_string(); + let mut state = new_game["state"].clone(); + // human_player == 0 means human moves first. + assert_eq!(state["current_player"].as_i64(), Some(0)); + + // 3. GET /api/games/ to confirm the registry stored the game. + let fetched: serde_json::Value = http + .get(&format!("{base}/api/games/{game_id}")) + .call() + .expect("GET /api/games/") + .into_json() + .expect("game json"); + assert_eq!(fetched["state"]["current_player"].as_i64(), Some(0)); + + // 4. Play up to 10 human moves, picking the first legal move action. + let mut moves = 0; + while state["winner"].is_null() && moves < 10 { + let legal = state["legal_actions"].as_array().expect("legal_actions"); + let move_idx = legal + .iter() + .find_map(|a| { + if a["kind"] == "move" { + a["index"].as_u64() + } else { + None + } + }) + .expect("at least one legal pawn move"); + let resp: serde_json::Value = http + .post(&format!("{base}/api/games/{game_id}/move")) + .send_json(serde_json::json!({ "action_index": move_idx })) + .expect("POST /move") + .into_json() + .expect("move json"); + state = resp["state"].clone(); + moves += 1; + // Each round-trip adds at least the human's move; if the game isn't + // over, the handler also runs an AI step (so +2). + let history_len = state["move_history"].as_array().unwrap().len(); + assert!( + history_len >= 2 * moves - 1, + "after {moves} human move(s), move_history len = {history_len}", + ); + } + + assert!(moves > 0, "played zero moves"); + + // 5. Unblock the listener and join the worker. + server.unblock(); + // tiny_http's unblock won't fire until the next incoming_requests poll + // returns, so prod the listener with a stray TCP connect. + let _ = TcpStream::connect(("127.0.0.1", port)); + worker.join().expect("worker join"); +} 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 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 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). diff --git a/docs/superpowers/plans/2026-05-29-quoridor-play-server.md b/docs/superpowers/plans/2026-05-29-quoridor-play-server.md new file mode 100644 index 00000000..67402364 --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-quoridor-play-server.md @@ -0,0 +1,1990 @@ +# Quoridor Play Server 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:** A small Rust binary that serves a local web app for playing Quoridor against the project's AlphaZero agent, with model selection from a directory. + +**Architecture:** One new `bin/play_server.rs` plus a `play_server` module in the existing `quoridor_rs` crate. The binary uses `tiny_http` (no framework) to serve embedded HTML/CSS/JS and a small JSON API; each browser session creates a `GameSession` (owns a `QGameMechanics` + an `AlphaZeroAgent`) held in an `Arc>`. Vanilla HTML+JS frontend with a `(2N-1) × (2N-1)` CSS-grid board; the server enriches legal actions with semantic shape so the client never has to know the action-index encoding. + +**Tech Stack:** Rust (edition 2024), `tiny_http`, `serde`/`serde_json`, `serde_yaml`, the existing `AlphaZeroAgent`/`QGameMechanics`/`actions` modules, plain HTML/CSS/JS in the browser. + +**Spec:** `docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md` + +--- + +## File structure + +- `deep_quoridor/rust/Cargo.toml` — add `tiny_http` dep + `play_server` bin entry + `ureq` dev-dep. (modify) +- `deep_quoridor/rust/src/lib.rs` — register `pub mod play_server;` behind the `binary` feature. (modify) +- `deep_quoridor/rust/src/play_server/mod.rs` — re-exports. (create) +- `deep_quoridor/rust/src/play_server/state.rs` — `StateView` JSON shape + action enrichment. (create) +- `deep_quoridor/rust/src/play_server/config.rs` — `ServerConfig` loaded from the play-dir. (create) +- `deep_quoridor/rust/src/play_server/session.rs` — `GameSession` + `GameRegistry`. (create) +- `deep_quoridor/rust/src/play_server/handlers.rs` — request/response types + pure handler functions. (create) +- `deep_quoridor/rust/src/play_server/static/index.html` — frontend HTML. (create) +- `deep_quoridor/rust/src/play_server/static/app.css` — frontend CSS. (create) +- `deep_quoridor/rust/src/play_server/static/app.js` — frontend JS. (create) +- `deep_quoridor/rust/src/bin/play_server.rs` — CLI + `tiny_http` loop + route dispatch. (create) +- `deep_quoridor/rust/tests/play_server_e2e.rs` — end-to-end test using the existing B5W2 fixture. (create) + +**Build/run notes for the implementer.** All work goes on the current branch (don't switch). The server is built with `--features binary` (NOT `--all-features`, which enables `gpu` and would require `ORT_DYLIB_PATH`). Run cargo commands with sandbox disabled and long timeouts (release/test builds use LTO and are slow). AGENTS.md: commit subject starts with `vibe: ` imperative ≤50 chars; do NOT run `cargo fmt` between tasks (formatting is a separate final task per the project rule). + +--- + +## Task 1: Cargo.toml — add `tiny_http`, `ureq` dev-dep, register the `play_server` bin + +**Files:** +- Modify: `deep_quoridor/rust/Cargo.toml` + +- [ ] **Step 1: Add the `tiny_http` optional dep** + +In `[dependencies]`, near `serde_json`: + +```toml +tiny_http = { version = "0.12", optional = true } +``` + +- [ ] **Step 2: Add `"tiny_http"` to the `binary` feature list** + +Current line: +```toml +binary = ["clap", "ort", "serde_yaml", "serde_json", "ndarray-npy", "zip", "rand_distr", "tokio", "futures"] +``` +Change to: +```toml +binary = ["clap", "ort", "serde_yaml", "serde_json", "ndarray-npy", "zip", "rand_distr", "tokio", "futures", "tiny_http"] +``` + +- [ ] **Step 3: Register the new bin** + +Below the existing `[[bin]]` entries (`create_policy_db`, `selfplay`), add: +```toml +[[bin]] +name = "play_server" +path = "src/bin/play_server.rs" +required-features = ["binary"] +``` + +- [ ] **Step 4: Add `ureq` as a dev-dependency for the end-to-end test** + +In `[dev-dependencies]`: +```toml +ureq = { version = "2", default-features = false, features = ["json"] } +``` + +- [ ] **Step 5: Verify the existing CPU build still passes** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo build --no-default-features --features binary --bin selfplay +``` +Expected: builds successfully. (`play_server` will fail until later tasks create it — that's fine; we only built `selfplay` here.) + +- [ ] **Step 6: Commit** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole +git add deep_quoridor/rust/Cargo.toml +git commit -m "vibe: add tiny_http dep + play_server bin entry" +``` + +--- + +## Task 2: `play_server` module scaffold + `StateView` + action enrichment + +**Files:** +- Create: `deep_quoridor/rust/src/play_server/mod.rs` +- Create: `deep_quoridor/rust/src/play_server/state.rs` +- Modify: `deep_quoridor/rust/src/lib.rs` + +- [ ] **Step 1: Add the module to `lib.rs`** + +Add this `pub mod` declaration (gated by the `binary` feature) alongside the others. Find a `#[cfg(feature = "binary")]` block of `pub mod` lines in `src/lib.rs` and add: +```rust +#[cfg(feature = "binary")] +pub mod play_server; +``` + +- [ ] **Step 2: Create the module entry point** + +Create `deep_quoridor/rust/src/play_server/mod.rs`: +```rust +//! Local web server for playing Quoridor against the AlphaZero agent. +//! +//! Architecture overview is in +//! `docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md`. + +pub mod config; +pub mod handlers; +pub mod session; +pub mod state; +``` + +(`config`, `handlers`, `session` are created in later tasks; the build will only succeed at the end of Task 5. To unblock incremental builds, comment out the not-yet-created `pub mod` lines and uncomment as each task lands. **Implementer:** at this step, only leave `pub mod state;` uncommented.) + +So the file at the end of this task is: +```rust +//! Local web server for playing Quoridor against the AlphaZero agent. +//! +//! Architecture overview is in +//! `docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md`. + +pub mod state; +// pub mod config; // added in Task 3 +// pub mod session; // added in Task 4 +// pub mod handlers; // added in Task 5 +``` + +- [ ] **Step 3: Write the failing test for action enrichment + StateView** + +Create `deep_quoridor/rust/src/play_server/state.rs` with the test scaffolding first: +```rust +//! Pure types and helpers for the play-server `state` JSON shape and for +//! enriching action indices with their semantic board coordinates. + +use serde::Serialize; + +use crate::actions::action_index_to_action; + +/// Single legal action carried over the wire. The client never needs to know +/// the action-index encoding; it just looks at `kind` and the coords. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum EnrichedAction { + Move { index: u32, to: [i32; 2] }, + Wall { + index: u32, + row: i32, + col: i32, + orientation: WallOrientation, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum WallOrientation { + H, + V, +} + +/// Snapshot of a `GameSession` for the client to render. Built by +/// `session::GameSession::view()` from the underlying `QGameMechanics` + +/// `CompactState`. +#[derive(Debug, Clone, Serialize)] +pub struct StateView { + pub board_size: i32, + pub max_walls: i32, + pub max_steps: i32, + pub current_player: i32, + pub p1_pos: [i32; 2], + pub p2_pos: [i32; 2], + pub p1_walls: i32, + pub p2_walls: i32, + pub walls: Vec, + pub legal_actions: Vec, + pub completed_steps: i32, + pub winner: Option, + pub human_player: i32, + pub last_action: Option, + pub move_history: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct WallEntry { + pub row: i32, + pub col: i32, + pub orientation: WallOrientation, +} + +/// Map an action index to its semantic enrichment (move dest / wall coords + +/// orientation). Matches the convention in `actions::action_index_to_action`: +/// indices < N*N are moves to row/col=index/N,index%N; the next (N-1)^2 are +/// vertical walls; the remaining (N-1)^2 are horizontal walls. +pub fn enrich_action(board_size: i32, index: usize) -> EnrichedAction { + let [row, col, action_type] = action_index_to_action(board_size, index); + match action_type { + 0 => EnrichedAction::Move { + index: index as u32, + to: [row, col], + }, + 1 => EnrichedAction::Wall { + index: index as u32, + row, + col, + orientation: WallOrientation::V, + }, + 2 => EnrichedAction::Wall { + index: index as u32, + row, + col, + orientation: WallOrientation::H, + }, + other => panic!("unexpected action type {other} for index {index}"), + } +} + +/// Apply `enrich_action` to every legal index in `mask`. +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))) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::actions::policy_size; + + #[test] + fn enrich_move_action_round_trips_coords() { + // First action on a 5x5 board: move to (0, 0). + let a = enrich_action(5, 0); + assert_eq!( + a, + EnrichedAction::Move { + index: 0, + to: [0, 0] + } + ); + + // Index N*N - 1 on 5x5 = last move cell = (4, 4). + let a = enrich_action(5, 24); + assert_eq!( + a, + EnrichedAction::Move { + index: 24, + to: [4, 4] + } + ); + } + + #[test] + fn enrich_first_vertical_then_first_horizontal_wall() { + let n: i32 = 5; + let nn = (n * n) as usize; + let walls = ((n - 1) * (n - 1)) as usize; + + // First vertical wall is at index N*N. + let v = enrich_action(n, nn); + assert_eq!( + v, + EnrichedAction::Wall { + index: nn as u32, + row: 0, + col: 0, + orientation: WallOrientation::V + } + ); + + // First horizontal wall is at index N*N + (N-1)^2. + let h = enrich_action(n, nn + walls); + assert_eq!( + h, + EnrichedAction::Wall { + index: (nn + walls) as u32, + row: 0, + col: 0, + orientation: WallOrientation::H + } + ); + } + + #[test] + fn enrich_legal_actions_filters_by_mask() { + let n = 5; + let size = policy_size(n); + let mut mask = vec![false; size]; + mask[0] = true; + mask[(n * n) as usize] = true; // first vertical wall + + let actions = enrich_legal_actions(n, &mask); + assert_eq!(actions.len(), 2); + assert!(matches!( + actions[0], + EnrichedAction::Move { index: 0, .. } + )); + assert!(matches!( + actions[1], + EnrichedAction::Wall { + orientation: WallOrientation::V, + .. + } + )); + } + + #[test] + fn enriched_action_serializes_with_kind_tag() { + let m = EnrichedAction::Move { + index: 3, + to: [4, 5], + }; + let s = serde_json::to_string(&m).unwrap(); + assert_eq!(s, r#"{"kind":"move","index":3,"to":[4,5]}"#); + + let w = EnrichedAction::Wall { + index: 17, + row: 3, + col: 2, + orientation: WallOrientation::H, + }; + let s = serde_json::to_string(&w).unwrap(); + assert_eq!( + s, + r#"{"kind":"wall","index":17,"row":3,"col":2,"orientation":"h"}"# + ); + } +} +``` + +- [ ] **Step 4: Run the new tests** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo test --lib --no-default-features --features binary play_server::state -- --nocapture +``` +Expected: all 4 tests pass. (If `action_index_to_action` returns a different action_type numbering than the comments above suggest, **read** `src/actions.rs` and adjust the `match action_type` arms accordingly — the goal is that vertical wall maps to `WallOrientation::V` and horizontal to `WallOrientation::H`.) + +- [ ] **Step 5: Commit** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole +git add deep_quoridor/rust/src/lib.rs deep_quoridor/rust/src/play_server +git commit -m "vibe: add play_server state view + action enrichment" +``` + +--- + +## Task 3: `ServerConfig` — load `/config.yaml` + list `models/*.onnx` + +**Files:** +- Create: `deep_quoridor/rust/src/play_server/config.rs` +- Modify: `deep_quoridor/rust/src/play_server/mod.rs` + +- [ ] **Step 1: Uncomment the module line** + +Edit `src/play_server/mod.rs` so the file contains: +```rust +//! Local web server for playing Quoridor against the AlphaZero agent. +//! +//! Architecture overview is in +//! `docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md`. + +pub mod config; +pub mod state; +// pub mod session; // added in Task 4 +// pub mod handlers; // added in Task 5 +``` + +- [ ] **Step 2: Create the config module with tests** + +Create `deep_quoridor/rust/src/play_server/config.rs`: +```rust +//! Server-side configuration: derived from `/config.yaml` plus the +//! list of selectable models found in `/models/*.onnx`. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::Deserialize; + +/// What the server needs from the play directory. +#[derive(Debug, Clone)] +pub struct ServerConfig { + pub play_dir: PathBuf, + pub board_size: i32, + pub max_walls: i32, + pub max_steps: i32, + pub default_mcts_c_puct: f32, + /// Filenames (not full paths) of `*.onnx` files in `/models/`. + pub models: Vec, +} + +/// Subset of `config.yaml` we actually parse. Anything else is ignored. +#[derive(Debug, Deserialize)] +struct ConfigFile { + quoridor: QuoridorSection, + #[serde(default)] + alphazero: AlphaZeroSection, +} + +#[derive(Debug, Deserialize)] +struct QuoridorSection { + board_size: i32, + max_walls: i32, + max_steps: i32, +} + +#[derive(Debug, Deserialize, Default)] +struct AlphaZeroSection { + #[serde(default)] + mcts_c_puct: Option, +} + +impl ServerConfig { + pub fn load(play_dir: &Path) -> Result { + let cfg_path = play_dir.join("config.yaml"); + let raw = std::fs::read_to_string(&cfg_path) + .with_context(|| format!("reading {}", cfg_path.display()))?; + let file: ConfigFile = serde_yaml::from_str(&raw) + .with_context(|| format!("parsing {}", cfg_path.display()))?; + + let models_dir = play_dir.join("models"); + let mut models: Vec = std::fs::read_dir(&models_dir) + .with_context(|| format!("reading {}", models_dir.display()))? + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("onnx")) + .filter_map(|e| e.file_name().to_str().map(|s| s.to_string())) + .collect(); + models.sort(); + + Ok(Self { + play_dir: play_dir.to_path_buf(), + board_size: file.quoridor.board_size, + max_walls: file.quoridor.max_walls, + max_steps: file.quoridor.max_steps, + default_mcts_c_puct: file.alphazero.mcts_c_puct.unwrap_or(1.4), + models, + }) + } + + /// Full path to a chosen model file. Returns `None` if the name isn't in + /// the listed `models`. + pub fn model_path(&self, model: &str) -> Option { + if self.models.iter().any(|m| m == model) { + Some(self.play_dir.join("models").join(model)) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn make_play_dir(yaml: &str, model_files: &[&str]) -> PathBuf { + let dir = tempfile::Builder::new() + .prefix("playsrv_test_") + .tempdir() + .expect("tempdir") + .into_path(); + fs::write(dir.join("config.yaml"), yaml).unwrap(); + fs::create_dir_all(dir.join("models")).unwrap(); + for f in model_files { + fs::write(dir.join("models").join(f), b"not really onnx").unwrap(); + } + dir + } + + #[test] + fn loads_minimal_config_and_lists_models_sorted() { + let dir = make_play_dir( + "quoridor:\n board_size: 5\n max_walls: 2\n max_steps: 50\n", + &["model_002.onnx", "model_000.onnx", "ignore.txt"], + ); + let cfg = ServerConfig::load(&dir).unwrap(); + assert_eq!(cfg.board_size, 5); + assert_eq!(cfg.max_walls, 2); + assert_eq!(cfg.max_steps, 50); + assert!((cfg.default_mcts_c_puct - 1.4).abs() < 1e-6); + assert_eq!(cfg.models, vec!["model_000.onnx", "model_002.onnx"]); + } + + #[test] + fn picks_up_alphazero_c_puct_when_present() { + let dir = make_play_dir( + "quoridor:\n board_size: 5\n max_walls: 2\n max_steps: 50\n\ + alphazero:\n mcts_c_puct: 1.7\n", + &["m.onnx"], + ); + let cfg = ServerConfig::load(&dir).unwrap(); + assert!((cfg.default_mcts_c_puct - 1.7).abs() < 1e-6); + } + + #[test] + fn model_path_returns_some_for_listed_and_none_for_unlisted() { + let dir = make_play_dir( + "quoridor:\n board_size: 5\n max_walls: 2\n max_steps: 50\n", + &["a.onnx", "b.onnx"], + ); + let cfg = ServerConfig::load(&dir).unwrap(); + assert!(cfg.model_path("a.onnx").is_some()); + assert!(cfg.model_path("c.onnx").is_none()); + } +} +``` + +- [ ] **Step 3: Run the new tests** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo test --lib --no-default-features --features binary play_server::config -- --nocapture +``` +Expected: 3 tests pass. + +- [ ] **Step 4: Commit** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole +git add deep_quoridor/rust/src/play_server +git commit -m "vibe: add play_server config loader and models scan" +``` + +--- + +## Task 4: `GameSession` + `GameRegistry` + +**Files:** +- Create: `deep_quoridor/rust/src/play_server/session.rs` +- Modify: `deep_quoridor/rust/src/play_server/mod.rs` + +- [ ] **Step 1: Enable the module** + +Edit `src/play_server/mod.rs` so the not-yet-existing `handlers` is still commented but `session` is enabled: +```rust +pub mod config; +pub mod session; +pub mod state; +// pub mod handlers; // added in Task 5 +``` + +- [ ] **Step 2: Implement `GameSession` + `GameRegistry`** + +Create `deep_quoridor/rust/src/play_server/session.rs`: + +```rust +//! Per-game state (`GameSession`) and the shared registry that the HTTP +//! handlers look up by `game_id`. +//! +//! Each session owns its own `AlphaZeroAgent` so games run independently. The +//! registry holds an `Arc>` per game; an HTTP handler takes +//! the outer `Mutex` briefly to look up the session and then holds the inner +//! `Mutex` for the duration of the move + AI response. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use anyhow::{anyhow, Context, Result}; +use rand::RngCore; + +use crate::actions::policy_size; +use crate::agents::ActionSelector; +use crate::agents::alphazero::agent::{AlphaZeroAgent, AlphaZeroAgentConfig}; +use crate::agents::alphazero::evaluator::{Evaluator, UniformMockEvaluator}; +use crate::agents::alphazero::mcts::MCTSConfig; +use crate::compact::q_bit_repr::CompactState; +use crate::compact::q_game_mechanics::QGameMechanics; +use crate::play_server::config::ServerConfig; +use crate::play_server::state::{ + enrich_action, enrich_legal_actions, EnrichedAction, StateView, WallEntry, WallOrientation, +}; + +pub type GameId = String; + +/// One running game: owns the agent and the board state. +pub struct GameSession { + pub mechanics: QGameMechanics, + pub state: CompactState, + pub agent: AlphaZeroAgent, + pub board_size: i32, + pub max_walls: i32, + pub max_steps: i32, + pub human_player: i32, + pub last_action: Option, + pub move_history: Vec, +} + +impl GameSession { + /// Construct an agent config matching the server's notion of "play-mode": + /// the requested `mcts_n`, temperature 0 (argmax visits), deterministic + /// tie-break, no Dirichlet noise, and the server's max_steps as the MCTS + /// search cap. + pub fn agent_config(mcts_n: u32, c_puct: f32, max_steps: i32) -> AlphaZeroAgentConfig { + AlphaZeroAgentConfig { + mcts: MCTSConfig { + n: Some(mcts_n), + k: None, + ucb_c: c_puct, + noise_epsilon: 0.0, + noise_alpha: None, + max_steps: Some(max_steps), + penalize_visited_states: false, + }, + temperature: 0.0, + drop_t_on_step: None, + penalize_visited_states: false, + deterministic_tie_break: true, + } + } + + /// Build a session that loads ONNX from disk. The session's mechanics + + /// initial state come from `cfg`. + pub fn new_from_onnx( + cfg: &ServerConfig, + model_path: &std::path::Path, + mcts_n: u32, + human_player: i32, + ) -> Result { + let mechanics = QGameMechanics::new( + cfg.board_size as usize, + cfg.max_walls as usize, + cfg.max_steps as usize, + ); + let state = mechanics.create_initial_state(); + let model_str = model_path + .to_str() + .ok_or_else(|| anyhow!("model path is not valid UTF-8"))?; + let agent_config = Self::agent_config(mcts_n, cfg.default_mcts_c_puct, cfg.max_steps); + let agent = AlphaZeroAgent::new(model_str, agent_config) + .context("constructing AlphaZeroAgent")?; + Ok(Self { + mechanics, + state, + agent, + board_size: cfg.board_size, + max_walls: cfg.max_walls, + max_steps: cfg.max_steps, + human_player, + last_action: None, + move_history: Vec::new(), + }) + } + + /// Test-only variant that injects a fake evaluator instead of loading ORT. + #[cfg(test)] + pub fn new_with_evaluator( + board_size: i32, + max_walls: i32, + max_steps: i32, + evaluator: Box, + mcts_n: u32, + c_puct: f32, + human_player: i32, + ) -> Self { + let mechanics = + QGameMechanics::new(board_size as usize, max_walls as usize, max_steps as usize); + let state = mechanics.create_initial_state(); + let agent_config = Self::agent_config(mcts_n, c_puct, max_steps); + let agent = AlphaZeroAgent::with_evaluator(evaluator, agent_config); + Self { + mechanics, + state, + agent, + board_size, + max_walls, + max_steps, + human_player, + last_action: None, + move_history: Vec::new(), + } + } + + fn current_player(&self) -> i32 { + self.mechanics.repr().get_current_player(self.state) as i32 + } + + fn is_game_over(&self) -> bool { + self.mechanics.is_game_over(self.state) + || self.mechanics.repr().get_completed_steps(self.state) >= self.max_steps as usize + } + + fn legal_mask(&mut self) -> Vec { + self.mechanics.get_action_mask_immut(self.state) + } + + /// Apply one action (no matter whose turn). Records it in `last_action` + + /// `move_history`. Returns an error if the action is illegal. + pub fn apply_action(&mut self, action_index: u32) -> Result<()> { + if self.is_game_over() { + return Err(anyhow!("game is already over")); + } + let mask = self.legal_mask(); + let idx = action_index as usize; + if idx >= mask.len() || !mask[idx] { + return Err(anyhow!("action {action_index} is not legal")); + } + self.last_action = Some(enrich_action(self.board_size, idx)); + self.move_history.push(action_index); + self.mechanics.apply_action_index(&mut self.state, idx); + Ok(()) + } + + /// Run the AI for one move on the current state. Errors if it's actually + /// the human's turn or the game is over. + pub fn ai_step(&mut self) -> Result<()> { + if self.is_game_over() { + return Ok(()); + } + if self.current_player() == self.human_player { + return Err(anyhow!("not AI's turn")); + } + let mask = self.legal_mask(); + let (action_idx, _policy) = self + .agent + .select_action(self.state, &self.mechanics, &mask) + .context("AI MCTS selection")?; + self.last_action = Some(enrich_action(self.board_size, action_idx)); + self.move_history.push(action_idx as u32); + self.mechanics + .apply_action_index(&mut self.state, action_idx); + Ok(()) + } + + /// Build the JSON-facing snapshot the client renders from. + pub fn view(&mut self) -> StateView { + let mask = self.legal_mask(); + let legal_actions = enrich_legal_actions(self.board_size, &mask); + let repr = self.mechanics.repr(); + let (p1r, p1c) = repr.get_player_position(self.state, 0); + let (p2r, p2c) = repr.get_player_position(self.state, 1); + let p1w = repr.get_walls_remaining(self.state, 0) as i32; + let p2w = repr.get_walls_remaining(self.state, 1) as i32; + let completed_steps = repr.get_completed_steps(self.state) as i32; + let winner = if self.mechanics.check_win(self.state, 0) { + Some(0) + } else if self.mechanics.check_win(self.state, 1) { + Some(1) + } else { + None + }; + + StateView { + board_size: self.board_size, + max_walls: self.max_walls, + max_steps: self.max_steps, + current_player: self.current_player(), + p1_pos: [p1r as i32, p1c as i32], + p2_pos: [p2r as i32, p2c as i32], + p1_walls: p1w, + p2_walls: p2w, + walls: list_walls(&self.mechanics, self.state, self.board_size), + legal_actions, + completed_steps, + winner, + human_player: self.human_player, + last_action: self.last_action.clone(), + move_history: self.move_history.clone(), + } + } +} + +/// Iterate every potential wall slot (`(N-1)^2` for each orientation) and ask +/// the mechanics whether a wall is currently present at that location. +fn list_walls(mechanics: &QGameMechanics, state: CompactState, board_size: i32) -> Vec { + let mut out = Vec::new(); + let wall_size = (board_size - 1) as usize; + for orientation_idx in 0..2 { + let orientation = if orientation_idx == 0 { + WallOrientation::H + } else { + WallOrientation::V + }; + for row in 0..wall_size { + for col in 0..wall_size { + if mechanics + .repr() + .get_wall(state, row, col, orientation_idx == 0) + { + out.push(WallEntry { + row: row as i32, + col: col as i32, + orientation, + }); + } + } + } + } + out +} + +/// Thread-safe map `game_id -> GameSession`. +#[derive(Clone, Default)] +pub struct GameRegistry { + inner: Arc>>>>, +} + +impl GameRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&self, session: GameSession) -> GameId { + let id = new_game_id(); + self.inner + .lock() + .unwrap() + .insert(id.clone(), Arc::new(Mutex::new(session))); + id + } + + pub fn get(&self, game_id: &str) -> Option>> { + self.inner.lock().unwrap().get(game_id).cloned() + } +} + +fn new_game_id() -> GameId { + let mut bytes = [0u8; 4]; + rand::thread_rng().fill_bytes(&mut bytes); + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mock_session(human_player: i32) -> GameSession { + GameSession::new_with_evaluator( + 5, + 2, + 50, + Box::new(UniformMockEvaluator), + 8, + 1.4, + human_player, + ) + } + + #[test] + fn initial_view_has_pawns_on_home_rows_and_no_walls() { + let mut s = mock_session(0); + let v = s.view(); + assert_eq!(v.board_size, 5); + assert_eq!(v.max_walls, 2); + assert_eq!(v.current_player, 0); + assert_eq!(v.completed_steps, 0); + assert!(v.walls.is_empty()); + assert_eq!(v.p1_walls, 2); + assert_eq!(v.p2_walls, 2); + assert!(v.winner.is_none()); + assert_eq!(v.human_player, 0); + assert!(v.last_action.is_none()); + assert!(v.move_history.is_empty()); + // Some legal move actions must exist at the start. + let has_move = v + .legal_actions + .iter() + .any(|a| matches!(a, EnrichedAction::Move { .. })); + assert!(has_move); + } + + #[test] + fn apply_action_records_last_action_and_advances_player() { + let mut s = mock_session(0); + let mask = s.legal_mask(); + let first_legal_move = mask + .iter() + .enumerate() + .find(|(_, &b)| b) + .map(|(i, _)| i as u32) + .expect("at least one legal action"); + s.apply_action(first_legal_move).unwrap(); + assert_eq!(s.move_history, vec![first_legal_move]); + assert!(s.last_action.is_some()); + assert_eq!(s.current_player(), 1); + } + + #[test] + fn apply_action_rejects_illegal_index() { + let mut s = mock_session(0); + let mask = s.legal_mask(); + let illegal = mask + .iter() + .enumerate() + .find(|(_, &b)| !b) + .map(|(i, _)| i as u32) + .expect("at least one illegal action"); + let err = s.apply_action(illegal).unwrap_err(); + assert!(err.to_string().contains("not legal")); + } + + #[test] + fn ai_step_errors_when_its_human_turn() { + let mut s = mock_session(0); // human is P1, AI is P2, P1 goes first + let err = s.ai_step().unwrap_err(); + assert!(err.to_string().contains("not AI's turn")); + } + + #[test] + fn ai_step_runs_when_its_ai_turn() { + let mut s = mock_session(1); // human is P2, AI is P1, AI goes first + s.ai_step().unwrap(); + assert_eq!(s.move_history.len(), 1); + assert!(s.last_action.is_some()); + assert_eq!(s.current_player(), 1); // now human (P2) to move + } + + #[test] + fn registry_insert_and_get_round_trip() { + let reg = GameRegistry::new(); + let id = reg.insert(mock_session(0)); + assert!(reg.get(&id).is_some()); + assert!(reg.get("does-not-exist").is_none()); + } + + #[test] + fn game_id_is_8_hex_chars() { + let id = new_game_id(); + assert_eq!(id.len(), 8); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); + } +} +``` + +- [ ] **Step 3: Run the session tests** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo test --lib --no-default-features --features binary play_server::session -- --nocapture +``` +Expected: all session tests pass. + +If `QGameMechanics::repr().get_wall(state, row, col, is_horizontal)` doesn't have that exact signature, **read** `src/compact/q_bit_repr.rs` for the actual `get_wall` signature and adjust `list_walls` accordingly. The semantic is "iterate every potential wall slot and ask if a wall is there"; the exact API call is local to that helper. + +- [ ] **Step 4: Commit** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole +git add deep_quoridor/rust/src/play_server +git commit -m "vibe: add GameSession + GameRegistry" +``` + +--- + +## Task 5: HTTP handlers (pure functions) + +**Files:** +- Create: `deep_quoridor/rust/src/play_server/handlers.rs` +- Modify: `deep_quoridor/rust/src/play_server/mod.rs` + +- [ ] **Step 1: Enable the module** + +Edit `src/play_server/mod.rs` so all four `pub mod` lines are active: +```rust +pub mod config; +pub mod handlers; +pub mod session; +pub mod state; +``` + +- [ ] **Step 2: Implement the handlers** + +Create `deep_quoridor/rust/src/play_server/handlers.rs`: +```rust +//! Pure handler functions that the HTTP layer calls. These are unit-testable +//! without an actual TCP socket — they take parsed request bodies + the +//! shared state and return response structs. + +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; + +use crate::play_server::config::ServerConfig; +use crate::play_server::session::{GameRegistry, GameSession}; +use crate::play_server::state::StateView; + +/// Reasonable bounds for the per-game MCTS slider. The server clamps inbound +/// values to this range. +pub const MCTS_N_MIN: u32 = 1; +pub const MCTS_N_MAX: u32 = 4000; + +#[derive(Debug, Serialize)] +pub struct ConfigResponse { + pub board_size: i32, + pub max_walls: i32, + pub max_steps: i32, + pub models: Vec, + pub default_mcts_n: u32, +} + +#[derive(Debug, Deserialize)] +pub struct NewGameRequest { + pub model: String, + pub mcts_n: u32, + pub human_player: i32, +} + +#[derive(Debug, Serialize)] +pub struct NewGameResponse { + pub game_id: String, + pub state: StateView, +} + +#[derive(Debug, Serialize)] +pub struct StateResponse { + pub state: StateView, +} + +#[derive(Debug, Deserialize)] +pub struct MoveRequest { + pub action_index: u32, +} + +/// Tag for `handle_*` failures so the HTTP layer can pick the right status. +#[derive(Debug)] +pub enum ApiError { + BadRequest(String), + NotFound(String), + Internal(String), +} + +pub fn handle_config(cfg: &ServerConfig, default_mcts_n: u32) -> ConfigResponse { + ConfigResponse { + board_size: cfg.board_size, + max_walls: cfg.max_walls, + max_steps: cfg.max_steps, + models: cfg.models.clone(), + default_mcts_n, + } +} + +pub fn handle_new_game( + cfg: &ServerConfig, + registry: &GameRegistry, + req: NewGameRequest, +) -> Result { + if req.human_player != 0 && req.human_player != 1 { + return Err(ApiError::BadRequest(format!( + "human_player must be 0 or 1, got {}", + req.human_player + ))); + } + let mcts_n = req.mcts_n.clamp(MCTS_N_MIN, MCTS_N_MAX); + let model_path = cfg + .model_path(&req.model) + .ok_or_else(|| ApiError::BadRequest(format!("unknown model: {}", req.model)))?; + + let mut session = GameSession::new_from_onnx(cfg, &model_path, mcts_n, req.human_player) + .map_err(|e| ApiError::Internal(format!("constructing session: {e:#}")))?; + + // If the AI plays first, take its move before returning the initial state. + if session.human_player != 0 + && !session.view().winner.is_some() + && session.view().current_player == 1 - session.human_player + { + session + .ai_step() + .map_err(|e| ApiError::Internal(format!("initial AI move: {e:#}")))?; + } + let game_id = registry.insert(session); + let view = with_session(registry, &game_id, |s| Ok::<_, anyhow::Error>(s.view())) + .map_err(api_internal)?; + Ok(NewGameResponse { + game_id, + state: view, + }) +} + +pub fn handle_get_state( + registry: &GameRegistry, + game_id: &str, +) -> Result { + let view = with_session(registry, game_id, |s| Ok::<_, anyhow::Error>(s.view())) + .map_err(api_internal)?; + Ok(StateResponse { state: view }) +} + +pub fn handle_move( + registry: &GameRegistry, + game_id: &str, + req: MoveRequest, +) -> Result { + let view = with_session(registry, game_id, |s| { + if s.view().winner.is_some() { + return Err(anyhow!("game is over")); + } + if s.view().current_player != s.human_player { + return Err(anyhow!("not human's turn")); + } + s.apply_action(req.action_index)?; + // If after the human's move it's the AI's turn (and the game isn't + // over), run the AI in the same round-trip. + if s.view().winner.is_none() && s.view().current_player != s.human_player { + s.ai_step()?; + } + Ok(s.view()) + }) + .map_err(|e| { + let m = format!("{e:#}"); + if m.contains("not legal") || m.contains("not human") || m.contains("game is over") { + ApiError::BadRequest(m) + } else { + ApiError::Internal(m) + } + })?; + Ok(StateResponse { state: view }) +} + +fn with_session( + registry: &GameRegistry, + game_id: &str, + f: F, +) -> Result +where + F: FnOnce(&mut GameSession) -> Result, + E: Into, +{ + let lock = registry + .get(game_id) + .ok_or_else(|| anyhow!("unknown game_id"))?; + let mut session = lock.lock().unwrap(); + f(&mut session).map_err(Into::into) +} + +fn api_internal(e: anyhow::Error) -> ApiError { + let m = format!("{e:#}"); + if m.contains("unknown game_id") { + ApiError::NotFound(m) + } else { + ApiError::Internal(m) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agents::alphazero::evaluator::UniformMockEvaluator; + + fn mock_session(human_player: i32) -> GameSession { + GameSession::new_with_evaluator( + 5, + 2, + 50, + Box::new(UniformMockEvaluator), + 8, + 1.4, + human_player, + ) + } + + fn register(reg: &GameRegistry, s: GameSession) -> String { + reg.insert(s) + } + + #[test] + fn handle_get_state_returns_view_when_game_exists() { + let reg = GameRegistry::new(); + let id = register(®, mock_session(0)); + let r = handle_get_state(®, &id).unwrap(); + assert_eq!(r.state.board_size, 5); + } + + #[test] + fn handle_get_state_returns_not_found_for_bad_id() { + let reg = GameRegistry::new(); + match handle_get_state(®, "ffffffff") { + Err(ApiError::NotFound(_)) => {} + other => panic!("expected NotFound, got {other:?}"), + } + } + + #[test] + fn handle_move_rejects_illegal_action() { + let reg = GameRegistry::new(); + let id = register(®, mock_session(0)); + // Pick an index that's known to be illegal (e.g. moving to own square + // = the agent's starting cell, index = N*N - first/last cell). + // Easier: pick a wall index that can't exist (index 0 is a move and is + // not legal at the very start because move 0 is to (0,0)). + match handle_move( + ®, + &id, + MoveRequest { + action_index: u32::MAX - 1, + }, + ) { + Err(ApiError::BadRequest(msg)) => assert!(msg.contains("not legal") || msg.contains("range")), + other => panic!("expected BadRequest, got {other:?}"), + } + } +} +``` + +- [ ] **Step 3: Build + run tests** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo test --lib --no-default-features --features binary play_server -- --nocapture +``` +Expected: all `play_server::*` tests pass. + +- [ ] **Step 4: Commit** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole +git add deep_quoridor/rust/src/play_server +git commit -m "vibe: add play_server API handlers" +``` + +--- + +## Task 6: `bin/play_server.rs` — CLI, tiny_http loop, static assets + +**Files:** +- Create: `deep_quoridor/rust/src/bin/play_server.rs` +- Create: `deep_quoridor/rust/src/play_server/static/index.html` (placeholder; real content in Task 8) +- Create: `deep_quoridor/rust/src/play_server/static/app.css` (placeholder) +- Create: `deep_quoridor/rust/src/play_server/static/app.js` (placeholder) + +- [ ] **Step 1: Create the static asset placeholders** + +The bin file uses `include_str!` to embed these at compile time, so they must exist as files even before Task 8 fills them. Minimal stand-ins: + +`deep_quoridor/rust/src/play_server/static/index.html`: +```html +Quoridor +

Play UI placeholder — see Task 8.

+``` + +`deep_quoridor/rust/src/play_server/static/app.css`: +```css +/* Real styles in Task 8. */ +``` + +`deep_quoridor/rust/src/play_server/static/app.js`: +```js +// Real frontend logic in Task 8. +``` + +- [ ] **Step 2: Write the binary** + +Create `deep_quoridor/rust/src/bin/play_server.rs`: +```rust +//! Local web server for playing Quoridor against the project's AlphaZero +//! agent. See `docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md`. + +use std::io::Read; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use clap::Parser; +use tiny_http::{Header, Method, Response, Server}; + +use quoridor_rs::play_server::config::ServerConfig; +use quoridor_rs::play_server::handlers::{ + self, ApiError, MoveRequest, NewGameRequest, +}; +use quoridor_rs::play_server::session::GameRegistry; + +const INDEX_HTML: &str = include_str!("../play_server/static/index.html"); +const APP_CSS: &str = include_str!("../play_server/static/app.css"); +const APP_JS: &str = include_str!("../play_server/static/app.js"); + +#[derive(Parser)] +#[command(about = "Quoridor play server")] +struct Cli { + /// Directory containing `config.yaml` and `models/*.onnx`. + #[arg(long)] + play_dir: PathBuf, + + /// TCP port to listen on. + #[arg(long, default_value_t = 8080)] + port: u16, + + /// Bind address. Default is loopback; use `0.0.0.0` for LAN access. + #[arg(long, default_value = "127.0.0.1")] + bind: String, + + /// Default `mcts_n` the UI starts the slider at. + #[arg(long, default_value_t = 400)] + default_mcts_n: u32, +} + +struct App { + cfg: ServerConfig, + default_mcts_n: u32, + registry: GameRegistry, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let cfg = + ServerConfig::load(&cli.play_dir).with_context(|| format!("loading {:?}", cli.play_dir))?; + let app = Arc::new(App { + cfg, + default_mcts_n: cli.default_mcts_n, + registry: GameRegistry::new(), + }); + + let addr: SocketAddr = format!("{}:{}", cli.bind, cli.port) + .parse() + .context("parse bind addr")?; + let server = Server::http(addr).map_err(|e| anyhow::anyhow!("tiny_http listen: {e}"))?; + println!("play_server listening on http://{addr}"); + println!("models: {:?}", app.cfg.models); + + for mut request in server.incoming_requests() { + let app = Arc::clone(&app); + // tiny_http is sync; for simplicity handle in-thread (sessions take + // ~ms; AI moves dominate but each session has its own lock, so + // concurrent games still progress in parallel via multiple threads + // if we spawn one here). + std::thread::spawn(move || { + let response = route(&app, &mut request); + if let Err(e) = request.respond(response) { + eprintln!("response error: {e:#}"); + } + }); + } + Ok(()) +} + +fn route(app: &App, request: &mut tiny_http::Request) -> Response>> { + let url = request.url().to_string(); + let method = request.method().clone(); + + // Static files. + if method == Method::Get { + match url.as_str() { + "/" | "/index.html" => return html(INDEX_HTML, 200), + "/static/app.css" => return text(APP_CSS, 200, "text/css; charset=utf-8"), + "/static/app.js" => { + return text(APP_JS, 200, "application/javascript; charset=utf-8") + } + "/api/config" => { + let resp = handlers::handle_config(&app.cfg, app.default_mcts_n); + return json_ok(&resp); + } + u if u.starts_with("/api/games/") && !u[11..].contains('/') => { + let game_id = &u[11..]; + return match handlers::handle_get_state(&app.registry, game_id) { + Ok(r) => json_ok(&r), + Err(e) => api_err_response(e), + }; + } + _ => {} + } + } else if method == Method::Post { + match url.as_str() { + "/api/games" => { + let body: NewGameRequest = match read_json(request) { + Ok(b) => b, + Err(msg) => return json_err(400, &msg), + }; + return match handlers::handle_new_game(&app.cfg, &app.registry, body) { + Ok(r) => json_ok(&r), + Err(e) => api_err_response(e), + }; + } + u if u.starts_with("/api/games/") && u.ends_with("/move") => { + let game_id = &u["/api/games/".len()..u.len() - "/move".len()]; + let body: MoveRequest = match read_json(request) { + Ok(b) => b, + Err(msg) => return json_err(400, &msg), + }; + return match handlers::handle_move(&app.registry, game_id, body) { + Ok(r) => json_ok(&r), + Err(e) => api_err_response(e), + }; + } + _ => {} + } + } + + text("not found", 404, "text/plain; charset=utf-8") +} + +fn read_json(request: &mut tiny_http::Request) -> Result { + let mut buf = String::new(); + request + .as_reader() + .read_to_string(&mut buf) + .map_err(|e| format!("reading body: {e}"))?; + serde_json::from_str(&buf).map_err(|e| format!("parsing body: {e}")) +} + +fn json_ok(body: &T) -> Response>> { + let s = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec()); + let mut r = Response::from_data(s).with_status_code(200); + r.add_header(Header::from_bytes("Content-Type", "application/json; charset=utf-8").unwrap()); + r +} + +fn json_err(code: u32, msg: &str) -> Response>> { + let body = serde_json::json!({ "error": msg }).to_string(); + let mut r = Response::from_string(body).with_status_code(code); + r.add_header(Header::from_bytes("Content-Type", "application/json; charset=utf-8").unwrap()); + r +} + +fn api_err_response(e: ApiError) -> Response>> { + match e { + ApiError::BadRequest(m) => json_err(400, &m), + ApiError::NotFound(m) => json_err(404, &m), + ApiError::Internal(m) => json_err(500, &m), + } +} + +fn html(body: &str, code: u32) -> Response>> { + text(body, code, "text/html; charset=utf-8") +} + +fn text(body: &str, code: u32, content_type: &str) -> Response>> { + let mut r = Response::from_string(body.to_string()).with_status_code(code); + r.add_header(Header::from_bytes("Content-Type", content_type).unwrap()); + r +} +``` + +- [ ] **Step 3: Build the bin** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo build --no-default-features --features binary --bin play_server +``` +Expected: builds successfully. (`Response::from_string` / `from_data` return slightly different concrete types in `tiny_http` 0.12; if the `Response>>` return signature mismatches, switch the helpers to return `Response` for `from_string` and convert via `.boxed()` consistently. The simplest is to import `tiny_http::Response` aliased without the generic and use `Response::from_data(Vec)` throughout so all helpers share the `Cursor>` type. Adjust as the compiler guides.) + +- [ ] **Step 4: Commit** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole +git add deep_quoridor/rust/src/bin/play_server.rs deep_quoridor/rust/src/play_server/static +git commit -m "vibe: add play_server bin with tiny_http loop" +``` + +--- + +## Task 7: End-to-end test using the existing B5W2 fixture + +**Files:** +- Create: `deep_quoridor/rust/tests/play_server_e2e.rs` + +- [ ] **Step 1: Write the failing test** + +Create `deep_quoridor/rust/tests/play_server_e2e.rs`: +```rust +//! End-to-end test: spawn the play server bound to port 0, drive it via HTTP, +//! and verify state transitions on a tiny game using the existing B5W2 +//! fixture model. + +#![cfg(feature = "binary")] + +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use serde_json::Value; + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn fixture_onnx() -> PathBuf { + workspace_root().join("fixtures/alphazero_B5W2_mv1.onnx") +} + +fn make_play_dir() -> PathBuf { + let dir = tempfile::Builder::new() + .prefix("play_e2e_") + .tempdir() + .expect("tempdir") + .into_path(); + std::fs::write( + dir.join("config.yaml"), + "quoridor:\n board_size: 5\n max_walls: 2\n max_steps: 50\n", + ) + .unwrap(); + std::fs::create_dir_all(dir.join("models")).unwrap(); + std::fs::copy( + fixture_onnx(), + dir.join("models").join("alphazero_B5W2_mv1.onnx"), + ) + .unwrap(); + dir +} + +struct ServerProc { + child: Child, + port: u16, +} + +impl Drop for ServerProc { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn spawn_server(play_dir: &std::path::Path) -> ServerProc { + // Use a fixed high-ish port; if collision, the test will retry. + let port = pick_port(); + let bin = workspace_root().join("target/debug/play_server"); + let child = Command::new(&bin) + .args([ + "--play-dir", + play_dir.to_str().unwrap(), + "--port", + &port.to_string(), + "--bind", + "127.0.0.1", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn play_server"); + wait_ready(port); + ServerProc { child, port } +} + +fn pick_port() -> u16 { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + drop(l); + port +} + +fn wait_ready(port: u16) { + let deadline = Instant::now() + Duration::from_secs(15); + let url = format!("http://127.0.0.1:{port}/api/config"); + while Instant::now() < deadline { + if ureq::get(&url).call().is_ok() { + return; + } + std::thread::sleep(Duration::from_millis(150)); + } + panic!("play_server never became ready on port {port}"); +} + +#[test] +fn e2e_new_game_and_first_move() { + // Build the bin once; ignore exit code (`cargo test` already builds bins + // it depends on, but we want explicit failure if it doesn't). + assert!( + Command::new(env!("CARGO")) + .args([ + "build", + "--no-default-features", + "--features", + "binary", + "--bin", + "play_server", + ]) + .status() + .expect("cargo build") + .success(), + "cargo build of play_server failed" + ); + + let dir = make_play_dir(); + let server = spawn_server(&dir); + let base = format!("http://127.0.0.1:{}", server.port); + + // /api/config lists the fixture model + the board config. + let cfg: Value = ureq::get(&format!("{base}/api/config")) + .call() + .unwrap() + .into_json() + .unwrap(); + assert_eq!(cfg["board_size"], 5); + assert_eq!(cfg["max_walls"], 2); + assert!(cfg["models"] + .as_array() + .unwrap() + .iter() + .any(|m| m == "alphazero_B5W2_mv1.onnx")); + + // POST /api/games -> initial state. + let new_game: Value = ureq::post(&format!("{base}/api/games")) + .send_json(serde_json::json!({ + "model": "alphazero_B5W2_mv1.onnx", + "mcts_n": 8, + "human_player": 0, + })) + .unwrap() + .into_json() + .unwrap(); + let game_id = new_game["game_id"].as_str().unwrap().to_string(); + assert_eq!(new_game["state"]["board_size"], 5); + assert_eq!(new_game["state"]["current_player"], 0); + assert_eq!(new_game["state"]["human_player"], 0); + assert!(new_game["state"]["legal_actions"] + .as_array() + .unwrap() + .iter() + .any(|a| a["kind"] == "move")); + + // Pick any legal move action; POST /api/games//move. + let first_move_idx = new_game["state"]["legal_actions"] + .as_array() + .unwrap() + .iter() + .find_map(|a| { + if a["kind"] == "move" { + a["index"].as_u64().map(|x| x as u32) + } else { + None + } + }) + .expect("at least one move action"); + let after: Value = ureq::post(&format!("{base}/api/games/{game_id}/move")) + .send_json(serde_json::json!({ "action_index": first_move_idx })) + .unwrap() + .into_json() + .unwrap(); + // After human move + AI response, it's the human's turn again, and + // history grew by exactly 2. + assert_eq!(after["state"]["human_player"], 0); + assert_eq!(after["state"]["current_player"], 0); + assert_eq!(after["state"]["move_history"].as_array().unwrap().len(), 2); +} +``` + +- [ ] **Step 2: Run the test** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo test --no-default-features --features binary --test play_server_e2e -- --nocapture +``` +Expected: `e2e_new_game_and_first_move` passes (≈ a few seconds — the fixture model is tiny). + +- [ ] **Step 3: Commit** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole +git add deep_quoridor/rust/tests/play_server_e2e.rs +git commit -m "vibe: e2e test for play_server against B5W2 fixture" +``` + +--- + +## Task 8: Frontend — `index.html`, `app.css`, `app.js` + +**Files:** +- Modify (replace placeholder): `deep_quoridor/rust/src/play_server/static/index.html` +- Modify: `deep_quoridor/rust/src/play_server/static/app.css` +- Modify: `deep_quoridor/rust/src/play_server/static/app.js` + +- [ ] **Step 1: Replace `index.html`** + +```html + + + + +Quoridor vs AlphaZero + + + +
+

Quoridor vs AlphaZero

+ +
+ + + + +
+ + +
+ + + +``` + +- [ ] **Step 2: Replace `app.css`** + +```css +* { box-sizing: border-box; } +body { font-family: system-ui, sans-serif; margin: 1.5em; background: #fafafa; color: #222; } +#app { max-width: 1000px; margin: 0 auto; } +h1 { margin-top: 0; } +.panel { background: #fff; padding: 1em; border: 1px solid #ddd; border-radius: 6px; } +#setup label { display: block; margin-bottom: 0.75em; } +#setup button { padding: 0.5em 1em; } +#game { display: flex; gap: 1em; margin-top: 1em; align-items: flex-start; } + +#board { + display: grid; + background: #d8b876; + padding: 4px; + border-radius: 6px; + --cell: 44px; + --slot: 8px; +} +#board > div { + display: flex; + align-items: center; + justify-content: center; +} +#board > .cell { + background: #f1d9a1; + border-radius: 3px; + font-weight: bold; +} +#board > .cell.legal { background: #c8e8a6; cursor: pointer; } +#board > .cell.legal:hover { background: #a4d878; } +#board > .cell.last { outline: 3px solid #f08020; } +#board > .pawn-p1 { color: #1a48d8; } +#board > .pawn-p2 { color: #d8281a; } +#board > .pawn-p1::before { content: '\25CF'; font-size: 1.5em; } +#board > .pawn-p2::before { content: '\25CF'; font-size: 1.5em; } +#board > .wallslot { background: transparent; } +#board > .wallslot.legal { background: #c8e8a6aa; cursor: pointer; } +#board > .wallslot.legal:hover { background: #a4d878dd; } +#board > .wallslot.wall { background: #5a3a18; border-radius: 2px; } +#board > .post { background: #b08850; border-radius: 1px; } + +#info { min-width: 220px; } +#status { font-weight: bold; min-height: 1.2em; } +.thinking #status::after { content: ' (AI thinking…)'; font-weight: normal; color: #666; } +``` + +- [ ] **Step 3: Replace `app.js`** + +```javascript +"use strict"; + +const $ = (id) => document.getElementById(id); +let CONFIG = null; +let GAME = null; + +async function fetchJson(method, path, body) { + const opts = { method, headers: {} }; + if (body !== undefined) { + opts.headers["Content-Type"] = "application/json"; + opts.body = JSON.stringify(body); + } + const r = await fetch(path, opts); + const text = await r.text(); + const data = text ? JSON.parse(text) : {}; + if (!r.ok) throw new Error(data.error || `${r.status} ${r.statusText}`); + return data; +} + +async function init() { + CONFIG = await fetchJson("GET", "/api/config"); + const modelSel = $("model"); + modelSel.innerHTML = ""; + for (const m of CONFIG.models) { + const opt = document.createElement("option"); + opt.value = m; + opt.textContent = m; + modelSel.appendChild(opt); + } + const slider = $("mcts"); + slider.value = CONFIG.default_mcts_n; + $("mcts-val").value = slider.value; + slider.addEventListener("input", () => ($("mcts-val").value = slider.value)); + + $("start").addEventListener("click", startGame); + $("newgame2").addEventListener("click", () => { + $("setup").hidden = false; + $("game").hidden = true; + }); +} + +async function startGame() { + const body = { + model: $("model").value, + mcts_n: parseInt($("mcts").value, 10), + human_player: parseInt($("first").value, 10), + }; + const data = await fetchJson("POST", "/api/games", body); + GAME = { id: data.game_id, state: data.state }; + $("setup").hidden = true; + $("game").hidden = false; + render(); +} + +function render() { + const s = GAME.state; + const N = s.board_size; + const board = $("board"); + board.style.gridTemplateColumns = `repeat(${2 * N - 1}, var(--col-size))`; + board.style.gridAutoRows = "var(--row-size)"; + board.style.setProperty("--col-size", "var(--cell)"); + board.style.setProperty("--row-size", "var(--cell)"); + + board.innerHTML = ""; + const mirror = s.human_player === 1; + // (gridR, gridC) is the (2N-1)x(2N-1) grid position. + for (let gr = 0; gr < 2 * N - 1; gr++) { + for (let gc = 0; gc < 2 * N - 1; gc++) { + const el = document.createElement("div"); + // Display row is mirrored so the human's home row is at the bottom. + el.style.gridRow = `${(mirror ? gr : 2 * N - 2 - gr) + 1}`; + el.style.gridColumn = `${gc + 1}`; + + if (gr % 2 === 0 && gc % 2 === 0) { + // pawn cell + const r = gr / 2; + const c = gc / 2; + el.className = "cell"; + el.style.width = "var(--cell)"; + el.style.height = "var(--cell)"; + if (s.p1_pos[0] === r && s.p1_pos[1] === c) el.classList.add("pawn-p1"); + if (s.p2_pos[0] === r && s.p2_pos[1] === c) el.classList.add("pawn-p2"); + el.dataset.kind = "cell"; + el.dataset.r = r; + el.dataset.c = c; + } else if (gr % 2 === 1 && gc % 2 === 1) { + el.className = "post"; + el.style.width = "var(--slot)"; + el.style.height = "var(--slot)"; + } else { + // wall slot + el.className = "wallslot"; + const horizontal = gr % 2 === 1; + el.dataset.kind = horizontal ? "wall-h" : "wall-v"; + el.dataset.r = horizontal ? (gr - 1) / 2 : gr / 2; + el.dataset.c = horizontal ? gc / 2 : (gc - 1) / 2; + if (horizontal) { + el.style.height = "var(--slot)"; + el.style.gridColumn = `${gc + 1} / span 3`; + } else { + el.style.width = "var(--slot)"; + el.style.gridRow = `${(mirror ? gr : 2 * N - 2 - gr) + 1} / span 3`; + } + } + board.appendChild(el); + } + } + + // Mark existing walls. + for (const w of s.walls) { + const sel = + w.orientation === "h" + ? `.wallslot[data-kind="wall-h"][data-r="${w.row}"][data-c="${w.col}"]` + : `.wallslot[data-kind="wall-v"][data-r="${w.row}"][data-c="${w.col}"]`; + document.querySelectorAll(sel).forEach((el) => el.classList.add("wall")); + } + // Highlight last action. + if (s.last_action) { + const a = s.last_action; + if (a.kind === "move") { + document + .querySelectorAll(`.cell[data-r="${a.to[0]}"][data-c="${a.to[1]}"]`) + .forEach((el) => el.classList.add("last")); + } + } + + // Mark legal actions only when it's the human's turn. + if (s.winner === null && s.current_player === s.human_player) { + for (const a of s.legal_actions) { + let el = null; + if (a.kind === "move") { + el = board.querySelector(`.cell[data-r="${a.to[0]}"][data-c="${a.to[1]}"]`); + } else { + const kind = a.orientation === "h" ? "wall-h" : "wall-v"; + el = board.querySelector( + `.wallslot[data-kind="${kind}"][data-r="${a.row}"][data-c="${a.col}"]`, + ); + } + if (el) { + el.classList.add("legal"); + el.addEventListener("click", () => playMove(a.index), { once: true }); + } + } + } + + // Side panel. + const turnName = s.winner !== null ? "Game over" : s.current_player === 0 ? "Player 1" : "Player 2"; + $("turn").textContent = turnName; + $("you").textContent = s.human_player === 0 ? "Player 1" : "Player 2"; + const hWalls = s.human_player === 0 ? s.p1_walls : s.p2_walls; + const aWalls = s.human_player === 0 ? s.p2_walls : s.p1_walls; + $("hwalls").textContent = hWalls; + $("awalls").textContent = aWalls; + $("steps").textContent = s.completed_steps; + const status = + s.winner === null + ? "" + : s.winner === s.human_player + ? "You won 🎉" + : "AI won"; + $("status").textContent = status; +} + +async function playMove(actionIndex) { + document.body.classList.add("thinking"); + try { + const data = await fetchJson("POST", `/api/games/${GAME.id}/move`, { + action_index: actionIndex, + }); + GAME.state = data.state; + render(); + } catch (e) { + alert(`move rejected: ${e.message}`); + } finally { + document.body.classList.remove("thinking"); + } +} + +init(); +``` + +- [ ] **Step 4: Rebuild + manual smoke check** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo build --no-default-features --features binary --bin play_server +``` +Expected: builds (`include_str!` re-embeds the new files). + +Then, manually: +```bash +mkdir -p /tmp/quoridor-play/models +cp deep_quoridor/rust/fixtures/alphazero_B5W2_mv1.onnx /tmp/quoridor-play/models/ +cat > /tmp/quoridor-play/config.yaml <>` per game); §Cargo.toml additions → Task 1; §Testing → Tasks 2–5 unit tests + Task 7 e2e; §Out of scope kept out (no auth, no persistence, no undo, etc.). All mapped. +- **Placeholder scan:** none — every step has concrete code or exact commands. Tasks 6 and 4 carry small "if API signature differs, read X and adapt" notes for two specific calls (`tiny_http::Response` generics; `repr().get_wall`) where the exact compile-time shape isn't guaranteed without reading those files; both name the file to read and what to preserve. +- **Type consistency:** `StateView` (Task 2) field names match the JSON in the spec, the e2e test assertions (Task 7), and the frontend reads (Task 8). `EnrichedAction` tag/`kind` naming consistent across server, e2e test (`"kind":"move"`), and JS. `GameRegistry::get`/`insert` signatures are the same in Tasks 4, 5, and 6. `ApiError::{BadRequest,NotFound,Internal}` defined in Task 5 and consumed in Task 6. 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. 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 ``, and the human-player toggle. + +**Orientation.** Render with the human's home row at the bottom regardless of +`human_player`, so the player always sees "I'm advancing upward." The server +sends absolute coordinates; the client mirrors when `human_player == 1`. + +**Game-over banner.** "You won" / "AI won" / "Draw (truncated)" based on +`state.winner` (vs `human_player`). + +## Concurrency + +- Outer `Arc>>>>`: takes the outer + lock briefly to look up the session, then holds the inner per-game lock for + the duration of a move + AI response. +- AI inference is already single-threaded (`with_intra_threads(1)` is set on + the session builders), so other concurrent games proceed in parallel without + oversubscribing the CPU. +- Game IDs are 8 hex chars from a secure RNG. + +## Cargo.toml additions +```toml +[[bin]] +name = "play_server" +path = "src/bin/play_server.rs" +required-features = ["binary"] + +[dependencies] +tiny_http = { version = "0.12", optional = true } +``` +And `tiny_http` is added to the `binary` feature list. `serde_json` is already +on `binary` from the metrics work. + +## Testing + +- **Rust unit tests** in `play_server.rs` for the pure helpers: `State` + serialization, legal-action enrichment (`index → {kind, to/row/col/orient}`), + the wall list / `last_action` mapping. +- **Rust integration test** using the existing `alphazero_B5W2_mv1.onnx` + fixture: bind to port 0 (OS-assigned), `POST /api/games`, walk through a + small game, assert state transitions and that `winner` is eventually set on a + forced win. Uses `ureq` as a dev-dependency for the HTTP client. +- No frontend tests — manual. + +## Out of scope (YAGNI) + +- Auth or user accounts. +- HTTPS (localhost/LAN only). +- Game persistence — sessions are in-memory and lost on server restart. +- Undo / takeback. +- Spectator mode. +- Human-vs-human (matchmaking, lobbies). +- Replay save/load. +- Mobile-responsive layout (assume desktop browser). +- Per-session TTL or eviction — restart the server to clear. +- Model switching mid-game. +- Mixed-config model directories. 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