Skip to content

Commit 42b7c0c

Browse files
authored
Merge pull request #89 from CurtisTechSolutions/feat/tournament-engine-matchups
feat(training): run selected model and engine matchups
2 parents 63d097c + 6d23432 commit 42b7c0c

5 files changed

Lines changed: 127 additions & 8 deletions

File tree

‎.github/scripts/python-stockfish-smoke.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,14 @@
2929
assert truncated and not terminated
3030
assert gym.get_state()["opponent"]["engine_sha256"] == shared.identity()["engine_sha256"]
3131
print("Gym Stockfish: both learner seats complete bounded episodes")
32+
33+
from gamesforai.tournament import Agent, run_round_robin
34+
35+
report = run_round_robin("chess", [
36+
Agent("random", opponent="random"),
37+
Agent("stockfish-five", opponent="stockfish:5", stockfish_pool=shared),
38+
], config={"max_plies": 4}, seed=42)
39+
assert len(report["matches"]) == 2
40+
assert all(match["status"] == "truncated" for match in report["matches"])
41+
assert all(row["rated_games"] == 0 for row in report["standings"])
42+
print("Tournament Stockfish: balanced real-engine games, truncated results unrated")

‎examples/engine-matchups.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Selected model matchups
2+
3+
```python
4+
from gamesforai import StockfishPool
5+
from gamesforai.tournament import Agent, run_round_robin
6+
7+
pool = StockfishPool("/usr/games/stockfish", workers=2)
8+
agents = [Agent("my-model-v1", policy_factory=my_model_factory)] + [
9+
Agent(f"stockfish-{level}", opponent=f"stockfish:{level}", stockfish_pool=pool)
10+
for level in range(1, 9)
11+
]
12+
report = run_round_robin("chess", agents, games_per_pair=100,
13+
matchups=[("my-model-v1", f"stockfish-{level}") for level in range(1, 9)])
14+
```
15+
16+
The explicit schedule avoids engine-versus-engine games when evaluating your
17+
model against a ladder. Each requested pair still alternates seats. Reports pin
18+
the engine binary hash and resource-preset identity alongside the matchups.
19+
Duplicate, self, and unknown-agent pairs are rejected before play.
20+
21+
Stockfish retains the same Linux sandbox requirements and UCI randomness
22+
limitations as the Gym adapter. Its level labels are resource presets until
23+
calibration assigns measured anchor ratings.

‎python/gamesforai/tournament.py‎

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from ._native import NativeEnv
1717
from .positions import PositionSet
1818
from .ratings import Rating
19+
from .stockfish import StockfishPool
1920
from .trajectories import EpisodeRecorder, _time
2021

2122

@@ -32,6 +33,7 @@ class Agent:
3233
opponent: str | None = None
3334
initial_rating: Rating | None = None
3435
fixed: bool = False
36+
stockfish_pool: StockfishPool | None = None
3537

3638
def __post_init__(self):
3739
if not isinstance(self.id, str) or not self.id or len(self.id.encode("utf-8")) > 256:
@@ -48,9 +50,14 @@ def __post_init__(self):
4850
raise ValueError("policy_factory must be callable")
4951
if self.opponent is not None and (
5052
not isinstance(self.opponent, str)
51-
or (self.opponent != "random" and not re.fullmatch(r"(minimax|mcts):([1-9]|10)", self.opponent))
53+
or (self.opponent != "random" and not re.fullmatch(r"(minimax|mcts|stockfish):([1-9]|10)", self.opponent))
5254
):
53-
raise ValueError("opponent must be random, minimax:1..10, or mcts:1..10")
55+
raise ValueError("opponent must be random, minimax:1..10, mcts:1..10, or stockfish:1..10")
56+
is_stockfish = self.opponent is not None and self.opponent.startswith("stockfish:")
57+
if self.stockfish_pool is not None and (not is_stockfish or not isinstance(self.stockfish_pool, StockfishPool)):
58+
raise ValueError("stockfish_pool is only valid for a Stockfish opponent")
59+
if is_stockfish and self.stockfish_pool is None:
60+
object.__setattr__(self, "stockfish_pool", StockfishPool())
5461

