Add separate expansion and simulation horizons for MCTS (#4526)

Implements Option C from design discussion: separate tree expansion
limits from leaf evaluation limits to ensure fair score comparisons.

With games having sequential same-player actions, fixed tree depth
creates unfair comparisons:
- "MOVE away, MOVE back" (2 actions, still my turn) → evaluated mid-turn
- "END_TURN" (1 action, now opponent's turn) → evaluated after turn
Not comparable - different game phases!

**Two independent limits:**
1. maxPlayerFlips (tree expansion): Controls how far to build tree
2. maxSimulationFlips (leaf evaluation): Controls evaluation horizon

**For Shardok (maxPlayerFlips=0, maxSimulationFlips=1):**
- Build tree through all my action sequences (playerFlips=0)
- When hitting a leaf: simulate until playerFlips > maxSimulationFlips
- Result: All leaves evaluated "after opponent responds"

1. Added maxSimulationFlips to MCTSConfig (default 0, backward compatible)
2. Updated MCTSSimulation to use maxSimulationFlips for horizon:
   - Early return check: startingPlayerFlips > maxSimulationFlips
   - Loop condition: playerFlips <= maxSimulationFlips
   - Allows one action AT the horizon before stopping
3. Configured Shardok to use maxSimulationFlips=1 for fair evaluation
4. Updated TicTacToe tests with appropriate simulation horizon values

 TicTacToe MCTS integration tests pass
 Abstract MCTS AI tests pass
 Shardok MCTS basic tests pass (now prefers ARCHERY over END_TURN)
 AI integration test has timeout (expected - deeper simulation)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-08 13:41:55 -08:00
committed by GitHub
co-authored by Claude
parent 9b0322e8a3
commit 7bdcc511f5
4 changed files with 29 additions and 5 deletions
@@ -301,14 +301,22 @@ auto AbstractMCTSAI::MCTSSimulation(
const int startingPlayerFlips) const -> double {
if (state.isTerminal()) { return state.score(startingPlayer); }
// If we've already exceeded the simulation horizon, don't simulate - just return immediate
// score This ensures fair comparison: all leaves are evaluated at the same game phase Example:
// maxSimulationFlips=1 means simulate THROUGH opponent's first response (i.e., allow one action
// at playerFlips=1, then stop)
if (startingPlayerFlips > config_.maxSimulationFlips) { return state.score(startingPlayer); }
// Create a mutable copy for simulation
auto currentState = state.clone();
int depth = 0;
int playerFlips = startingPlayerFlips; // Start from the expanded node's flip count
MCTSPlayerId previousPlayer = currentState->currentPlayerId();
// Simulate until terminal or max depth
while (!currentState->isTerminal() && depth < config_.maxSimulationDepth) {
// Simulate until we exceed the horizon, hit terminal state, or max depth
// Note: We allow one action AT maxSimulationFlips before stopping
while (!currentState->isTerminal() && depth < config_.maxSimulationDepth &&
playerFlips <= config_.maxSimulationFlips) {
// Track player changes
const MCTSPlayerId currentPlayer = currentState->currentPlayerId();
if (currentPlayer != previousPlayer) {
@@ -321,7 +329,7 @@ auto AbstractMCTSAI::MCTSSimulation(
*currentState,
playerId_,
playerFlips,
config_.maxPlayerFlips);
config_.maxSimulationFlips);
if (actions.empty()) { break; }
// Determine if current player is maximizing or minimizing
@@ -44,8 +44,13 @@ struct MCTSConfig {
int numThreads = 16; // Number of threads for parallel MCTS
MCTSSimulationPolicy simulationPolicy = MCTSSimulationPolicy::BEST_IMMEDIATE;
MCTSBackpropagationPolicy backpropagationPolicy = MCTSBackpropagationPolicy::AVERAGING;
int maxPlayerFlips = 0; // Maximum number of player changes to explore (0 = stop at first
// flip, 1 = explore through opponent's response, etc.)
int maxPlayerFlips = 0; // Maximum number of player changes for tree expansion
// (0 = expand through current player's turn only,
// 1 = expand through opponent's first response, etc.)
int maxSimulationFlips = 0; // Maximum player flips for leaf evaluation
// When evaluating a leaf at playerFlips < maxSimulationFlips,
// simulate forward to this phase for fair comparison
// (default 0 = evaluate leaves as-is, backward compatible)
std::string debugDumpPath = ""; // If non-empty, dump MCTS tree to this file path
};
@@ -135,6 +135,14 @@ auto ShardokAIClient::StandardChooseCommandIndex(
// - MINIMAX correctly models opponent choosing best response
// - Explores through one opponent turn for tactical accuracy
auto adjustedMCTSConfig = mctsConfig;
// For fair evaluation: simulate leaves to opponent's turn start (maxSimulationFlips=1)
// This ensures all leaves are scored at the same game phase:
// - Leaves at playerFlips=0 (still my turn): simulate through END_TURN to playerFlips=1
// - Leaves at playerFlips=1 (opponent's turn): evaluate immediately
// Result: consistent comparison of "what happens after I end my turn"
adjustedMCTSConfig.maxSimulationFlips = 1;
// adjustedMCTSConfig.maxPlayerFlips = 0;
// if (timeBudget.isCloseToEnemy) {
// adjustedMCTSConfig.maxPlayerFlips = 1;
@@ -287,6 +287,7 @@ TEST_F(MCTSIntegrationTest, AdaptiveExplorationExploitation) {
TEST_F(MCTSIntegrationTest, EarlyTerminationOnWin_MaxPlayerFlips0) {
// Explicitly set maxPlayerFlips=0 for single-player MCTS
config_.maxPlayerFlips = 0;
config_.maxSimulationFlips = 1; // Simulate to opponent's turn for fair evaluation
// If MCTS finds a guaranteed win, it should recognize it quickly
// X X . <- Position 2 wins
@@ -313,6 +314,7 @@ TEST_F(MCTSIntegrationTest, EarlyTerminationOnWin_MaxPlayerFlips0) {
TEST_F(MCTSIntegrationTest, EarlyTerminationOnWin_MaxPlayerFlips1) {
// Explicitly set maxPlayerFlips=1 for two-player adversarial MCTS
config_.maxPlayerFlips = 1;
config_.maxSimulationFlips = 2; // Simulate to next player flip for deeper evaluation
// If MCTS finds a guaranteed win, it should recognize it quickly
// X X . <- Position 2 wins
@@ -342,6 +344,7 @@ TEST_F(MCTSIntegrationTest, EarlyTerminationOnWin_MaxPlayerFlips8) {
// Explicitly set maxPlayerFlips=8 for full game tree exploration
// (TicTacToe has max 9 moves = 8 player flips)
config_.maxPlayerFlips = 8;
config_.maxSimulationFlips = 9; // Simulate to game completion
// If MCTS finds a guaranteed win, it should recognize it quickly
// X X . <- Position 2 wins