Skip to content

Commit b10e3ea

Browse files
authored
Merge pull request #79 from CurtisTechSolutions/fix/gym-checkpoint-opponent-identity
fix(training): bind Gym checkpoints to opponent settings
2 parents f276ed4 + 17e96d2 commit b10e3ea

4 files changed

Lines changed: 115 additions & 3 deletions

File tree

‎examples/gym-checkpoints.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Resuming single-seat training
2+
3+
`GameEnv.get_state()` version 3 binds the learner seat, game/configuration,
4+
built-in opponent name and level, native engine state, environment RNG, and
5+
curriculum. A restore into an environment with a different built-in opponent
6+
fails atomically. Versions 1 and 2 remain loadable with the default random
7+
opponent only, because those formats did not identify an opponent.
8+
9+
Python callbacks, including `ChatPolicy`, use the `python` identity marker.
10+
The trainer must restore the matching model, optimizer, policy RNG and remote
11+
model configuration itself. Environment checkpoints do not contain weights,
12+
API keys, HTTP clients, or external model state. Gym space sampling RNGs are
13+
also caller-owned. Use your trainer's seeded RNG for reproducible action sampling.
14+
15+
Checkpoints contain private engine and curriculum data. Save them in trusted
16+
training storage; policy inputs should use only seat-visible observations.

‎python/gamesforai/gym_env.py‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,8 @@ def clone(self) -> GameEnv:
189189
def get_state(self) -> dict[str, Any]:
190190
"""Trusted checkpoint containing private engine state and environment RNG."""
191191
return {
192-
"version": 2,
192+
"version": 3,
193+
"opponent": self.opponent if isinstance(self.opponent, str) else "python",
193194
"seat": self.seat,
194195
"native": json.loads(self.native.get_state()),
195196
"rng": copy.deepcopy(self.np_random.bit_generator.state),
@@ -200,11 +201,26 @@ def get_state(self) -> dict[str, Any]:
200201

201202
def set_state(self, state: dict[str, Any]) -> None:
202203
"""Atomically restore this wrapper; external policy state is not included."""
204+
if not isinstance(state, dict) or type(state.get("version")) is not int:
205+
raise ValueError("incompatible environment checkpoint")
206+
version = state["version"]
203207
required = {"version", "seat", "native", "rng", "rng_seed", "needs_reset"}
204-
if state.get("version") == 2:
208+
if version in (2, 3):
205209
required.add("curriculum")
206-
if set(state) != required or state["version"] not in (1, 2) or state["seat"] != self.seat:
210+
if version == 3:
211+
required.add("opponent")
212+
if (
213+
set(state) != required or version not in (1, 2, 3)
214+
or type(state["seat"]) is not int or state["seat"] != self.seat
215+
or type(state["rng_seed"]) is not int or state["rng_seed"] < -1
216+
):
207217
raise ValueError("incompatible environment checkpoint")
218+
identity = self.opponent if isinstance(self.opponent, str) else "python"
219+
# Old checkpoints did not bind an opponent; their original default was random.
220+
if (version == 3 and state["opponent"] != identity) or (
221+
version < 3 and identity != "random"
222+
):
223+
raise ValueError("checkpoint opponent differs from this environment")
208224
if not isinstance(state["needs_reset"], bool):
209225
raise ValueError("invalid checkpoint episode flag")
210226
candidate = self.native.clone()

‎python/tests/test_curriculum.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ def test_older_gym_checkpoints_remain_loadable():
5757
snapshot = env.get_state()
5858
snapshot["version"] = 1
5959
del snapshot["curriculum"]
60+
del snapshot["opponent"]
6061
env.set_state(snapshot)
6162
assert env.get_state()["native"] == snapshot["native"]
6263

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import copy
2+
import json
3+
4+
import numpy as np
5+
import pytest
6+
7+
from gamesforai import make
8+
9+
10+
@pytest.mark.parametrize("opponent", ["random", "minimax:1", "minimax:3", "mcts:2"])
11+
def test_resume_repeats_policy_decisions_and_rng(opponent):
12+
env = make("connect4", opponent=opponent)
13+
env.reset(seed=87)
14+
restored = make("connect4", opponent=opponent)
15+
restored.set_state(json.loads(json.dumps(env.get_state())))
16+
for _ in range(4):
17+
action = int(np.flatnonzero(env.action_masks())[0])
18+
first, second = env.step(action), restored.step(action)
19+
np.testing.assert_array_equal(first[0], second[0])
20+
assert first[1:4] == second[1:4]
21+
assert env.get_state() == restored.get_state()
22+
if first[2] or first[3]:
23+
break
24+
25+
26+
@pytest.mark.parametrize("opponent", ["random", "minimax:2", "mcts:3"])
27+
def test_wrong_opponent_is_rejected_without_state_changes(opponent):
28+
original = make("connect4", opponent="minimax:3")
29+
original.reset(seed=3)
30+
other = make("connect4", opponent=opponent)
31+
other.reset(seed=5)
32+
before = other.get_state()
33+
with pytest.raises(ValueError, match="opponent"):
34+
other.set_state(original.get_state())
35+
assert other.get_state() == before
36+
37+
38+
def test_legacy_checkpoints_bind_only_to_random_opponent():
39+
random = make("connect4")
40+
random.reset(seed=81)
41+
legacy = random.get_state()
42+
del legacy["opponent"]
43+
for version in (2, 1):
44+
legacy["version"] = version
45+
if version == 1:
46+
del legacy["curriculum"]
47+
random.set_state(legacy)
48+
search = make("connect4", opponent="minimax:1")
49+
with pytest.raises(ValueError, match="opponent"):
50+
search.set_state(legacy)
51+
52+
53+
@pytest.mark.parametrize("field,value", [
54+
("version", True), ("seat", False), ("rng_seed", True), ("rng_seed", -2),
55+
])
56+
def test_malformed_metadata_is_atomic(field, value):
57+
env = make("connect4")
58+
env.reset(seed=4)
59+
before = env.get_state()
60+
invalid = copy.deepcopy(before)
61+
invalid[field] = value
62+
with pytest.raises(ValueError):
63+
env.set_state(invalid)
64+
assert env.get_state() == before
65+
66+
67+
def test_python_policy_state_remains_explicitly_caller_owned():
68+
def policy(obs, info):
69+
return int(np.flatnonzero(info["action_mask"])[0])
70+
71+
env = make("connect4", opponent=policy)
72+
env.reset(seed=2)
73+
state = env.get_state()
74+
assert state["opponent"] == "python"
75+
restored = make("connect4", opponent=policy)
76+
restored.set_state(state)
77+
assert restored.get_state() == state
78+
with pytest.raises(ValueError, match="opponent"):
79+
make("connect4").set_state(state)

0 commit comments

Comments
 (0)