5562

5663
def _wilson(wins, games):
@@ -61,12 +68,13 @@ def _wilson(wins, games):
6168
denominator = 1 + z * z / games
6269
center = (p + z * z / (2 * games)) / denominator
6370
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)]
71+
return [0.0 if wins == 0 else max(0.0, center - radius),
72+
1.0 if wins == games else min(1.0, center + radius)]
6573

6674

6775
def run_round_robin(
6876
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,
77+
tau=0.5, run_id=None, created_at=None, on_episode=None, matchups=None,
7078
):
7179
"""Run balanced seats and rate completed games as one Glicko-2 period.
7280
@@ -81,7 +89,21 @@ def run_round_robin(
8189
raise ValueError("agent snapshot ids must be unique")
8290
if type(games_per_pair) is not int or not 2 <= games_per_pair <= 1000 or games_per_pair % 2:
8391
raise ValueError("games_per_pair must be even, 2..1000, for balanced seats")
84-
total = len(agents) * (len(agents) - 1) // 2 * games_per_pair
92+
schedule = list(itertools.combinations(agents, 2))
93+
if matchups is not None:
94+
lookup, seen, schedule = {agent.id: agent for agent in agents}, set(), []
95+
for pair in matchups:
96+
if (
97+
not isinstance(pair, (list, tuple)) or len(pair) != 2
98+
or any(not isinstance(name, str) or name not in lookup for name in pair)
99+
or pair[0] == pair[1] or frozenset(pair) in seen
100+
):
101+
raise ValueError("matchups must name unique pairs of distinct registered agents")
102+
seen.add(frozenset(pair))
103+
schedule.append((lookup[pair[0]], lookup[pair[1]]))
104+
if not schedule:
105+
raise ValueError("matchups must include at least one pair")
106+
total = len(schedule) * games_per_pair
85107
if total > 10000 or type(seed) is not int or not 0 <= seed <= 2**64 - total:
86108
raise ValueError("tournament exceeds 10000 games or the 64-bit seed range")
87109
if isinstance(tau, bool) or not isinstance(tau, (int, float)) or not 0.2 <= tau <= 1.2:
@@ -91,6 +113,8 @@ def run_round_robin(
91113
if prototype.num_players != 2 or spec["turn_structure"] != "sequential":
92114
raise ValueError("round robin currently requires a sequential two-player game")
93115
for agent in agents:
116+
if agent.stockfish_pool is not None and game != "chess":
117+
raise ValueError("Stockfish matchups require chess")
94118
if agent.opponent not in (None, "random") and (
95119
spec["information"] != "perfect" or spec["stochastic"]
96120
):
@@ -112,7 +136,7 @@ def run_round_robin(
112136
totals = {agent.id: {"wins": 0, "draws": 0, "losses": 0, "truncated": 0, "failed": 0,
113137
"invalid_starts": 0, "seats": [0, 0]} for agent in agents}
114138
matches = []
115-
for left, right in itertools.combinations(agents, 2):
139+
for left, right in schedule:
116140
for repetition in range(games_per_pair):
117141
order = (left, right) if repetition % 2 == 0 else (right, left)
118142
match_seed = seed + len(matches)
@@ -154,8 +178,11 @@ def run_round_robin(
154178
action = int(rng.choice(np.flatnonzero(info["action_mask"])))
155179
elif agent.opponent is not None:
156180
algorithm, level = agent.opponent.split(":")
157-
action = episode.builtin_action(seat, algorithm, int(level),
158-
int(rng.integers(0, 2**64, dtype=np.uint64)))
181+
planning_seed = int(rng.integers(0, 2**64, dtype=np.uint64))
182+
if algorithm == "stockfish":
183+
action = episode.stockfish_action(agent.stockfish_pool, seat, int(level), planning_seed)
184+
else:
185+
action = episode.builtin_action(seat, algorithm, int(level), planning_seed)
159186
else:
160187
action = policies[seat](obs, info)
161188
except Exception:
@@ -222,5 +249,10 @@ def run_round_robin(
222249
"seed": str(seed), "created_at": created_at, "games_per_pair": games_per_pair,
223250
"rating_system": {"name": "glicko2", "tau": tau, "period": "whole_tournament"},
224251
"position_set": None if dataset is None else {"id": dataset.identifier, "sha256": dataset.sha256},
252+
"agent_specs": [{
253+
"id": agent.id, "opponent": agent.opponent,
254+
"stockfish": None if agent.stockfish_pool is None else agent.stockfish_pool.identity(),
255+
} for agent in agents],
256+
"matchups": [[left.id, right.id] for left, right in schedule],
225257
"standings": standings, "matches": matches,
226258
}

‎python/gamesforai/trajectories.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,10 @@ def builtin_action(self, seat, algorithm, level, seed):
153153
"""Choose a built-in action through the engine's observation-only planner."""
154154
return self._native.builtin_action(seat, algorithm, level, seed)
155155

