Add heuristic_balanced opponent_policy to interleaved scheduler

Mirrors the discard_only plumbing pattern. Uses HeuristicBot() (default
balanced params) and converts the bot's phase-local action to unified via
state.to_unified_action. Recursive (Cython) path was already supported
and is unchanged.

Tests: smoke run + accept/reject validators. All 59 tests pass.
This commit is contained in:
2026-05-10 21:08:12 +09:00
parent 12d10fd9c8
commit 7d59398159
3 changed files with 67 additions and 3 deletions
@@ -197,10 +197,16 @@ class TraversalConfig(StrictModel):
if self.scheduler == "interleaved":
if self.sampling_mode != "outcome":
raise ValueError("scheduler='interleaved' currently supports only outcome sampling")
if self.opponent_policy not in {"network", "average_strategy", "discard_only"}:
if self.opponent_policy not in {
"network",
"average_strategy",
"discard_only",
"heuristic_balanced",
}:
raise ValueError(
"scheduler='interleaved' currently supports only "
"opponent_policy='network', 'average_strategy', or 'discard_only'"
"opponent_policy='network', 'average_strategy', 'discard_only', "
"or 'heuristic_balanced'"
)
if self.cutoff_rollouts != 0 or self.cutoff_value_mode != "score_diff":
raise ValueError(
@@ -9,6 +9,7 @@ import numpy as np
import torch
from coolrl_lost_cities.games.classic.bots.discard_only import DiscardOnlyBot
from coolrl_lost_cities.games.classic.bots.heuristic_py import HeuristicBot
from coolrl_lost_cities.games.classic.deep_cfr.encoding import encode_info_state
from coolrl_lost_cities.games.classic.deep_cfr.memory import TrainingSample
from coolrl_lost_cities.games.classic.deep_cfr.traversal_stats import TraversalStats
@@ -381,6 +382,9 @@ class InterleavedContext:
self._discard_only_bot: DiscardOnlyBot | None = (
DiscardOnlyBot() if cfg.opponent_policy == "discard_only" else None
)
self._heuristic_bot: HeuristicBot | None = (
HeuristicBot() if cfg.opponent_policy == "heuristic_balanced" else None
)
def advance_until_policy(self, context_index: int) -> None:
while not self.done and self.pending is None and self.stack:
@@ -482,6 +486,14 @@ class InterleavedContext:
self.stack.append(FixedActionFrame(swapped_deck_index=swapped_deck_index))
self.stack.append(EnterFrame(depth + 1))
return
if player != self.traverser and self._heuristic_bot is not None:
phase_local_action = int(self._heuristic_bot.act(self.state))
action = int(self.state.to_unified_action(phase_local_action))
swapped_deck_index = self._sample_deck_draw_chance(action)
self.state.push_unified_action(action)
self.stack.append(FixedActionFrame(swapped_deck_index=swapped_deck_index))
self.stack.append(EnterFrame(depth + 1))
return
info_state = encode_info_state(self.state, player, self.cfg.encoding)
legal_mask = np.zeros(self.cfg.action_size, dtype=bool)
legal_mask[legal_actions] = True