use postflop_solver::*; use serde::Serialize; use std::env; use std::fs::{self, File}; use std::path::PathBuf; use std::process::Command; use std::time::{Instant, SystemTime, UNIX_EPOCH}; const ENGINE_REPOSITORY: &str = "https://github.com/b-inary/postflop-solver"; const ENGINE_COMMIT: &str = "9d1509fe5077d019825f833eed04b16d342dfda1"; const STARTING_POT_CHIPS: i32 = 100; const EXPLOITABILITY_CHECK_INTERVAL: u32 = 10; #[derive(Clone, Serialize)] struct ScenarioSpec { id: &'static str, purpose: &'static str, oop_range: &'static str, ip_range: &'static str, flop: &'static str, } #[derive(Clone, Serialize)] struct ConvergenceSpec { id: &'static str, target_exploitability_chips: f32, max_iterations: u32, } #[derive(Serialize)] struct ExperimentConfig { question: &'static str, suit_permutation: &'static str, positions: [&'static str; 2], game: &'static str, street: &'static str, starting_pot_chips: i32, effective_stack_behind_chips: i32, rake_rate: f64, rake_cap_chips: f64, flop_bet_sizes: [&'static str; 2], flop_raise_sizes: [&'static str; 2], turn_bet_sizes: [&'static str; 2], river_bet_sizes: [&'static str; 2], add_allin_threshold: f64, force_allin_threshold: f64, merging_threshold: f64, chance_model: &'static str, ev_convention: &'static str, exploitability_definition: &'static str, exploitability_check_interval_iterations: u32, range_interpretation: &'static str, compatible_reach_mass_definition: &'static str, scenarios: Vec, convergence_runs: Vec, } #[derive(Serialize)] struct EngineInfo { repository: &'static str, commit: &'static str, crate_version: &'static str, license: &'static str, cargo_features: &'static str, numeric_precision: &'static str, algorithm: &'static str, rustc_version: String, source_git_head_verified: String, source_git_worktree_clean: bool, } #[derive(Serialize)] struct HandRow { hand: String, compatible_reach_mass: f32, mixed_ev_chips: f32, actions: Vec, } #[derive(Serialize)] struct ActionRow { action: String, strategy: f32, action_ev_chips: f32, } #[derive(Serialize)] struct NodeResult { path: Vec, acting_player: &'static str, hands: Vec, } #[derive(Serialize)] struct SolveResult { scenario_id: &'static str, convergence_id: &'static str, target_exploitability_chips: f32, target_exploitability_pct_starting_pot: f32, achieved_exploitability_chips: f32, achieved_exploitability_pct_starting_pot: f32, iterations: u32, max_iterations: u32, runtime_ms: u128, memory_uncompressed_bytes: u64, memory_compressed_bytes: u64, nodes: Vec, } #[derive(Serialize)] struct RawOutput { generated_unix_seconds: u64, engine: EngineInfo, config: ExperimentConfig, results: Vec, } fn experiment_config() -> ExperimentConfig { ExperimentConfig { question: "When must same-rank suit combinations map under a true hearts/spades relabel, and how can board/runout structure or exact-combination range weights break their within-spot equivalence?", suit_permutation: "hearts <-> spades; clubs and diamonds fixed", positions: ["OOP", "IP"], game: "heads-up no-limit Texas Hold'em chip-EV postflop game", street: "flop; every legal turn/river runout is evaluated exactly, with lossless isomorphic chance grouping where board and ranges permit", starting_pot_chips: STARTING_POT_CHIPS, effective_stack_behind_chips: 100, rake_rate: 0.0, rake_cap_chips: 0.0, flop_bet_sizes: ["OOP: none (check only)", "IP: 75% pot"], flop_raise_sizes: ["none", "none"], turn_bet_sizes: ["none (check only)", "none (check only)"], river_bet_sizes: ["none (check only)", "none (check only)"], add_allin_threshold: 0.0, force_allin_threshold: 0.0, merging_threshold: 0.0, chance_model: "standard 52-card deck without replacement; all legal runouts evaluated exactly, with lossless range-aware isomorphic chance grouping", ev_convention: "expected_values_detail/action EV and expected_values/mixed EV use the engine's node-relative chip-EV convention; fold is set to 0 where available; values are not equity, and cross-hand comparisons in this experiment are only made within the same node and scenario", exploitability_definition: "global full-toy-game exploitability in chips, not a per-hand EV error bound", exploitability_check_interval_iterations: EXPLOITABILITY_CHECK_INTERVAL, range_interpretation: "each listed physical two-card combination has the stated independent initial weight; omitted combinations have weight zero", compatible_reach_mass_definition: "the engine's current-node weighted legal-matchup mass for the focal hand: its own reach weight multiplied by the summed compatible opponent reach after card-collision removal; it is not a probability and can exceed 1", scenarios: vec![ ScenarioSpec { id: "symmetric", purpose: "Board and both exact-combination ranges are invariant under hearts/spades exchange.", oop_range: "AhJh:1,AsJs:1", ip_range: "KhQh:1,KsQs:1,Ah5h:1,As5s:1", flop: "Qc7h7s", }, ScenarioSpec { id: "asymmetric", purpose: "Only the IP bluff-candidate weights break hearts/spades exchangeability.", oop_range: "AhJh:1,AsJs:1", ip_range: "KhQh:1,KsQs:1,Ah5h:1,As5s:0.2", flop: "Qc7h7s", }, ScenarioSpec { id: "asymmetric_relabelled", purpose: "Global hearts/spades relabel of the asymmetric scenario; the 1.0 and 0.2 weights move with their physical combinations.", oop_range: "AsJs:1,AhJh:1", ip_range: "KsQs:1,KhQh:1,As5s:1,Ah5h:0.2", flop: "Qc7s7h", }, ScenarioSpec { id: "board_asymmetric", purpose: "Ranges are hearts/spades symmetric, but Qc7h2d breaks hearts/spades symmetry across future-card and blocker relationships; in particular Ah5h has a runner-runner heart-flush path while As5s cannot make a spade flush by the river.", oop_range: "AhJh:1,AsJs:1", ip_range: "KhQh:1,KsQs:1,Ah5h:1,As5s:1", flop: "Qc7h2d", }, ScenarioSpec { id: "board_asymmetric_relabelled", purpose: "Global hearts/spades relabel of the board-asymmetric scenario.", oop_range: "AsJs:1,AhJh:1", ip_range: "KsQs:1,KhQh:1,As5s:1,Ah5h:1", flop: "Qc7s2d", }, ], convergence_runs: vec![ ConvergenceSpec { id: "baseline", target_exploitability_chips: 0.10, max_iterations: 5_000, }, ConvergenceSpec { id: "middle", target_exploitability_chips: 0.01, max_iterations: 10_000, }, ConvergenceSpec { id: "tight", target_exploitability_chips: 0.001, max_iterations: 50_000, }, ], } } fn build_game(scenario: &ScenarioSpec) -> PostFlopGame { let card_config = CardConfig { range: [ scenario.oop_range.parse().expect("valid OOP range"), scenario.ip_range.parse().expect("valid IP range"), ], flop: flop_from_str(scenario.flop).expect("valid flop"), turn: NOT_DEALT, river: NOT_DEALT, }; let ip_flop = BetSizeOptions::try_from(("75%", "")).expect("valid IP flop size"); let tree_config = TreeConfig { initial_state: BoardState::Flop, starting_pot: STARTING_POT_CHIPS, effective_stack: 100, rake_rate: 0.0, rake_cap: 0.0, flop_bet_sizes: [BetSizeOptions::default(), ip_flop], turn_bet_sizes: Default::default(), river_bet_sizes: Default::default(), turn_donk_sizes: Some(DonkSizeOptions::default()), river_donk_sizes: Some(DonkSizeOptions::default()), add_allin_threshold: 0.0, force_allin_threshold: 0.0, merging_threshold: 0.0, }; let action_tree = ActionTree::new(tree_config).expect("valid action tree"); PostFlopGame::with_config(card_config, action_tree).expect("valid postflop game") } fn action_name(action: Action) -> String { match action { Action::None => "none".to_string(), Action::Fold => "fold".to_string(), Action::Check => "check".to_string(), Action::Call => "call".to_string(), Action::Bet(amount) => format!("bet_{amount}"), Action::Raise(amount) => format!("raise_to_{amount}"), Action::AllIn(amount) => format!("all_in_{amount}"), Action::Chance(card) => format!("chance_{}", card_to_string(card).unwrap()), } } fn find_action(game: &PostFlopGame, predicate: impl Fn(Action) -> bool) -> usize { game.available_actions() .iter() .copied() .position(predicate) .expect("expected action is available") } fn capture_node(game: &mut PostFlopGame, path: Vec) -> NodeResult { game.cache_normalized_weights(); let player = game.current_player(); let player_name = if player == 0 { "OOP" } else { "IP" }; let actions = game.available_actions().to_vec(); let private_cards = holes_to_strings(game.private_cards(player)).unwrap(); let num_hands = private_cards.len(); let strategy = game.strategy(); let action_evs = game.expected_values_detail(player); let mixed_evs = game.expected_values(player); let normalized_weights = game.normalized_weights(player).to_vec(); let hands = private_cards .into_iter() .enumerate() .map(|(hand_index, hand)| { let action_rows = actions .iter() .enumerate() .map(|(action_index, &action)| { let index = action_index * num_hands + hand_index; ActionRow { action: action_name(action), strategy: strategy[index], action_ev_chips: action_evs[index], } }) .collect(); HandRow { hand, compatible_reach_mass: normalized_weights[hand_index], mixed_ev_chips: mixed_evs[hand_index], actions: action_rows, } }) .collect(); NodeResult { path, acting_player: player_name, hands, } } fn solve_one(scenario: &ScenarioSpec, convergence: &ConvergenceSpec) -> SolveResult { let mut game = build_game(scenario); let (memory_uncompressed_bytes, memory_compressed_bytes) = game.memory_usage(); game.allocate_memory(false); let started = Instant::now(); let mut exploitability = compute_exploitability(&game); let mut iterations = 0; for t in 0..convergence.max_iterations { if exploitability <= convergence.target_exploitability_chips { break; } solve_step(&game, t); iterations = t + 1; if iterations % EXPLOITABILITY_CHECK_INTERVAL == 0 || iterations == convergence.max_iterations { exploitability = compute_exploitability(&game); } } finalize(&mut game); let runtime_ms = started.elapsed().as_millis(); let mut nodes = Vec::new(); nodes.push(capture_node(&mut game, vec![])); let check_index = find_action(&game, |action| action == Action::Check); game.play(check_index); nodes.push(capture_node(&mut game, vec!["check".to_string()])); let bet_index = find_action(&game, |action| matches!(action, Action::Bet(_) | Action::AllIn(_))); let bet_action = action_name(game.available_actions()[bet_index]); game.play(bet_index); nodes.push(capture_node( &mut game, vec!["check".to_string(), bet_action], )); SolveResult { scenario_id: scenario.id, convergence_id: convergence.id, target_exploitability_chips: convergence.target_exploitability_chips, target_exploitability_pct_starting_pot: 100.0 * convergence.target_exploitability_chips / STARTING_POT_CHIPS as f32, achieved_exploitability_chips: exploitability, achieved_exploitability_pct_starting_pot: 100.0 * exploitability / STARTING_POT_CHIPS as f32, iterations, max_iterations: convergence.max_iterations, runtime_ms, memory_uncompressed_bytes: memory_uncompressed_bytes as u64, memory_compressed_bytes: memory_compressed_bytes as u64, nodes, } } fn rustc_version() -> String { Command::new("rustc") .arg("--version") .output() .ok() .and_then(|output| String::from_utf8(output.stdout).ok()) .map(|version| version.trim().to_string()) .filter(|version| !version.is_empty()) .unwrap_or_else(|| "unavailable at runtime".to_string()) } fn command_stdout(command: &mut Command, description: &str) -> String { let output = command.output().unwrap_or_else(|error| { panic!("failed to run {description}: {error}") }); if !output.status.success() { panic!( "{description} failed: {}", String::from_utf8_lossy(&output.stderr).trim() ); } String::from_utf8(output.stdout) .expect("command output is UTF-8") .trim() .to_string() } fn verify_engine_checkout() -> (String, bool) { let engine_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../open-source/postflop-solver") .canonicalize() .expect("canonicalize engine checkout path"); let head = command_stdout( Command::new("git") .arg("-C") .arg(&engine_dir) .args(["rev-parse", "HEAD"]), "git rev-parse for engine checkout", ); let status = command_stdout( Command::new("git") .arg("-C") .arg(&engine_dir) .args(["status", "--porcelain", "--untracked-files=all"]), "git status for engine checkout", ); assert_eq!(head, ENGINE_COMMIT, "engine checkout is not at pinned commit"); assert!(status.is_empty(), "engine checkout is dirty: {status}"); (head, status.is_empty()) } fn main() { let out_dir = env::args() .nth(1) .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("../results")); fs::create_dir_all(&out_dir).expect("create output directory"); let (source_git_head_verified, source_git_worktree_clean) = verify_engine_checkout(); let config = experiment_config(); let mut results = Vec::new(); for scenario in &config.scenarios { for convergence in &config.convergence_runs { eprintln!("solving {} / {}", scenario.id, convergence.id); results.push(solve_one(scenario, convergence)); } } let raw = RawOutput { generated_unix_seconds: SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs(), engine: EngineInfo { repository: ENGINE_REPOSITORY, commit: ENGINE_COMMIT, crate_version: "0.1.0", license: "AGPL-3.0-or-later", cargo_features: "default-features = false (sequential; no bincode, rayon, zstd, or custom allocator)", numeric_precision: "mostly f32 internally; f64 for selected summations, per upstream README", algorithm: "Discounted CFR; gamma=3.0 and cumulative-strategy reset at powers of four, per upstream README/source", rustc_version: rustc_version(), source_git_head_verified, source_git_worktree_clean, }, config, results, }; let mut config_file = File::create(out_dir.join("experiment-config.json")).unwrap(); serde_json::to_writer_pretty(&mut config_file, &raw.config).unwrap(); let mut raw_file = File::create(out_dir.join("raw-results.json")).unwrap(); serde_json::to_writer_pretty(&mut raw_file, &raw).unwrap(); }