156+
def stockfish_action(self, pool, seat, level, seed):
157+
"""Choose through an explicitly configured, host-owned engine pool."""
158+
return pool.choose(self._native, seat, level, seed)
159+
156160
def records(self):
157161
"""Finalized rows with the eventual outcome for each acting seat."""
158162
terminated, truncated = self.flags
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import numpy as np
2+
import pytest
3+
4+
import gamesforai.stockfish as stockfish_module
5+
from gamesforai import StockfishPool
6+
from gamesforai.tournament import Agent, _wilson, run_round_robin
7+
8+
9+
def test_explicit_matchups_skip_unrequested_pairs_and_preserve_balance():
10+
agents = [Agent(name, opponent="random") for name in ["model", "engine-one", "engine-two"]]
11+
report = run_round_robin("tictactoe", agents, games_per_pair=4,
12+
matchups=[("model", "engine-one"), ("model", "engine-two")])
13+
assert len(report["matches"]) == 8
14+
assert all("model" in match["agents"] for match in report["matches"])
15+
stats = {row["agent"]: row for row in report["standings"]}
16+
assert stats["model"]["seats"] == [4, 4]
17+
assert stats["engine-one"]["seats"] == stats["engine-two"]["seats"] == [2, 2]
18+
for invalid in [[], [("model", "missing")], [("model", "model")],
19+
[("model", "engine-one"), ("engine-one", "model")]]:
20+
with pytest.raises(ValueError, match="matchups"):
21+
run_round_robin("tictactoe", agents, matchups=invalid)
22+
23+
24+
def test_stockfish_matchups_record_binary_identity_and_unrated_truncation(tmp_path, monkeypatch):
25+
class Fixture:
26+
def __init__(self, path, workers):
27+
pass
28+
29+
def choose(self, env, seat, level, seed, time_ms):
30+
return int(np.flatnonzero(env.frame(seat)["action_mask"])[0])
31+
32+
monkeypatch.setattr(stockfish_module, "NativeUciPool", Fixture)
33+
path = tmp_path / "engine"
34+
path.write_bytes(b"immutable engine fixture")
35+
pool = StockfishPool(path)
36+
agents = [Agent("model", opponent="random"),
37+
Agent("engine", opponent="stockfish:5", stockfish_pool=pool)]
38+
report = run_round_robin("chess", agents, config={"max_plies": 4})
39+
assert all(match["status"] == "truncated" for match in report["matches"])
40+
assert all(row["rated_games"] == 0 for row in report["standings"])
41+
assert report["agent_specs"][1]["stockfish"] == pool.identity()
42+
with pytest.raises(ValueError, match="chess"):
43+
run_round_robin("connect4", agents)
44+
45+
46+
def test_wilson_interval_exact_probability_endpoints():
47+
for games in [1, 2, 4, 10, 100, 200]:
48+
assert _wilson(0, games)[0] == 0
49+
assert _wilson(games, games)[1] == 1

0 commit comments

Comments
 (0)