|
| 1 | +"""Reproducible local round robins between frozen model snapshots.""" |
| 2 | +from __future__ import annotations |
| 3 | + |
| 4 | +import hashlib |
| 5 | +import itertools |
| 6 | +import json |
| 7 | +import math |
| 8 | +import re |
| 9 | +import uuid |
| 10 | +from dataclasses import dataclass |
| 11 | +from datetime import datetime, timezone |
| 12 | +from typing import Callable |
| 13 | + |
| 14 | +import numpy as np |
| 15 | + |
| 16 | +from ._native import NativeEnv |
| 17 | +from .positions import PositionSet |
| 18 | +from .ratings import Rating |
| 19 | +from .trajectories import EpisodeRecorder, _time |
| 20 | + |
| 21 | + |
| 22 | +@dataclass(frozen=True) |
| 23 | +class Agent: |
| 24 | + """One immutable snapshot identity and a fresh per-game policy factory. |
| 25 | +
|
| 26 | + The factory receives (seed, seat) and returns a policy(observation, info). |
| 27 | + Alternatively opponent selects random or native minimax/mcts levels. |
| 28 | + """ |
| 29 | + |
| 30 | + id: str |
| 31 | + policy_factory: Callable | None = None |
| 32 | + opponent: str | None = None |
| 33 | + initial_rating: Rating | None = None |
| 34 | + fixed: bool = False |
| 35 | + |
| 36 | + def __post_init__(self): |
| 37 | + if not isinstance(self.id, str) or not self.id or len(self.id.encode("utf-8")) > 256: |
| 38 | + raise ValueError("agent id must be nonempty text within 256 bytes") |
| 39 | + if type(self.fixed) is not bool or (self.fixed and self.initial_rating is None): |
| 40 | + raise ValueError("fixed anchors require an explicit calibrated initial_rating") |
| 41 | + if self.initial_rating is not None and not isinstance(self.initial_rating, Rating): |
| 42 | + raise ValueError("initial_rating must be a Rating") |
| 43 | + if self.initial_rating is not None and self.initial_rating.rd == 0 and not self.fixed: |
| 44 | + raise ValueError("zero rating deviation requires a fixed calibrated anchor") |
| 45 | + if (self.policy_factory is None) == (self.opponent is None): |
| 46 | + raise ValueError("choose a policy factory or built-in opponent") |
| 47 | + if self.policy_factory is not None and not callable(self.policy_factory): |
| 48 | + raise ValueError("policy_factory must be callable") |
| 49 | + if self.opponent is not None and ( |
| 50 | + not isinstance(self.opponent, str) |
| 51 | + or (self.opponent != "random" and not re.fullmatch(r"(minimax|mcts):([1-9]|10)", self.opponent)) |
| 52 | + ): |
| 53 | + raise ValueError("opponent must be random, minimax:1..10, or mcts:1..10") |
| 54 | + |
| 55 | + |
| 56 | +def _wilson(wins, games): |
| 57 | + if games == 0: |
| 58 | + return None |
| 59 | + z = 1.959963984540054 |
| 60 | + p = wins / games |
| 61 | + denominator = 1 + z * z / games |
| 62 | + center = (p + z * z / (2 * games)) / denominator |
| 63 | + radius = z * math.sqrt(p * (1 - p) / games + z * z / (4 * games * games)) / denominator |
| 64 | + return [max(0.0, center - radius), min(1.0, center + radius)] |
| 65 | + |
| 66 | + |
| 67 | +def run_round_robin( |
| 68 | + game, agents, *, games_per_pair=2, seed=42, config=None, position_set=None, |
| 69 | + tau=0.5, run_id=None, created_at=None, on_episode=None, |
| 70 | +): |
| 71 | + """Run balanced seats and rate completed games as one Glicko-2 period. |
| 72 | +
|
| 73 | + Truncated, failed and already-finished starts are reported but never rated. |
| 74 | + Optional on_episode(match_summary, records) receives completed trajectories. |
| 75 | + A sink error aborts the run so a storage failure cannot silently lose data. |
| 76 | + """ |
| 77 | + agents = tuple(agents) |
| 78 | + if not 2 <= len(agents) <= 32 or any(not isinstance(agent, Agent) for agent in agents): |
| 79 | + raise ValueError("a tournament requires 2..32 Agent snapshots") |
| 80 | + if len({agent.id for agent in agents}) != len(agents): |
| 81 | + raise ValueError("agent snapshot ids must be unique") |
| 82 | + if type(games_per_pair) is not int or not 2 <= games_per_pair <= 1000 or games_per_pair % 2: |
| 83 | + raise ValueError("games_per_pair must be even, 2..1000, for balanced seats") |
| 84 | + total = len(agents) * (len(agents) - 1) // 2 * games_per_pair |
| 85 | + if total > 10000 or type(seed) is not int or not 0 <= seed <= 2**64 - total: |
| 86 | + raise ValueError("tournament exceeds 10000 games or the 64-bit seed range") |
| 87 | + if isinstance(tau, bool) or not isinstance(tau, (int, float)) or not 0.2 <= tau <= 1.2: |
| 88 | + raise ValueError("tau must be 0.2..1.2") |
| 89 | + prototype = NativeEnv(game, json.dumps(config or {}), seed) |
| 90 | + spec = json.loads(prototype.spec_json()) |
| 91 | + if prototype.num_players != 2 or spec["turn_structure"] != "sequential": |
| 92 | + raise ValueError("round robin currently requires a sequential two-player game") |
| 93 | + for agent in agents: |
| 94 | + if agent.opponent not in (None, "random") and ( |
| 95 | + spec["information"] != "perfect" or spec["stochastic"] |
| 96 | + ): |
| 97 | + raise ValueError("native search requires a deterministic perfect-information game") |
| 98 | + dataset = PositionSet.load(position_set) if isinstance(position_set, str) else position_set |
| 99 | + if dataset is not None: |
| 100 | + if not isinstance(dataset, PositionSet): |
| 101 | + raise ValueError("position_set must be a pinned name or PositionSet") |
| 102 | + dataset.validate_for(prototype) |
| 103 | + run_id = run_id or str(uuid.uuid4()) |
| 104 | + if not isinstance(run_id, str) or not run_id or len(run_id.encode("utf-8")) > 200: |
| 105 | + raise ValueError("run_id must be nonempty text within 200 bytes") |
| 106 | + created_at = created_at or datetime.now(timezone.utc).isoformat() |
| 107 | + _time(created_at) |
| 108 | + if on_episode is not None and not callable(on_episode): |
| 109 | + raise ValueError("on_episode must be callable") |
| 110 | + priors = {agent.id: agent.initial_rating or Rating() for agent in agents} |
| 111 | + periods = {agent.id: [] for agent in agents} |
| 112 | + totals = {agent.id: {"wins": 0, "draws": 0, "losses": 0, "truncated": 0, "failed": 0, |
| 113 | + "invalid_starts": 0, "seats": [0, 0]} for agent in agents} |
| 114 | + matches = [] |
| 115 | + for left, right in itertools.combinations(agents, 2): |
| 116 | + for repetition in range(games_per_pair): |
| 117 | + order = (left, right) if repetition % 2 == 0 else (right, left) |
| 118 | + match_seed = seed + len(matches) |
| 119 | + rng = np.random.default_rng(match_seed) |
| 120 | + entry = None if dataset is None else dataset.sample(rng) |
| 121 | + episode = EpisodeRecorder( |
| 122 | + game, [agent.id for agent in order], config=config, seed=match_seed, |
| 123 | + position=None if entry is None else entry.position, |
| 124 | + episode_id=f"{run_id}:{len(matches)}", created_at=created_at, |
| 125 | + ratings=[priors[agent.id].rating for agent in order], |
| 126 | + ) |
| 127 | + match = { |
| 128 | + "id": f"{run_id}:{len(matches)}", "seed": str(match_seed), |
| 129 | + "agents": [agent.id for agent in order], "turns": 0, |
| 130 | + "status": "completed", "failure": None, "returns": None, |
| 131 | + "position_id": None if entry is None else entry.id, |
| 132 | + } |
| 133 | + for seat, agent in enumerate(order): |
| 134 | + totals[agent.id]["seats"][seat] += 1 |
| 135 | + policies = [None, None] |
| 136 | + if any(episode.flags): |
| 137 | + match["status"] = "invalid_start" |
| 138 | + else: |
| 139 | + for seat, agent in enumerate(order): |
| 140 | + if agent.policy_factory is not None: |
| 141 | + try: |
| 142 | + policies[seat] = agent.policy_factory(match_seed, seat) |
| 143 | + if not callable(policies[seat]): |
| 144 | + raise TypeError("factory did not return a policy") |
| 145 | + except Exception: |
| 146 | + match["status"], match["failure"] = "failed", {"agent": agent.id, "code": "policy_factory"} |
| 147 | + break |
| 148 | + while match["status"] == "completed" and episode.current_players: |
| 149 | + seat = episode.current_players[0] |
| 150 | + agent = order[seat] |
| 151 | + obs, info = episode.observation(seat) |
| 152 | + try: |
| 153 | + if agent.opponent == "random": |
| 154 | + action = int(rng.choice(np.flatnonzero(info["action_mask"]))) |
| 155 | + elif agent.opponent is not None: |
| 156 | + algorithm, level = agent.opponent.split(":") |
| 157 | + action = episode.builtin_action(seat, algorithm, int(level), |
| 158 | + int(rng.integers(0, 2**64, dtype=np.uint64))) |
| 159 | + else: |
| 160 | + action = policies[seat](obs, info) |
| 161 | + except Exception: |
| 162 | + match["status"], match["failure"] = "failed", {"agent": agent.id, "code": "policy_error"} |
| 163 | + break |
| 164 | + if ( |
| 165 | + isinstance(action, (bool, np.bool_)) or not isinstance(action, (int, np.integer)) |
| 166 | + or not 0 <= action < len(info["action_mask"]) or not info["action_mask"][action] |
| 167 | + ): |
| 168 | + match["status"], match["failure"] = "failed", {"agent": agent.id, "code": "illegal_action"} |
| 169 | + break |
| 170 | + try: |
| 171 | + episode.step(seat, int(action)) |
| 172 | + except Exception: |
| 173 | + match["status"], match["failure"] = "failed", {"agent": agent.id, "code": "episode_error"} |
| 174 | + break |
| 175 | + match["turns"] += 1 |
| 176 | + if match["status"] == "completed": |
| 177 | + match["returns"] = episode.returns |
| 178 | + if episode.flags[1]: |
| 179 | + match["status"] = "truncated" |
| 180 | + else: |
| 181 | + first, second = episode.returns |
| 182 | + score = 1.0 if first > second else 0.0 if first < second else 0.5 |
| 183 | + for seat, value in enumerate((score, 1.0 - score)): |
| 184 | + agent, opponent = order[seat], order[1 - seat] |
| 185 | + periods[agent.id].append((priors[opponent.id], value)) |
| 186 | + totals[agent.id]["wins" if value == 1 else "losses" if value == 0 else "draws"] += 1 |
| 187 | + rows = episode.records() |
| 188 | + encoded = json.dumps(rows, sort_keys=True, separators=(",", ":"), allow_nan=False).encode() |
| 189 | + match["trajectory_sha256"] = hashlib.sha256(encoded).hexdigest() |
| 190 | + if on_episode is not None: |
| 191 | + on_episode(dict(match), rows) |
| 192 | + for agent in order: |
| 193 | + if match["status"] != "completed": |
| 194 | + field = "invalid_starts" if match["status"] == "invalid_start" else match["status"] |
| 195 | + totals[agent.id][field] += 1 |
| 196 | + matches.append(match) |
| 197 | + |
| 198 | + standings = [] |
| 199 | + for agent in agents: |
| 200 | + prior, results, stats = priors[agent.id], periods[agent.id], totals[agent.id] |
| 201 | + # No eligible games is an unchanged tournament, not an implicit inactive time period. |
| 202 | + if not results: |
| 203 | + updated = prior |
| 204 | + elif agent.fixed: |
| 205 | + updated = Rating(**{**prior.to_dict(), "games_played": prior.games_played + len(results)}) |
| 206 | + else: |
| 207 | + updated = prior.update(results, tau=tau) |
| 208 | + games = len(results) |
| 209 | + standings.append({ |
| 210 | + "agent": agent.id, **stats, "rated_games": games, "fixed": agent.fixed, |
| 211 | + "initial_rating": prior.to_dict(), "rating": updated.to_dict(), |
| 212 | + "rating_interval95": list(updated.interval95), |
| 213 | + "win_rate": None if games == 0 else stats["wins"] / games, |
| 214 | + "win_rate_wilson95": _wilson(stats["wins"], games), |
| 215 | + "score": None if games == 0 else (stats["wins"] + 0.5 * stats["draws"]) / games, |
| 216 | + }) |
| 217 | + standings.sort(key=lambda row: (-row["rating"]["rating"], row["agent"])) |
| 218 | + return { |
| 219 | + "format_version": 1, "run_id": run_id, "game": game, |
| 220 | + "engine_version": spec["engine_version"], |
| 221 | + "config": json.loads(prototype.get_state())["config"], |
| 222 | + "seed": str(seed), "created_at": created_at, "games_per_pair": games_per_pair, |
| 223 | + "rating_system": {"name": "glicko2", "tau": tau, "period": "whole_tournament"}, |
| 224 | + "position_set": None if dataset is None else {"id": dataset.identifier, "sha256": dataset.sha256}, |
| 225 | + "standings": standings, "matches": matches, |
| 226 | + } |
0 commit comments