Skip to content

Commit f276ed4

Browse files
authored
Merge pull request #78 from CurtisTechSolutions/feat/aec-training-checkpoints
feat(training): resume AEC self-play checkpoints
2 parents 1d9bcfb + 9a75843 commit f276ed4

3 files changed

Lines changed: 240 additions & 0 deletions

File tree

‎examples/aec-checkpoints.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Resuming self-play
2+
3+
`aec(...).get_state()` returns a JSON-compatible checkpoint. Restore it with
4+
`set_state(checkpoint)` in an environment with the same game and configuration.
5+
This preserves the engine, curriculum dataset and RNG, current turn, pending
6+
cumulative rewards, and terminal agents still waiting for their final `step(None)`.
7+
Restoration validates the complete candidate before committing it.
8+
9+
```python
10+
import json
11+
import gamesforai
12+
13+
env = gamesforai.aec("connect4")
14+
env.reset(seed=42)
15+
checkpoint = json.loads(json.dumps(env.get_state()))
16+
resumed = gamesforai.aec("connect4")
17+
resumed.set_state(checkpoint)
18+
```
19+
20+
Checkpoints contain private game state and are for trusted training processes.
21+
Give each policy only `env.observe(agent)` and its own `env.infos[agent]`.
22+
Model weights, optimizer state, policy RNGs, and Gym space sampling RNGs are
23+
owned by the training program and must be saved alongside the environment.
24+
Rendered information is reconstructed from the engine on restore; custom
25+
caller-added `infos` fields are not checkpointed.

‎python/gamesforai/aec_env.py‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import copy
55
import json
6+
import math
67
from typing import Any
78

89
import numpy as np
@@ -37,6 +38,14 @@ def __init__(
3738
"action_mask": spaces.MultiBinary(self.native.action_space_size),
3839
})
3940
self.agents = []
41+
self.rewards = {}
42+
self._cumulative_rewards = {}
43+
self.terminations = {}
44+
self.truncations = {}
45+
self.infos = {}
46+
self.agent_selection = None
47+
self._skip_agent_selection = None
48+
self._initialized = False
4049
self._position_set = None
4150
self._position_id = None
4251
self._rng, _ = seeding.np_random(None)
@@ -74,6 +83,7 @@ def reset(self, seed=None, options=None):
7483
self.native, self._rng = candidate, rng
7584
self._position_set, self._position_id = dataset, position_id
7685
self.agents = self.possible_agents[:]
86+
self._initialized = True
7787
self.rewards = {agent: 0.0 for agent in self.agents}
7888
self._cumulative_rewards = self.rewards.copy()
7989
terminated, truncated = self.native.flags()
@@ -111,6 +121,96 @@ def render(self):
111121
def close(self):
112122
pass
113123

