Skip to content

Commit 63d097c

Browse files
authored
Merge pull request #88 from CurtisTechSolutions/feat/local-model-tournaments
feat(training): run balanced model snapshot tournaments
2 parents bd91f98 + 9fe9472 commit 63d097c

4 files changed

Lines changed: 364 additions & 0 deletions

File tree

‎examples/model-tournaments.md‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Comparing your model snapshots
2+
3+
```python
4+
from gamesforai import ChatPolicy
5+
from gamesforai.tournament import Agent, run_round_robin
6+
from gamesforai.parquet import write_parquet
7+
8+
agents = [
9+
Agent("my-checkpoint-v1", policy_factory=lambda seed, seat:
10+
ChatPolicy("http://localhost:8000/v1", "my-checkpoint-v1")),
11+
Agent("minimax-level-3", opponent="minimax:3"),
12+
]
13+
report = run_round_robin("connect4", agents, games_per_pair=100, seed=42)
14+
```
15+
16+
For a Python model, return a callable `(observation, info) -> action_index` from
17+
the factory. The factory runs once per game with its seed and seat; reset recurrent
18+
state and policy RNG there. Keep weights frozen during evaluation and give every
19+
checkpoint a distinct stable agent id. Training may run between tournaments.
20+
21+
Schedules alternate seats for every pair. Built-in random/minimax/MCTS policies
22+
are available without model dependencies. A pinned `position_set` can supply
23+
curriculum starts, and its content hash appears in the report.
24+
25+
Every completed, non-truncated game contributes to one tournament-wide Glicko-2
26+
period using pre-tournament opponent ratings. Failures, truncated episodes and
27+
already-finished starts are counted separately and remain unrated. Reports
28+
contain outcomes, rating intervals, win-rate Wilson intervals, seeds and trajectory
29+
hashes. Intervals summarize this schedule; repeated/correlated positions can
30+
make them optimistic. Engine levels remain uncalibrated without measured anchors.
31+
32+
Use `on_episode(summary, rows)` to write completed trajectories to JSONL or
33+
Parquet; the runner retains summaries rather than every episode's tensors.
34+
Sink failures abort the run. Local Python policies run in the trainer process;
35+
they must return promptly. The HTTP adapter has its own socket timeout.
36+
37+
Native policy decisions and datasets repeat with fixed seeds. Full report hashes
38+
also require the same explicit `run_id` and `created_at` values. Remote model
39+
sampling and caller-owned model state are controlled by your model host/trainer.

‎python/gamesforai/tournament.py‎

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
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+
}

‎python/gamesforai/trajectories.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@ def step(self, seat, action, *, reasoning=None, transcript=None):
149149
self._bytes += size
150150
return list(rewards), terminated, truncated
151151

152+
def builtin_action(self, seat, algorithm, level, seed):
153+
"""Choose a built-in action through the engine's observation-only planner."""
154+
return self._native.builtin_action(seat, algorithm, level, seed)
155+
152156
def records(self):
153157
"""Finalized rows with the eventual outcome for each acting seat."""
154158
terminated, truncated = self.flags

