Add Monte Carlo simulator, game metrics and sim CLI (Phase 5)
`dojo sim` plays seeded games over a scenario matrix (variant, players, Kiai bonus, start position, bot lineup) in parallel, prints a summary per scenario and optionally writes one CSV row per game. Metrics are replayed from the event log. Plan updated with the Phase 5 baseline. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+128
@@ -0,0 +1,128 @@
|
||||
"""Monte Carlo runner: many seeded games over a matrix of rule configurations.
|
||||
|
||||
Game ``i`` of every scenario uses seed ``base + i``, so scenarios are compared on the same
|
||||
dice. The lineup rotates one seat per game, separating seat effects from bot effects.
|
||||
Results do not depend on the number of worker processes.
|
||||
"""
|
||||
|
||||
import itertools
|
||||
import random
|
||||
from collections.abc import Sequence
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from dataclasses import dataclass, replace
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
|
||||
from dojo.board import Board, load_board
|
||||
from dojo.bots import PROFILES, HeuristicAgent, RandomAgent
|
||||
from dojo.engine import Agent, Game, RandomSource, seat_corners
|
||||
from dojo.metrics import GameMetrics, measure
|
||||
from dojo.rules import VARIANT_A, VARIANT_B, RulesConfig
|
||||
|
||||
VARIANTS = {"A": VARIANT_A, "B": VARIANT_B}
|
||||
RANDOM_BOT = "random"
|
||||
BOTS = (RANDOM_BOT, *PROFILES)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Scenario:
|
||||
variant: str
|
||||
players: int
|
||||
kiai_bonus: int
|
||||
start_position: int
|
||||
# Bot names, repeated in order to fill the seats.
|
||||
lineup: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.variant not in VARIANTS:
|
||||
raise ValueError(f"unknown variant {self.variant!r}: expected one of {list(VARIANTS)}")
|
||||
unknown = [bot for bot in self.lineup if bot not in BOTS]
|
||||
if unknown or not self.lineup:
|
||||
raise ValueError(f"unknown bots {unknown}: expected some of {list(BOTS)}")
|
||||
seat_corners(self.players)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return (
|
||||
f"{self.variant} p{self.players} kiai+{self.kiai_bonus} start{self.start_position} "
|
||||
+ ",".join(self.lineup)
|
||||
)
|
||||
|
||||
@property
|
||||
def config(self) -> RulesConfig:
|
||||
return replace(
|
||||
VARIANTS[self.variant],
|
||||
kiai_bonus=self.kiai_bonus,
|
||||
start_position=self.start_position,
|
||||
)
|
||||
|
||||
def seating(self, game: int) -> tuple[str, ...]:
|
||||
seats = [self.lineup[seat % len(self.lineup)] for seat in range(self.players)]
|
||||
shift = game % self.players
|
||||
return tuple(seats[shift:] + seats[:shift])
|
||||
|
||||
|
||||
def matrix(
|
||||
variants: Sequence[str],
|
||||
players: Sequence[int],
|
||||
kiai_bonuses: Sequence[int],
|
||||
start_positions: Sequence[int],
|
||||
lineups: Sequence[tuple[str, ...]],
|
||||
) -> list[Scenario]:
|
||||
return [
|
||||
Scenario(*axes)
|
||||
for axes in itertools.product(variants, players, kiai_bonuses, start_positions, lineups)
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BoardFiles:
|
||||
quadrant: Path
|
||||
legend: Path | None
|
||||
|
||||
|
||||
def make_agent(bot: str, seed: int, seat: int) -> Agent:
|
||||
if bot == RANDOM_BOT:
|
||||
return RandomAgent(random.Random(f"bot:{seed}:{seat}"))
|
||||
return HeuristicAgent(PROFILES[bot])
|
||||
|
||||
|
||||
def play(board: Board, scenario: Scenario, index: int, seed: int) -> GameMetrics:
|
||||
bots = scenario.seating(index)
|
||||
agents = [make_agent(bot, seed, seat) for seat, bot in enumerate(bots)]
|
||||
game = Game(board, scenario.config, agents, RandomSource(seed))
|
||||
game.run()
|
||||
return measure(
|
||||
game.events,
|
||||
scenario.config,
|
||||
seat_corners(scenario.players),
|
||||
bots,
|
||||
scenario=scenario.name,
|
||||
seed=seed,
|
||||
)
|
||||
|
||||
|
||||
def run(
|
||||
files: BoardFiles,
|
||||
scenarios: Sequence[Scenario],
|
||||
games: int,
|
||||
seed: int = 0,
|
||||
jobs: int = 1,
|
||||
) -> list[GameMetrics]:
|
||||
"""Play ``games`` games of each scenario; results are ordered by scenario, then game."""
|
||||
tasks = [(s, i, seed + i) for s in scenarios for i in range(games)]
|
||||
if jobs == 1:
|
||||
board = _board(files)
|
||||
return [play(board, *task) for task in tasks]
|
||||
with ProcessPoolExecutor(jobs) as pool:
|
||||
chunk = max(1, len(tasks) // (jobs * 8))
|
||||
return list(pool.map(_play_task, itertools.repeat(files), tasks, chunksize=chunk))
|
||||
|
||||
|
||||
def _play_task(files: BoardFiles, task: tuple[Scenario, int, int]) -> GameMetrics:
|
||||
return play(_board(files), *task)
|
||||
|
||||
|
||||
@cache
|
||||
def _board(files: BoardFiles) -> Board:
|
||||
return load_board(files.quadrant, files.legend)
|
||||
Reference in New Issue
Block a user