124+
def get_state(self) -> dict[str, Any]:
125+
"""Private, JSON-compatible checkpoint including pending AEC rewards."""
126+
return {
127+
"version": 1,
128+
"native": json.loads(self.native.get_state()),
129+
"rng": copy.deepcopy(self._rng.bit_generator.state),
130+
"curriculum": curriculum.checkpoint(self._position_set, self._position_id),
131+
"initialized": self._initialized,
132+
"agents": self.agents[:],
133+
"agent_selection": self.agent_selection,
134+
"skip_agent_selection": self._skip_agent_selection,
135+
"rewards": self.rewards.copy(),
136+
"cumulative_rewards": self._cumulative_rewards.copy(),
137+
"terminations": self.terminations.copy(),
138+
"truncations": self.truncations.copy(),
139+
}
140+
141+
def set_state(self, state: dict[str, Any]) -> None:
142+
"""Validate a checkpoint completely before replacing any live state."""
143+
required = {
144+
"version", "native", "rng", "curriculum", "initialized", "agents",
145+
"agent_selection", "skip_agent_selection", "rewards",
146+
"cumulative_rewards", "terminations", "truncations",
147+
}
148+
if (
149+
not isinstance(state, dict) or set(state) != required
150+
or type(state["version"]) is not int or state["version"] != 1
151+
or type(state["initialized"]) is not bool
152+
):
153+
raise ValueError("incompatible AEC checkpoint")
154+
agents = state["agents"]
155+
if (
156+
not isinstance(agents, list)
157+
or any(not isinstance(agent, str) or agent not in self._seats for agent in agents)
158+
or agents != [agent for agent in self.possible_agents if agent in agents]
159+
):
160+
raise ValueError("invalid AEC checkpoint agents")
161+
for field in ("rewards", "cumulative_rewards", "terminations", "truncations"):
162+
values = state[field]
163+
if not isinstance(values, dict) or set(values) != set(agents):
164+
raise ValueError("invalid AEC checkpoint bookkeeping")
165+
if field in ("terminations", "truncations"):
166+
if any(type(value) is not bool for value in values.values()):
167+
raise ValueError("invalid AEC checkpoint flags")
168+
elif any(
169+
type(value) not in (int, float) or not math.isfinite(value)
170+
for value in values.values()
171+
):
172+
raise ValueError("invalid AEC checkpoint rewards")
173+
selection, skip = state["agent_selection"], state["skip_agent_selection"]
174+
if any(value is not None and value not in self.possible_agents for value in (selection, skip)):
175+
raise ValueError("invalid AEC checkpoint turn")
176+
candidate = self.native.clone()
177+
candidate.set_state(json.dumps(state["native"]))
178+
terminated, truncated = candidate.flags()
179+
actors = candidate.current_players()
180+
if not state["initialized"]:
181+
if agents or selection is not None or skip is not None:
182+
raise ValueError("uninitialized AEC checkpoint has live bookkeeping")
183+
elif not terminated and not truncated:
184+
if (
185+
agents != self.possible_agents or len(actors) != 1
186+
or selection != self.possible_agents[actors[0]] or skip is not None
187+
):
188+
raise ValueError("AEC checkpoint turn does not match engine")
189+
elif (agents and selection != agents[0]) or (not agents and skip is not None):
190+
raise ValueError("invalid AEC checkpoint dead-agent order")
191+
if any(value != terminated for value in state["terminations"].values()) or any(
192+
value != truncated for value in state["truncations"].values()
193+
):
194+
raise ValueError("AEC checkpoint flags do not match engine")
195+
generator = np.random.default_rng()
196+
generator.bit_generator.state = copy.deepcopy(state["rng"])
197+
dataset, position_id = curriculum.restore(state["curriculum"], candidate)
198+
199+
# Derive information sets from the validated engine, never serialized infos.
200+
restored = copy.copy(self)
201+
restored.native = candidate
202+
restored._rng = generator
203+
restored._position_set, restored._position_id = dataset, position_id
204+
restored._initialized = state["initialized"]
205+
restored.agents = agents[:]
206+
restored.agent_selection, restored._skip_agent_selection = selection, skip
207+
restored.rewards = state["rewards"].copy()
208+
restored._cumulative_rewards = state["cumulative_rewards"].copy()
209+
restored.terminations = state["terminations"].copy()
210+
restored.truncations = state["truncations"].copy()
211+
restored._refresh_infos()
212+
self.__dict__.update(restored.__dict__)
213+
114214
def clone(self):
115215
"""Independent native and AEC bookkeeping state for trusted local search."""
116216
result = copy.copy(self)
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import copy
2+
import json
3+
4+
import numpy as np
5+
import pytest
6+
7+
from gamesforai import aec
8+
9+
10+
def assert_same(left, right):
11+
assert left.get_state() == right.get_state()
12+
assert left.infos == right.infos
13+
if left.agents:
14+
assert left.agent_selection == right.agent_selection
15+
first, second = left.last(), right.last()
16+
np.testing.assert_array_equal(first[0]["observation"], second[0]["observation"])
17+
np.testing.assert_array_equal(first[0]["action_mask"], second[0]["action_mask"])
18+
assert first[1:] == second[1:]
19+
20+
21+
def restore(env, game="tictactoe", config=None):
22+
other = aec(game, config=config)
23+
other.set_state(json.loads(json.dumps(env.get_state())))
24+
assert_same(env, other)
25+
return other
26+
27+
28+
def test_checkpoint_before_reset_and_after_every_live_and_dead_turn():
29+
env = aec("tictactoe")
30+
other = restore(env)
31+
with pytest.raises(ValueError, match="reset"):
32+
other.step(0)
33+
env.reset(seed=42)
34+
for action in [0, 3, 1, 4, 2]:
35+
other = restore(env)
36+
env.step(action)
37+
other.step(action)
38+
assert_same(env, other)
39+
observed = {}
40+
while env.agents:
41+
other = restore(env)
42+
observed[env.agent_selection] = env.last()[1]
43+
env.step(None)
44+
other.step(None)
45+
assert_same(env, other)
46+
assert observed == {"player_0": 1.0, "player_1": -1.0}
47+
restore(env)
48+
49+
50+
def test_truncated_episode_preserves_final_delivery():
51+
env = aec("chess", config={"max_plies": 2})
52+
env.reset(seed=5)
53+
for _ in range(2):
54+
env.step(int(np.flatnonzero(env.last()[0]["action_mask"])[0]))
55+
other = restore(env, "chess", {"max_plies": 2})
56+
while env.agents:
57+
assert env.last()[2:4] == (False, True)
58+
env.step(None)
59+
other.step(None)
60+
assert_same(env, other)
61+
62+
63+
def test_curriculum_rng_and_independent_mutable_checkpoint():
64+
env = aec("connect4")
65+
env.reset(seed=19, options={"position_set": "connect4-solved-positions@1"})
66+
other = restore(env, "connect4")
67+
for _ in range(20):
68+
env.reset()
69+
other.reset()
70+
assert_same(env, other)
71+
state = env.get_state()
72+
state["agents"].clear()
73+
state["rewards"].clear()
74+
state["rng"].clear()
75+
assert env.agents and env.rewards and env.get_state()["rng"]
76+
77+
78+
@pytest.mark.parametrize("field,value", [
79+
("version", True),
80+
("agents", ["player_0", "player_0"]),
81+
("agents", ["player_1"]),
82+
("agent_selection", "player_1"),
83+
("skip_agent_selection", "player_1"),
84+
("rewards", {"player_0": float("nan"), "player_1": 0.0}),
85+
("cumulative_rewards", {}),
86+
("terminations", {"player_0": True, "player_1": True}),
87+
("truncations", {"player_0": 0, "player_1": 0}),
88+
("rng", {}),
89+
("initialized", False),
90+
])
91+
def test_invalid_restore_is_atomic(field, value):
92+
env = aec("tictactoe")
93+
env.reset(seed=7)
94+
before = env.get_state()
95+
invalid = copy.deepcopy(before)
96+
invalid[field] = value
97+
with pytest.raises((ValueError, TypeError)):
98+
env.set_state(invalid)
99+
assert env.get_state() == before
100+
101+
102+
def test_engine_mismatch_and_curriculum_tampering_are_atomic():
103+
env = aec("connect4")
104+
env.reset(seed=8, options={"position_set": "connect4-solved-positions@1"})
105+
before = env.get_state()
106+
bad = copy.deepcopy(before)
107+
bad["curriculum"]["data"] += " "
108+
with pytest.raises(ValueError, match="hash"):
109+
env.set_state(bad)
110+
assert env.get_state() == before
111+
wrong = aec("tictactoe")
112+
wrong.reset(seed=8)
113+
with pytest.raises(ValueError):
114+
env.set_state(wrong.get_state())
115+
assert env.get_state() == before

0 commit comments

Comments
 (0)