‎python/tests/test_tournament.py‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import json
2+
3+
import numpy as np
4+
import pytest
5+
6+
from gamesforai.ratings import Rating
7+
from gamesforai.tournament import Agent, run_round_robin
8+
9+
10+
def run(agents=None, **kwargs):
11+
return run_round_robin("tictactoe", agents or [
12+
Agent("search-v1", opponent="minimax:3"),
13+
Agent("random-v1", opponent="random"),
14+
Agent("random-v2", opponent="random"),
15+
], seed=19, run_id="test-league", created_at="2026-01-01T00:00:00Z", **kwargs)
16+
17+
18+
def test_round_robin_balances_seats_repeats_and_exports_completed_games():
19+
saved = []
20+
first = run(games_per_pair=4, on_episode=lambda summary, rows: saved.append((summary, rows)))
21+
second = run(games_per_pair=4)
22+
assert first == second
23+
assert len(first["matches"]) == len(saved) == 12
24+
assert all(row["seats"] == [4, 4] and row["rated_games"] == 8 for row in first["standings"])
25+
assert all(row["rating"]["games_played"] == 8 for row in first["standings"])
26+
assert sum(row["wins"] for row in first["standings"]) == sum(row["losses"] for row in first["standings"])
27+
for summary, rows in saved:
28+
assert summary["status"] == "completed"
29+
assert rows[-1]["terminated"]
30+
assert all(row["episode_id"] == summary["id"] for row in rows)
31+
json.dumps(first, allow_nan=False)
32+
33+
34+
def test_python_factories_reset_each_game_and_only_receive_policy_inputs():
35+
calls = []
36+
37+
def factory(seed, seat):
38+
calls.append((seed, seat))
39+
rng = np.random.default_rng(seed + seat)
40+
41+
def choose(obs, info):
42+
assert "native" not in info and "state" not in info
43+
return int(rng.choice(np.flatnonzero(info["action_mask"])))
44+
return choose
45+
46+
report = run([Agent("my-model", policy_factory=factory), Agent("random", opponent="random")], games_per_pair=4)
47+
assert calls == [(19, 0), (20, 1), (21, 0), (22, 1)]
48+
assert all(match["status"] == "completed" for match in report["matches"])
49+
50+
51+
def test_failures_and_truncations_are_not_wins_draws_or_rating_updates():
52+
invalid = Agent("bad-policy", policy_factory=lambda seed, seat: lambda obs, info: 999)
53+
report = run([invalid, Agent("random", opponent="random")])
54+
assert all(match["status"] == "failed" for match in report["matches"])
55+
assert all(row["rated_games"] == 0 and row["rating"] == Rating().to_dict() for row in report["standings"])
56+
report = run_round_robin("chess", [Agent("one", opponent="random"), Agent("two", opponent="random")],
57+
config={"max_plies": 2})
58+
assert all(match["status"] == "truncated" for match in report["matches"])
59+
assert all(row["truncated"] == 2 and row["rated_games"] == 0 for row in report["standings"])
60+
61+
62+
def test_fixed_anchor_and_reported_win_rate_interval():
63+
report = run([Agent("anchor", opponent="random", initial_rating=Rating(rating=1800, rd=0), fixed=True),
64+
Agent("learner", opponent="minimax:3")], games_per_pair=4)
65+
anchor = next(row for row in report["standings"] if row["agent"] == "anchor")
66+
assert anchor["rating"]["rating"] == 1800 and anchor["rating"]["rd"] == 0
67+
assert anchor["rating"]["games_played"] == 4
68+
for row in report["standings"]:
69+
low, high = row["win_rate_wilson95"]
70+
assert low <= row["win_rate"] <= high
71+
72+
73+
def test_policy_error_details_are_not_exported_and_sink_failures_propagate():
74+
def fail(seed, seat):
75+
raise RuntimeError("private-model-key")
76+
report = run([Agent("failure", policy_factory=fail), Agent("random", opponent="random")])
77+
assert "private-model-key" not in json.dumps(report)
78+
79+
def sink(summary, rows):
80+
raise RuntimeError("disk full")
81+
with pytest.raises(RuntimeError, match="disk full"):
82+
run(on_episode=sink)
83+
84+
85+
def test_invalid_schedules_and_agent_configs_are_rejected():
86+
with pytest.raises(ValueError):
87+
run(games_per_pair=3)
88+
with pytest.raises(ValueError):
89+
run([Agent("same", opponent="random"), Agent("same", opponent="random")])
90+
with pytest.raises(ValueError):
91+
Agent("anchor", opponent="random", fixed=True)
92+
with pytest.raises(ValueError):
93+
Agent("missing")
94+
with pytest.raises(ValueError):
95+
run_round_robin("sudoku", [Agent("one", opponent="random"), Agent("two", opponent="random")])

0 commit comments

Comments
 (0)