Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f997cc8
Update B9W10 config
jonbinney May 24, 2026
250c2c6
vibe: add self-play MCTS metrics design spec
jonbinney May 27, 2026
be19093
vibe: add self-play MCTS metrics implementation plan
jonbinney May 27, 2026
c5befa0
vibe: return per-search MCTS SearchStats
jonbinney May 27, 2026
eac3726
vibe: collect per-game MCTS metrics and move hashes
jonbinney May 27, 2026
3ea932c
vibe: add SelfPlayAccumulator with JSON metric flush
jonbinney May 27, 2026
e936b69
vibe: flush self-play MCTS metrics per model version
jonbinney May 27, 2026
defa27b
vibe: add self-play metrics aggregator and W&B logger
jonbinney May 27, 2026
12f00c7
vibe: spawn self-play metrics logger from train_v2
jonbinney May 27, 2026
9ed6c73
vibe: cargo fmt + ruff format
jonbinney May 27, 2026
dde24f3
vibe: add Quoridor play server design spec
jonbinney May 29, 2026
e4ae6d7
vibe: add Quoridor play server implementation plan
jonbinney May 29, 2026
e58cad1
vibe: add tiny_http dep + play_server bin entry
jonbinney May 29, 2026
810907d
vibe: add play_server state view + action enrichment
jonbinney May 29, 2026
8386d2f
vibe: add play_server config loader and models scan
jonbinney May 29, 2026
e8a391f
vibe: add GameSession + GameRegistry
jonbinney May 29, 2026
d2d31a9
vibe: add play_server JSON handlers
jonbinney May 29, 2026
1dc17ad
vibe: tighten handler test to assert AI step ran
jonbinney May 29, 2026
9d4fa7f
vibe: add play_server tiny_http binary
jonbinney May 29, 2026
8499e08
vibe: add play_server end-to-end test
jonbinney May 29, 2026
4ef0a96
vibe: add playable Quoridor frontend assets
jonbinney May 29, 2026
ab19c84
vibe: gate test-only Evaluator import behind cfg(test)
jonbinney May 29, 2026
8304135
vibe: cargo fmt
jonbinney May 29, 2026
d9cdd91
vibe: fix frontend UI issues and refine board palette
jonbinney May 29, 2026
820f5ba
vibe: restore click handlers broken by pending guard
jonbinney May 29, 2026
e016e94
vibe: anchor-only wall hover + put human at bottom
jonbinney May 29, 2026
29e0203
vibe: fix swapped walls-remaining; reword label
jonbinney May 29, 2026
8b0ba1f
vibe: default mcts_n 1000; simpler who-goes-first labels
jonbinney May 29, 2026
5c0bdc0
vibe: silence cargo-test filename-collision warning
jonbinney May 31, 2026
de6683c
vibe: prefer filter().map() over bool::then in filter_map
jonbinney May 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions deep_quoridor/rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 }
Expand All @@ -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 }
Expand All @@ -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
Expand Down
23 changes: 23 additions & 0 deletions deep_quoridor/rust/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
1 change: 1 addition & 0 deletions deep_quoridor/rust/src/agents/alphazero/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
135 changes: 109 additions & 26 deletions deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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 {
Expand All @@ -80,14 +108,16 @@ pub async fn play_game_async(
board_size: i32,
max_walls: i32,
max_steps: i32,
) -> Result<GameResult> {
) -> 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();
let (orig_to_rot, _) = create_rotation_mapping(board_size);
let mut replay_items: Vec<ReplayBufferItem> = Vec::new();
let visited = HashSet::new();
let mut winner: Option<i32> = None;
let mut gm = GameMetrics::default();
let mut actions: Vec<usize> = Vec::new();

for step in 0..max_steps {
let current_player = mechanics.repr().get_current_player(data) as i32;
Expand All @@ -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 {
Expand Down Expand Up @@ -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(
Expand All @@ -170,8 +225,8 @@ async fn run_az_select(
visited: &HashSet<CompactState>,
settings: GameSettings,
step: usize,
) -> Result<(usize, Vec<f32>)> {
let (children, _root_value) = mcts.search(data, mechanics, visited).await?;
) -> Result<(usize, Vec<f32>, SearchStats)> {
let (children, _root_value, stats) = mcts.search(data, mechanics, visited).await?;
let visit_counts: Vec<u32> = children.iter().map(|c| c.visit_count).collect();
let action_indices: Vec<usize> = children.iter().map(|c| c.action_index).collect();
let temperature = match settings.drop_t_on_step {
Expand All @@ -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<f32>) {
Expand All @@ -210,3 +265,31 @@ fn random_select(mask: &[bool]) -> (usize, Vec<f32>) {
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");
}
}
Loading
Loading