Files
rgriffithandClaude Opus 4.6 9b79d18201 feat(lan-client): triggers panel + CMD DMG score badges + fix encoding project-wide
Left action panel revised to show triggers list + Create Token/Auto-Arrange.
CMD DMG badges added to center life-panel score areas (click opens matrix).
Removed renderEnhancedPanels calls that split the battlefield on damage adjust.
Added encoding='utf-8' to all bare open() calls across 17 Python files to
prevent cp1252 crashes on MTG card text with smart quotes/em-dashes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 22:14:56 -04:00

427 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Commander AI Lab — Tournament Evaluator (Phase 10)
═══════════════════════════════════════════════════
Round-robin evaluation of multiple AI policies:
- Learned Policy (supervised or PPO checkpoint)
- Heuristic Policy (rule-based baseline)
- Random Policy (uniform random baseline)
- Forge AI (via Java subprocess, when available)
Runs many synthetic games between each pair of policies,
tracks win rates, and produces a tournament results table.
Usage:
python -m ml.eval.tournament
python -m ml.eval.tournament --episodes 100 --checkpoint ml/models/checkpoints/best_ppo.pt
"""
import json
import logging
import os
import sys
import time
from dataclasses import dataclass, field
from itertools import combinations
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import numpy as np
project_root = str(Path(__file__).parent.parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
from ml.config.scope import NUM_ACTIONS, STATE_DIMS, IDX_TO_ACTION
from ml.training.reward import RewardConfig
from ml.training.self_play import (
GameState, create_random_initial_state, apply_action, advance_turn,
encode_state_simple, RandomPolicy, HeuristicPolicy,
run_self_play_episode,
)
logger = logging.getLogger("ml.eval.tournament")
class LearnedPolicy:
"""Policy that uses a trained neural network checkpoint."""
def __init__(self, checkpoint_path: str, device: str = "cpu"):
import torch
import torch.nn.functional as F
self.torch = torch
self.F = F
self.device = device
self.model = None
self.name = "learned"
if os.path.exists(checkpoint_path):
checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)
config = checkpoint.get("model_config", {})
# Try PolicyValueNetwork first (PPO), fall back to PolicyNetwork (supervised)
try:
from ml.training.policy_network import PolicyValueNetwork
self.model = PolicyValueNetwork(
input_dim=config.get("input_dim", STATE_DIMS.total_state_dim),
hidden_dim=config.get("hidden_dim", 512),
num_actions=config.get("num_actions", NUM_ACTIONS),
num_layers=config.get("num_layers", 3),
dropout=config.get("dropout", 0.0),
).to(device)
self.model.load_state_dict(checkpoint["model_state_dict"])
self.name = "learned_ppo"
except (RuntimeError, KeyError):
from ml.training.policy_network import PolicyNetwork
self.model = PolicyNetwork(
input_dim=config.get("input_dim", STATE_DIMS.total_state_dim),
hidden_dim=config.get("hidden_dim", 512),
num_actions=config.get("num_actions", NUM_ACTIONS),
num_layers=config.get("num_layers", 3),
dropout=config.get("dropout", 0.0),
).to(device)
self.model.load_state_dict(checkpoint["model_state_dict"])
self.name = "learned_supervised"
self.model.eval()
logger.info("Loaded checkpoint: %s", checkpoint_path)
else:
logger.warning("Checkpoint not found: %s — will act as random", checkpoint_path)
def select_action(self, state_vec: np.ndarray) -> Tuple[int, float]:
if self.model is None:
action = np.random.randint(0, NUM_ACTIONS)
return action, -np.log(NUM_ACTIONS)
state_tensor = self.torch.from_numpy(state_vec).unsqueeze(0).to(self.device)
with self.torch.no_grad():
if hasattr(self.model, 'forward_with_value'):
logits, _ = self.model.forward_with_value(state_tensor)
else:
logits = self.model(state_tensor)
probs = self.F.softmax(logits, dim=-1)
action = probs.argmax(dim=-1).item() # Greedy for evaluation
log_prob = self.torch.log(probs[0, action]).item()
return action, log_prob
@dataclass
class MatchResult:
"""Result of a single match between two policies."""
policy_a: str
policy_b: str
winner: Optional[str] # Name of winner, or None for draw
steps: int
total_reward_a: float
total_reward_b: float
timeout: bool = False
@dataclass
class TournamentResult:
"""Aggregated tournament results."""
policies: List[str]
match_results: List[MatchResult] = field(default_factory=list)
win_matrix: Dict = field(default_factory=dict)
win_rates: Dict = field(default_factory=dict)
total_matches: int = 0
total_time_s: float = 0.0
def to_dict(self) -> dict:
return {
"policies": self.policies,
"total_matches": self.total_matches,
"total_time_s": round(self.total_time_s, 1),
"win_rates": self.win_rates,
"win_matrix": self.win_matrix,
"matches": [
{
"policy_a": m.policy_a,
"policy_b": m.policy_b,
"winner": m.winner,
"steps": m.steps,
"timeout": m.timeout,
}
for m in self.match_results
],
}
def run_match(
policy_a, name_a: str,
policy_b, name_b: str,
playstyle: str = "midrange",
max_steps: int = 50,
reward_config: RewardConfig = None,
) -> MatchResult:
"""Run a single match between two policies.
policy_a plays as seat 0, policy_b as seat 1.
"""
state = create_random_initial_state()
cfg = reward_config or RewardConfig()
total_reward_a = 0.0
total_reward_b = 0.0
steps = 0
for step in range(max_steps):
if state.game_over:
break
prev_snapshot = state.to_snapshot()
# Policy A's turn (seat 0)
state_vec_a = encode_state_simple(state, agent_seat=0, playstyle=playstyle)
action_a, _ = policy_a.select_action(state_vec_a)
state = apply_action(state, action_a, player_seat=0)
if state.game_over:
steps = step + 1
break
# Policy B's turn (seat 1)
state_vec_b = encode_state_simple(state, agent_seat=1, playstyle=playstyle)
action_b, _ = policy_b.select_action(state_vec_b)
state = apply_action(state, action_b, player_seat=1)
steps = step + 1
if state.game_over:
break
state = advance_turn(state)
# Determine winner
winner = None
timeout = False
if state.game_over:
if state.draw:
winner = None
elif state.timeout:
timeout = True
if state.winner is not None:
winner = name_a if state.winner == 0 else name_b
elif state.winner is not None:
winner = name_a if state.winner == 0 else name_b
else:
# Max steps reached without game ending — check life totals
timeout = True
if state.players[0].life_total > state.players[1].life_total:
winner = name_a
elif state.players[1].life_total > state.players[0].life_total:
winner = name_b
# else draw
return MatchResult(
policy_a=name_a,
policy_b=name_b,
winner=winner,
steps=steps,
total_reward_a=total_reward_a,
total_reward_b=total_reward_b,
timeout=timeout,
)
def run_tournament(
policies: Dict[str, object],
episodes_per_matchup: int = 100,
playstyle: str = "midrange",
) -> TournamentResult:
"""Run a round-robin tournament between all policy pairs.
Args:
policies: Dict mapping policy name → policy object (with select_action method)
episodes_per_matchup: Games per pair (played both sides)
playstyle: Default playstyle
Returns:
TournamentResult with full stats
"""
t_start = time.time()
names = list(policies.keys())
result = TournamentResult(policies=names)
# Initialize win matrix
for name in names:
result.win_matrix[name] = {n: 0 for n in names}
result.win_rates[name] = {"wins": 0, "losses": 0, "draws": 0, "total": 0}
total_matchups = len(list(combinations(names, 2)))
logger.info("Tournament: %d policies, %d matchups, %d games each (×2 sides) = %d total games",
len(names), total_matchups, episodes_per_matchup,
total_matchups * episodes_per_matchup * 2)
logger.info("")
for name_a, name_b in combinations(names, 2):
policy_a = policies[name_a]
policy_b = policies[name_b]
a_wins = 0
b_wins = 0
draws = 0
for ep in range(episodes_per_matchup):
# Play both sides for fairness
# Game 1: A as seat 0, B as seat 1
m1 = run_match(policy_a, name_a, policy_b, name_b, playstyle=playstyle)
result.match_results.append(m1)
if m1.winner == name_a:
a_wins += 1
elif m1.winner == name_b:
b_wins += 1
else:
draws += 1
# Game 2: B as seat 0, A as seat 1
m2 = run_match(policy_b, name_b, policy_a, name_a, playstyle=playstyle)
result.match_results.append(m2)
if m2.winner == name_a:
a_wins += 1
elif m2.winner == name_b:
b_wins += 1
else:
draws += 1
total_games = episodes_per_matchup * 2
result.win_matrix[name_a][name_b] = a_wins
result.win_matrix[name_b][name_a] = b_wins
result.win_rates[name_a]["wins"] += a_wins
result.win_rates[name_a]["losses"] += b_wins
result.win_rates[name_a]["draws"] += draws
result.win_rates[name_a]["total"] += total_games
result.win_rates[name_b]["wins"] += b_wins
result.win_rates[name_b]["losses"] += a_wins
result.win_rates[name_b]["draws"] += draws
result.win_rates[name_b]["total"] += total_games
logger.info(" %s vs %s: %d-%d-%d (%.0f%% vs %.0f%%)",
name_a, name_b, a_wins, b_wins, draws,
a_wins / total_games * 100, b_wins / total_games * 100)
result.total_matches = len(result.match_results)
result.total_time_s = time.time() - t_start
# Compute overall win rates
for name in names:
wr = result.win_rates[name]
total = wr["total"]
if total > 0:
wr["win_rate"] = round(wr["wins"] / total, 4)
else:
wr["win_rate"] = 0.0
return result
def print_tournament_results(result: TournamentResult):
"""Pretty-print tournament results."""
logger.info("")
logger.info("=" * 60)
logger.info(" TOURNAMENT RESULTS")
logger.info("=" * 60)
logger.info("")
# Leaderboard
sorted_policies = sorted(
result.policies,
key=lambda n: result.win_rates[n].get("win_rate", 0),
reverse=True,
)
logger.info(" %-20s %6s %6s %6s %6s %8s", "Policy", "Wins", "Losses", "Draws", "Total", "Win Rate")
logger.info(" " + "-" * 56)
for name in sorted_policies:
wr = result.win_rates[name]
logger.info(" %-20s %6d %6d %6d %6d %7.1f%%",
name, wr["wins"], wr["losses"], wr["draws"], wr["total"],
wr.get("win_rate", 0) * 100)
# Win matrix
logger.info("")
logger.info(" Win Matrix (row beats column):")
header = " %-14s" + " %10s" * len(result.policies)
logger.info(header, "", *result.policies)
for name in result.policies:
row = [result.win_matrix[name].get(n, "-") for n in result.policies]
row_strs = [str(v) if name != n else "-" for v, n in zip(row, result.policies)]
logger.info(" %-14s" + " %10s" * len(row_strs), name, *row_strs)
logger.info("")
logger.info(" Total matches: %d", result.total_matches)
logger.info(" Total time: %.1f s", result.total_time_s)
logger.info("=" * 60)
def main():
"""CLI entry point for tournament evaluation."""
import argparse
parser = argparse.ArgumentParser(description="Commander AI Lab — Tournament Evaluator")
parser.add_argument("--episodes", type=int, default=50,
help="Episodes per matchup pair (played ×2 for both sides)")
parser.add_argument("--checkpoint", default=None,
help="Path to learned policy checkpoint (.pt)")
parser.add_argument("--ppo-checkpoint", default=None,
help="Path to PPO checkpoint (.pt)")
parser.add_argument("--playstyle", default="midrange")
parser.add_argument("--output", default=None,
help="Save results JSON to this path")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(message)s")
# Build policy roster
policies = {
"heuristic": HeuristicPolicy(),
"random": RandomPolicy(),
}
# Add learned policies if checkpoints exist
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
supervised_path = args.checkpoint or os.path.join(ckpt_dir, "best_policy.pt")
if os.path.exists(supervised_path):
policies["supervised"] = LearnedPolicy(supervised_path)
logger.info("Added supervised policy: %s", supervised_path)
ppo_path = args.ppo_checkpoint or os.path.join(ckpt_dir, "best_ppo.pt")
if os.path.exists(ppo_path):
policies["ppo"] = LearnedPolicy(ppo_path)
logger.info("Added PPO policy: %s", ppo_path)
if len(policies) < 2:
logger.error("Need at least 2 policies for a tournament. Train a model first.")
sys.exit(1)
logger.info("")
# Run tournament
result = run_tournament(
policies=policies,
episodes_per_matchup=args.episodes,
playstyle=args.playstyle,
)
print_tournament_results(result)
# Save results
output_path = args.output or os.path.join(ckpt_dir, "tournament_results.json")
with open(output_path, "w", encoding="utf-8") as f:
json.dump(result.to_dict(), f, indent=2)
logger.info("Results saved: %s", output_path)
if __name__ == "__main__":
main()