diff --git a/ml/eval/forge_evaluator.py b/ml/eval/forge_evaluator.py new file mode 100644 index 0000000..94566c2 --- /dev/null +++ b/ml/eval/forge_evaluator.py @@ -0,0 +1,497 @@ +""" +Commander AI Lab — Forge Evaluator (Issue #153) +═════════════════════════════════════════════════ +Reusable eval harness that: + 1. Starts the policy inference server pointing at a given checkpoint + 2. Launches Forge games via MultiThreadBatchRunner with the policy + server wired in as the AI decision-maker + 3. Collects per-game metrics and writes a JSON results file + +Metrics collected per game: + - won : bool + - turns : int + - life_final : int (winning seat final life) + - life_delta : int (winner life - loser life at game end) + - cmd_damage : int (commander damage dealt by policy seat) + - illegal_acts : int (actions rejected by Forge — should be 0) + - entropy_mean : float (mean policy entropy across decision steps) + - inference_ms : float (mean per-step inference latency) + +Usage: + from ml.eval.forge_evaluator import ForgeEvaluator, EvalConfig + cfg = EvalConfig( + checkpoint_path="ml/models/checkpoints/forge-trained/final.pt", + forge_jar="forge/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar", + deck_files=["decks/cedh_test_a.dck", "decks/cedh_test_b.dck"], + num_games=200, + num_threads=4, + run_id="forge-trained", + results_dir="results", + ) + evaluator = ForgeEvaluator(cfg) + summary = evaluator.run() + print(summary) +""" + +import json +import logging +import os +import sys +import time +import uuid +from dataclasses import dataclass, field, asdict +from pathlib import Path +from typing import Dict, List, Optional + +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) + +logger = logging.getLogger("ml.eval.forge_evaluator") + + +# ───────────────────────────────────────────── +# Configuration +# ───────────────────────────────────────────── + +@dataclass +class EvalConfig: + """All parameters needed for a single evaluation run.""" + + # Model + checkpoint_path: str = "ml/models/checkpoints/best_policy.pt" + embeddings_dir: str = "embeddings" + device: str = None # None = auto-detect + + # Forge + forge_jar: str = "forge/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar" + forge_work_dir: str = "forge" + java_path: str = "java" + deck_files: List[str] = field(default_factory=list) + deck_names: List[str] = field(default_factory=list) + policy_server_url: str = "http://localhost:8091" + clock_seconds: int = 120 + + # Run + num_games: int = 200 + num_threads: int = 4 + seed: Optional[int] = None + run_id: str = "" + results_dir: str = "results" + + # Inference + greedy: bool = True + temperature: float = 1.0 + + def __post_init__(self): + if not self.run_id: + self.run_id = str(uuid.uuid4())[:8] + + +# ───────────────────────────────────────────── +# Per-game result +# ───────────────────────────────────────────── + +@dataclass +class GameRecord: + game_index: int + won: bool + turns: int + life_final_policy: int # life total of policy seat at game end + life_final_opponent: int + life_delta: int # policy life - opponent life + commander_damage_dealt: int + illegal_actions: int + entropy_mean: float # mean per-step policy entropy + inference_ms_mean: float # mean per-step inference latency + decision_steps: int # number of decision points in this game + error: str = "" + + +# ───────────────────────────────────────────── +# Eval summary +# ───────────────────────────────────────────── + +@dataclass +class EvalSummary: + run_id: str + checkpoint_path: str + num_games: int + wins: int + losses: int + win_rate: float + win_rate_ci95_low: float + win_rate_ci95_high: float + avg_turns: float + avg_life_delta: float + avg_commander_damage: float + avg_illegal_actions: float + avg_entropy: float + avg_inference_ms: float + total_decision_steps: int + elapsed_wall_s: float + games_per_sec: float + error_count: int + result_file: str + + def __str__(self) -> str: + return ( + f"EvalSummary run={self.run_id}\n" + f" checkpoint : {self.checkpoint_path}\n" + f" games : {self.num_games} ({self.wins}W / {self.losses}L)\n" + f" win rate : {self.win_rate:.1%} " + f"[{self.win_rate_ci95_low:.1%}, {self.win_rate_ci95_high:.1%}] 95% CI\n" + f" avg turns : {self.avg_turns:.1f}\n" + f" avg life Δ : {self.avg_life_delta:+.1f}\n" + f" avg cmd dmg: {self.avg_commander_damage:.1f}\n" + f" illegal : {self.avg_illegal_actions:.2f}/game\n" + f" entropy : {self.avg_entropy:.4f}\n" + f" latency : {self.avg_inference_ms:.1f} ms/step\n" + f" throughput : {self.games_per_sec:.2f} games/sec\n" + f" result file: {self.result_file}\n" + ) + + +# ───────────────────────────────────────────── +# Main evaluator +# ───────────────────────────────────────────── + +class ForgeEvaluator: + """ + Orchestrates a full eval run: + 1. Spin up PolicyInferenceService on cfg.policy_server_url port + 2. Run cfg.num_games Forge games via MultiThreadBatchRunner + 3. Parse Forge JSONL output for per-game metrics + 4. Write results to {results_dir}/eval-{run_id}.json + """ + + def __init__(self, cfg: EvalConfig): + self.cfg = cfg + self._records: List[GameRecord] = [] + self._result_path = os.path.join( + cfg.results_dir, f"eval-{cfg.run_id}.json" + ) + + # ── Public entry point ─────────────────── + + def run(self) -> EvalSummary: + """Run the full evaluation. Returns an EvalSummary.""" + t0 = time.time() + os.makedirs(self.cfg.results_dir, exist_ok=True) + + logger.info("[Eval %s] Starting %d-game eval run", self.cfg.run_id, self.cfg.num_games) + logger.info("[Eval %s] Checkpoint: %s", self.cfg.run_id, self.cfg.checkpoint_path) + + # Load policy service + service = self._load_policy_service() + + # Run games — either via real Forge or synthetic fallback + forge_available = ( + self.cfg.forge_jar + and Path(self.cfg.forge_jar).exists() + and len(self.cfg.deck_files) >= 2 + ) + + if forge_available: + self._records = self._run_forge_games(service) + else: + logger.warning( + "[Eval %s] Forge JAR or decks not found — running synthetic eval.", + self.cfg.run_id + ) + self._records = self._run_synthetic_eval(service) + + elapsed = time.time() - t0 + summary = self._build_summary(elapsed) + self._write_results(summary) + logger.info("[Eval %s] Done. %s", self.cfg.run_id, summary) + return summary + + # ── Policy service setup ───────────────── + + def _load_policy_service(self): + try: + from ml.serving.policy_server import PolicyInferenceService + svc = PolicyInferenceService( + checkpoint_dir=str(Path(self.cfg.checkpoint_path).parent), + embeddings_dir=self.cfg.embeddings_dir, + device=self.cfg.device, + ) + # Override the default checkpoint filename with the exact path + svc.checkpoint_dir = str(Path(self.cfg.checkpoint_path).parent) + ok = svc.load() + if not ok: + logger.warning("[Eval] Policy service load failed — entropy/latency metrics unavailable") + return svc + except Exception as e: + logger.error("[Eval] Could not load PolicyInferenceService: %s", e) + return None + + # ── Real Forge game runner ─────────────── + + def _run_forge_games(self, service) -> List[GameRecord]: + """Run games via MultiThreadBatchRunner + Forge subprocess.""" + try: + from commanderailab.batch.MultiThreadBatchRunner import MultiThreadBatchRunner # type: ignore + from commanderailab.schema.BatchResult import DeckInfo # type: ignore + from commanderailab.ai.AiPolicy import AiPolicy # type: ignore + except ImportError: + logger.warning("[Eval] Java bridge not importable from Python — using JSONL parsing fallback") + return self._run_forge_via_subprocess(service) + + decks = [ + DeckInfo(name=Path(f).stem, file=f) + for f in self.cfg.deck_files[:4] + ] + runner = MultiThreadBatchRunner( + self.cfg.forge_jar, self.cfg.forge_work_dir, + decks, AiPolicy.MIDRANGE, + self.cfg.num_threads, True, self.cfg.clock_seconds, self.cfg.java_path + ) + runner.enableMlLogging(self.cfg.results_dir, f"eval-{self.cfg.run_id}") + runner.warmPool() + + try: + results = runner.runBatch(self.cfg.num_games, self.cfg.seed) + finally: + runner.shutdownPool() + + return self._java_results_to_records(results, service) + + def _run_forge_via_subprocess(self, service) -> List[GameRecord]: + """Parse JSONL files produced by a separately-launched Forge batch.""" + records = [] + results_path = Path(self.cfg.results_dir) + pattern = f"ml-decisions-forge-eval-{self.cfg.run_id}*.jsonl" + + all_files = sorted(results_path.glob(pattern)) + if not all_files: + logger.warning("[Eval] No JSONL files found matching %s — falling back to synthetic", pattern) + return self._run_synthetic_eval(service) + + from ml.training.forge_episode_generator import parse_forge_jsonl, snapshots_to_episode + games_seen: Dict[str, list] = {} + for f in all_files: + for snap in parse_forge_jsonl(f): + gid = snap.get("game_id", "g0") + games_seen.setdefault(gid, []).append(snap) + + for idx, (gid, snaps) in enumerate(games_seen.items()): + records.append(self._snaps_to_record(idx, gid, snaps, service)) + if len(records) >= self.cfg.num_games: + break + + return records + + def _java_results_to_records(self, java_results, service) -> List[GameRecord]: + """Convert Java GameResult objects to GameRecord.""" + records = [] + for jr in java_results: + try: + won = getattr(jr, "winningSeat", 0) == 0 # seat 0 = policy + records.append(GameRecord( + game_index=getattr(jr, "gameIndex", len(records)), + won=won, + turns=getattr(jr, "turns", 0), + life_final_policy=getattr(jr, "lifeAtEnd", [40])[0] if hasattr(jr, "lifeAtEnd") else 40, + life_final_opponent=getattr(jr, "lifeAtEnd", [40, 40])[1] if hasattr(jr, "lifeAtEnd") else 40, + life_delta=0, + commander_damage_dealt=0, + illegal_actions=getattr(jr, "illegalActions", 0), + entropy_mean=0.0, + inference_ms_mean=0.0, + decision_steps=0, + )) + except Exception as e: + logger.warning("[Eval] Skipping malformed GameResult: %s", e) + return records + + # ── Synthetic fallback ─────────────────── + + def _run_synthetic_eval(self, service) -> List[GameRecord]: + """Run synthetic games using self_play engine (no Forge required). + + Used when Forge JAR is missing or deck files aren't configured. + Metrics like illegal_actions and commander_damage are 0 in this mode. + """ + import warnings + warnings.filterwarnings("ignore", category=DeprecationWarning) + + records = [] + try: + from ml.training.self_play import ( + create_random_initial_state, apply_action, advance_turn, + encode_state_simple, HeuristicPolicy + ) + from ml.config.scope import NUM_ACTIONS, IDX_TO_ACTION + import torch + import torch.nn.functional as F + except ImportError as e: + logger.error("[Eval] Cannot import self_play: %s", e) + return [] + + opponent = HeuristicPolicy() + agent_has_model = service is not None and service._loaded + + for game_idx in range(self.cfg.num_games): + state = create_random_initial_state() + entropies, latencies = [], [] + steps = 0 + + while not state.game_over and steps < 60: + sv = encode_state_simple(state, 0, "midrange") + t0 = time.time() + + if agent_has_model: + snap = state.to_snapshot() + result = service.predict( + snap, playstyle="midrange", + greedy=self.cfg.greedy, + temperature=self.cfg.temperature, + ) + action_idx = result.get("action_index", 7) + probs_map = result.get("probabilities", {}) + probs = np.array(list(probs_map.values())) if probs_map else np.ones(NUM_ACTIONS) / NUM_ACTIONS + else: + action_idx = np.random.randint(NUM_ACTIONS) + probs = np.ones(NUM_ACTIONS) / NUM_ACTIONS + + latencies.append((time.time() - t0) * 1000) + p = np.clip(probs, 1e-9, 1.0) + p /= p.sum() + entropies.append(float(-np.sum(p * np.log(p)))) + + state = apply_action(state, action_idx, 0) + if not state.game_over: + opp_sv = encode_state_simple(state, 1, "midrange") + opp_a, _ = opponent.select_action(opp_sv) + state = apply_action(state, opp_a, 1) + if not state.game_over: + state = advance_turn(state) + steps += 1 + + won = state.winner == 0 + lp = state.players[0].life_total + lo = state.players[1].life_total + records.append(GameRecord( + game_index=game_idx, + won=won, + turns=state.turn, + life_final_policy=lp, + life_final_opponent=lo, + life_delta=lp - lo, + commander_damage_dealt=0, + illegal_actions=0, + entropy_mean=float(np.mean(entropies)) if entropies else 0.0, + inference_ms_mean=float(np.mean(latencies)) if latencies else 0.0, + decision_steps=steps, + )) + + return records + + # ── JSONL snapshot → GameRecord ────────── + + def _snaps_to_record(self, idx: int, gid: str, snaps: list, service) -> GameRecord: + entropies, latencies = [], [] + won = False + turns = len(snaps) + cmd_dmg = 0 + illegal = 0 + lp, lo = 40, 40 + + for snap in snaps: + if "outcome" in snap and snap == snaps[-1]: + won = snap["outcome"].get("winner_seat", -1) == 0 + lp = snap["outcome"].get("life_remaining", 40) + lo = snap["outcome"].get("opp_life_remaining", 0) + cmd_dmg = snap["outcome"].get("commander_damage_dealt", 0) + + if service and service._loaded and "state_vector" in snap: + t0 = time.time() + result = service.predict(snap, playstyle="midrange", greedy=self.cfg.greedy) + latencies.append((time.time() - t0) * 1000) + probs_map = result.get("probabilities", {}) + if probs_map: + p = np.clip(list(probs_map.values()), 1e-9, 1.0) + p /= p.sum() + entropies.append(float(-np.sum(p * np.log(p)))) + + return GameRecord( + game_index=idx, + won=won, + turns=turns, + life_final_policy=lp, + life_final_opponent=lo, + life_delta=lp - lo, + commander_damage_dealt=cmd_dmg, + illegal_actions=illegal, + entropy_mean=float(np.mean(entropies)) if entropies else 0.0, + inference_ms_mean=float(np.mean(latencies)) if latencies else 0.0, + decision_steps=len(snaps), + ) + + # ── Summary builder ────────────────────── + + def _build_summary(self, elapsed: float) -> EvalSummary: + n = len(self._records) + if n == 0: + return EvalSummary( + run_id=self.cfg.run_id, + checkpoint_path=self.cfg.checkpoint_path, + num_games=0, wins=0, losses=0, + win_rate=0.0, win_rate_ci95_low=0.0, win_rate_ci95_high=0.0, + avg_turns=0.0, avg_life_delta=0.0, avg_commander_damage=0.0, + avg_illegal_actions=0.0, avg_entropy=0.0, avg_inference_ms=0.0, + total_decision_steps=0, elapsed_wall_s=elapsed, games_per_sec=0.0, + error_count=0, result_file=self._result_path, + ) + + wins = sum(1 for r in self._records if r.won) + losses = n - wins + wr = wins / n + + # Wilson score 95% CI + z = 1.96 + denom = 1 + z**2 / n + centre = (wr + z**2 / (2 * n)) / denom + margin = (z * (wr * (1 - wr) / n + z**2 / (4 * n**2)) ** 0.5) / denom + ci_lo = max(0.0, centre - margin) + ci_hi = min(1.0, centre + margin) + + errors = sum(1 for r in self._records if r.error) + + return EvalSummary( + run_id=self.cfg.run_id, + checkpoint_path=self.cfg.checkpoint_path, + num_games=n, + wins=wins, + losses=losses, + win_rate=wr, + win_rate_ci95_low=ci_lo, + win_rate_ci95_high=ci_hi, + avg_turns=float(np.mean([r.turns for r in self._records])), + avg_life_delta=float(np.mean([r.life_delta for r in self._records])), + avg_commander_damage=float(np.mean([r.commander_damage_dealt for r in self._records])), + avg_illegal_actions=float(np.mean([r.illegal_actions for r in self._records])), + avg_entropy=float(np.mean([r.entropy_mean for r in self._records])), + avg_inference_ms=float(np.mean([r.inference_ms_mean for r in self._records])), + total_decision_steps=sum(r.decision_steps for r in self._records), + elapsed_wall_s=elapsed, + games_per_sec=n / max(elapsed, 0.001), + error_count=errors, + result_file=self._result_path, + ) + + # ── Result writer ──────────────────────── + + def _write_results(self, summary: EvalSummary) -> None: + payload = { + "summary": asdict(summary), + "games": [asdict(r) for r in self._records], + } + os.makedirs(self.cfg.results_dir, exist_ok=True) + with open(self._result_path, "w") as f: + json.dump(payload, f, indent=2) + logger.info("[Eval %s] Results written to %s", self.cfg.run_id, self._result_path) diff --git a/scripts/compare_eval.py b/scripts/compare_eval.py new file mode 100644 index 0000000..97c6ba6 --- /dev/null +++ b/scripts/compare_eval.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python +""" +Commander AI Lab — Compare Eval Results (Issue #153) +═════════════════════════════════════════════════════ +Loads two eval result JSONs produced by eval_policy.py, computes +win-rate delta, 95% CI, and Fisher's exact test for significance. + +Usage: + python scripts/compare_eval.py \ + --baseline results/eval-baseline.json \ + --forge results/eval-forge.json + + # Custom threshold + python scripts/compare_eval.py \ + --baseline results/eval-baseline.json \ + --forge results/eval-forge.json \ + --min-delta 0.05 \ + --alpha 0.05 + +Exit codes: + 0 — Forge model passes (delta >= min-delta AND p < alpha) + 1 — Forge model fails (delta below threshold OR not significant) + 2 — Input error (missing files, bad JSON) +""" + +import argparse +import json +import math +import sys +from pathlib import Path + + +# ───────────────────────────────────────────── +# Statistics helpers +# ───────────────────────────────────────────── + +def wilson_ci(wins: int, n: int, z: float = 1.96): + """Wilson score interval for a proportion.""" + if n == 0: + return 0.0, 0.0 + p = wins / n + denom = 1 + z ** 2 / n + centre = (p + z ** 2 / (2 * n)) / denom + margin = (z * math.sqrt(p * (1 - p) / n + z ** 2 / (4 * n ** 2))) / denom + return max(0.0, centre - margin), min(1.0, centre + margin) + + +def fishers_exact(a_wins, a_n, b_wins, b_n): + """ + Two-tailed Fisher's exact test. + Contingency table: + [[a_wins, a_n - a_wins], + [b_wins, b_n - b_wins]] + + Returns (odds_ratio, p_value). + Uses scipy if available, otherwise a pure-Python hypergeometric + approximation suitable for n >= 50. + """ + try: + from scipy.stats import fisher_exact + table = [ + [a_wins, a_n - a_wins], + [b_wins, b_n - b_wins], + ] + oddsratio, pvalue = fisher_exact(table, alternative="two-sided") + return float(oddsratio), float(pvalue) + except ImportError: + pass + + # Pure-Python fallback: hypergeometric tail sum (two-tailed) + # Works well when all cell counts >= 5 + def _log_comb(n, k): + if k < 0 or k > n: + return -math.inf + return ( + sum(math.log(n - i) for i in range(k)) + - sum(math.log(i + 1) for i in range(k)) + ) + + N = a_n + b_n + K = a_wins + b_wins # total wins + n = a_n # first group size + k_obs = a_wins + + log_denom = _log_comb(N, K) + p_obs = math.exp(_log_comb(n, k_obs) + _log_comb(N - n, K - k_obs) - log_denom) + + p_val = 0.0 + k_lo = max(0, K - b_n) + k_hi = min(K, n) + for k in range(k_lo, k_hi + 1): + p_k = math.exp(_log_comb(n, k) + _log_comb(N - n, K - k) - log_denom) + if p_k <= p_obs + 1e-10: + p_val += p_k + + a_loss = a_n - a_wins + b_loss = b_n - b_wins + or_val = ( + (a_wins * b_loss / (a_loss * b_wins)) + if a_loss > 0 and b_wins > 0 + else float("inf") + ) + return or_val, min(p_val, 1.0) + + +# ───────────────────────────────────────────── +# JSON loader +# ───────────────────────────────────────────── + +def load_result(path: str) -> dict: + p = Path(path) + if not p.exists(): + print(f"ERROR: File not found: {path}", file=sys.stderr) + sys.exit(2) + try: + with open(p) as f: + data = json.load(f) + except json.JSONDecodeError as e: + print(f"ERROR: Invalid JSON in {path}: {e}", file=sys.stderr) + sys.exit(2) + + # Accept both {summary: {...}} and flat {win_rate: ...} shapes + if "summary" in data: + return data["summary"] + return data + + +# ───────────────────────────────────────────── +# Comparison table printer +# ───────────────────────────────────────────── + +def fmt_pct(v) -> str: + try: + return f"{float(v):.1%}" + except (TypeError, ValueError): + return str(v) + + +def fmt_f(v, dp=2) -> str: + try: + return f"{float(v):.{dp}f}" + except (TypeError, ValueError): + return str(v) + + +def print_comparison(b: dict, f: dict, delta: float, ci_lo: float, ci_hi: float, + odds: float, pval: float, min_delta: float, alpha: float): + W = 62 + SEP = "─" * W + + def row(label, bval, fval, delta_str=""): + print(f" {label:<28} {bval:>10} {fval:>10} {delta_str:>8}") + + print() + print("=" * W) + print(" COMMANDER AI LAB — EVAL COMPARISON") + print("=" * W) + print(f" Baseline : {b.get('checkpoint_path', b.get('run_id', '?'))}") + print(f" Forge : {f.get('checkpoint_path', f.get('run_id', '?'))}") + print(SEP) + print(f" {'Metric':<28} {'Baseline':>10} {'Forge':>10} {'Delta':>8}") + print(SEP) + + b_n, f_n = int(b.get("num_games", 0)), int(f.get("num_games", 0)) + b_w, f_w = int(b.get("wins", 0)), int(f.get("wins", 0)) + + row("Games", str(b_n), str(f_n)) + row("Wins", str(b_w), str(f_w), f"{f_w - b_w:+d}") + row("Win rate", fmt_pct(b.get("win_rate")), fmt_pct(f.get("win_rate")), + f"{delta:+.1%}") + + b_lo, b_hi = wilson_ci(b_w, b_n) + f_lo, f_hi = wilson_ci(f_w, f_n) + row(" 95% CI", + f"[{fmt_pct(b_lo)}, {fmt_pct(b_hi)}]", + f"[{fmt_pct(f_lo)}, {fmt_pct(f_hi)}]", + ) + row(" Δ 95% CI", "", "", f"[{ci_lo:+.1%}, {ci_hi:+.1%}]") + print(SEP) + row("Avg turns", + fmt_f(b.get("avg_turns")), + fmt_f(f.get("avg_turns")), + fmt_f(float(f.get("avg_turns", 0)) - float(b.get("avg_turns", 0)), 1)) + row("Avg life Δ", + fmt_f(b.get("avg_life_delta")), + fmt_f(f.get("avg_life_delta")), + fmt_f(float(f.get("avg_life_delta", 0)) - float(b.get("avg_life_delta", 0)), 1)) + row("Avg cmd dmg", + fmt_f(b.get("avg_commander_damage")), + fmt_f(f.get("avg_commander_damage")), + fmt_f(float(f.get("avg_commander_damage", 0)) - float(b.get("avg_commander_damage", 0)), 1)) + row("Illegal acts/g", + fmt_f(b.get("avg_illegal_actions")), + fmt_f(f.get("avg_illegal_actions")), + fmt_f(float(f.get("avg_illegal_actions", 0)) - float(b.get("avg_illegal_actions", 0)), 2)) + row("Avg entropy", + fmt_f(b.get("avg_entropy"), 4), + fmt_f(f.get("avg_entropy"), 4), + fmt_f(float(f.get("avg_entropy", 0)) - float(b.get("avg_entropy", 0)), 4)) + row("Avg infer ms", + fmt_f(b.get("avg_inference_ms"), 1), + fmt_f(f.get("avg_inference_ms"), 1), + fmt_f(float(f.get("avg_inference_ms", 0)) - float(b.get("avg_inference_ms", 0)), 1)) + print(SEP) + + # Significance block + print(f" Fisher's exact test (two-tailed)") + print(f" odds ratio : {odds:.4f}") + print(f" p-value : {pval:.6f}") + print(f" alpha : {alpha}") + print(SEP) + + # Pass / fail verdict + delta_pass = delta >= min_delta + sig_pass = pval < alpha + passed = delta_pass and sig_pass + + if passed: + verdict = "PASS ✓ Forge model outperforms baseline" + elif not delta_pass and not sig_pass: + verdict = f"FAIL ✗ delta ({delta:+.1%}) < {min_delta:.1%} AND not significant (p={pval:.4f})" + elif not delta_pass: + verdict = f"FAIL ✗ delta ({delta:+.1%}) < threshold ({min_delta:.1%})" + else: + verdict = f"FAIL ✗ not statistically significant (p={pval:.4f} >= alpha={alpha})" + + print(f" {verdict}") + print("=" * W) + print() + + +# ───────────────────────────────────────────── +# CLI +# ───────────────────────────────────────────── + +def parse_args(): + p = argparse.ArgumentParser( + description="Compare two eval result JSONs from eval_policy.py.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + p.add_argument("--baseline", required=True, help="Baseline (synthetic) eval JSON") + p.add_argument("--forge", required=True, help="Forge-trained eval JSON") + p.add_argument("--min-delta", type=float, default=0.05, + help="Minimum absolute win-rate improvement to pass") + p.add_argument("--alpha", type=float, default=0.05, + help="Significance level for Fisher's exact test") + p.add_argument("--json-out", default="", + help="Optional: write comparison result to this JSON file") + return p.parse_args() + + +def main() -> int: + args = parse_args() + + b = load_result(args.baseline) + f = load_result(args.forge) + + b_n, f_n = int(b.get("num_games", 0)), int(f.get("num_games", 0)) + b_w, f_w = int(b.get("wins", 0)), int(f.get("wins", 0)) + b_wr = b_w / max(b_n, 1) + f_wr = f_w / max(f_n, 1) + + delta = f_wr - b_wr + + # Delta 95% CI via normal approximation + se_b = math.sqrt(b_wr * (1 - b_wr) / max(b_n, 1)) + se_f = math.sqrt(f_wr * (1 - f_wr) / max(f_n, 1)) + se_delta = math.sqrt(se_b ** 2 + se_f ** 2) + ci_lo = delta - 1.96 * se_delta + ci_hi = delta + 1.96 * se_delta + + odds, pval = fishers_exact(b_w, b_n, f_w, f_n) + + print_comparison(b, f, delta, ci_lo, ci_hi, odds, pval, args.min_delta, args.alpha) + + if args.json_out: + out = { + "baseline": args.baseline, + "forge": args.forge, + "baseline_win_rate": b_wr, + "forge_win_rate": f_wr, + "delta": delta, + "delta_ci95_low": ci_lo, + "delta_ci95_high": ci_hi, + "odds_ratio": odds, + "p_value": pval, + "alpha": args.alpha, + "min_delta": args.min_delta, + "passed": delta >= args.min_delta and pval < args.alpha, + } + Path(args.json_out).parent.mkdir(parents=True, exist_ok=True) + with open(args.json_out, "w") as jf: + json.dump(out, jf, indent=2) + print(f"Comparison JSON written to: {args.json_out}") + + passed = delta >= args.min_delta and pval < args.alpha + return 0 if passed else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/eval_policy.py b/scripts/eval_policy.py new file mode 100644 index 0000000..0a6f142 --- /dev/null +++ b/scripts/eval_policy.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python +""" +Commander AI Lab — Eval Policy Script (Issue #153) +═══════════════════════════════════════════════════ +Run N Forge evaluation games with a given checkpoint and write results. + +Usage: + python scripts/eval_policy.py \ + --model ml/models/checkpoints/forge-trained/final.pt \ + --games 200 \ + --run-id forge-trained \ + --out results/eval-forge.json + + python scripts/eval_policy.py \ + --model ml/models/checkpoints/baseline-synthetic/final.pt \ + --games 200 \ + --run-id baseline-synthetic \ + --out results/eval-baseline.json \ + --threads 4 \ + --seed 42 + +Opts: + --model PATH Path to .pt checkpoint [required] + --games N Number of eval games [default: 200] + --run-id ID Label for this run [default: derived from --model] + --out PATH Output JSON path [default: results/eval-{run-id}.json] + --forge-jar PATH Forge JAR path [default: auto-detect] + --forge-dir DIR Forge working directory [default: forge] + --decks D [D ...] Deck .dck files to use (2-4) [default: first 2 found in decks/] + --threads N Parallel Forge workers [default: 4] + --clock N Per-game clock limit in seconds [default: 120] + --seed N RNG seed for reproducibility + --greedy Use greedy argmax inference (no sampling) + --temperature T Softmax temperature [default: 1.0] + --device DEVICE torch device (cpu, cuda, mps) [default: auto] + --embeddings DIR Embeddings directory [default: embeddings] + --results-dir DIR Results output directory [default: results] +""" + +import argparse +import json +import logging +import os +import sys +from pathlib import Path + +project_root = str(Path(__file__).parent.parent) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-7s %(name)s %(message)s", + datefmt="%H:%M:%S", +) +logger = logging.getLogger("eval_policy") + + +def find_decks(deck_dir: str, n: int = 2) -> list: + """Auto-discover .dck files under deck_dir.""" + p = Path(deck_dir) + if not p.exists(): + return [] + files = sorted(p.glob("**/*.dck"))[:n] + return [str(f) for f in files] + + +def derive_run_id(model_path: str) -> str: + """Derive a readable run ID from the checkpoint path.""" + p = Path(model_path) + # e.g. ml/models/checkpoints/forge-trained/final.pt -> forge-trained + parts = p.parts + if len(parts) >= 2: + return parts[-2] + return p.stem + + +def auto_forge_jar(search_dirs=None) -> str: + """Find the Forge JAR by scanning common locations.""" + candidates = [ + "forge/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar", + "forge/forge-gui-desktop.jar", + "forge.jar", + ] + dirs = search_dirs or [".", "forge", "lib"] + for d in dirs: + for c in candidates: + path = os.path.join(d, c) + if os.path.exists(path): + return path + # Also glob + for jar in Path(".").rglob("forge-gui-desktop*.jar"): + return str(jar) + return "" + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser( + description="Run Forge eval games for a policy checkpoint.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + p.add_argument("--model", required=True, help="Path to .pt checkpoint") + p.add_argument("--games", type=int, default=200) + p.add_argument("--run-id", default="") + p.add_argument("--out", default="") + p.add_argument("--forge-jar", default="") + p.add_argument("--forge-dir", default="forge") + p.add_argument("--decks", nargs="+", default=[]) + p.add_argument("--threads", type=int, default=4) + p.add_argument("--clock", type=int, default=120) + p.add_argument("--seed", type=int, default=None) + p.add_argument("--greedy", action="store_true") + p.add_argument("--temperature", type=float, default=1.0) + p.add_argument("--device", default=None) + p.add_argument("--embeddings", default="embeddings") + p.add_argument("--results-dir", default="results") + return p.parse_args() + + +def main() -> int: + args = parse_args() + + # ── Resolve run ID + run_id = args.run_id or derive_run_id(args.model) + + # ── Resolve output path + out_path = args.out or os.path.join(args.results_dir, f"eval-{run_id}.json") + + # ── Resolve Forge JAR + forge_jar = args.forge_jar or auto_forge_jar() + if not forge_jar: + logger.warning("Forge JAR not found — synthetic fallback will be used.") + + # ── Resolve decks + deck_files = args.decks + if not deck_files: + deck_files = find_decks("decks", n=2) + if not deck_files: + logger.warning("No .dck files found — synthetic fallback will be used.") + + logger.info("=" * 60) + logger.info("Eval run : %s", run_id) + logger.info("Checkpoint: %s", args.model) + logger.info("Games : %d", args.games) + logger.info("Threads : %d", args.threads) + logger.info("Forge JAR : %s", forge_jar or "(not found)") + logger.info("Decks : %s", deck_files or "(none — synthetic)") + logger.info("Output : %s", out_path) + logger.info("=" * 60) + + # ── Import and run evaluator + from ml.eval.forge_evaluator import EvalConfig, ForgeEvaluator + + cfg = EvalConfig( + checkpoint_path=args.model, + embeddings_dir=args.embeddings, + device=args.device, + forge_jar=forge_jar, + forge_work_dir=args.forge_dir, + deck_files=deck_files, + deck_names=[Path(f).stem for f in deck_files], + num_games=args.games, + num_threads=args.threads, + clock_seconds=args.clock, + seed=args.seed, + run_id=run_id, + results_dir=args.results_dir, + greedy=args.greedy, + temperature=args.temperature, + ) + # Override result path to match --out + cfg_out = Path(out_path) + cfg.results_dir = str(cfg_out.parent) + cfg.run_id = cfg_out.stem.replace("eval-", "", 1) if cfg_out.stem.startswith("eval-") else cfg_out.stem + + evaluator = ForgeEvaluator(cfg) + summary = evaluator.run() + + print("\n" + str(summary)) + print(f"Results written to: {out_path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())