Skip to content

Commit 8b72c97

Browse files
authored
Merge pull request #69 from CurtisTechSolutions/feat/python-position-curricula
feat(training): sample reproducible position curricula in Gymnasium and AEC
2 parents ad8ddc0 + 1b5f1f2 commit 8b72c97

5 files changed

Lines changed: 213 additions & 20 deletions

File tree

‎python/README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,9 @@ of numeric output per batch. Throughput still needs measurement against the PRD
9898
## Readable actions for model policies
9999

100100
Native frames include `legal_actions` as `(index, canonical_notation)` pairs in stable notation order. Gymnasium info includes the same catalog plus the game ID, rules, and action notation for learner and opponent policies. The catalog contains only actions available to that seat and matches its numeric mask. `NativeEnv.spec_json()` returns the static game contract. Vector batches keep their numeric-only output.
101+
102+
## Sampling a curriculum on every reset
103+
104+
Use `env.reset(seed=42, options={"position_set": "chess-endgames-basic@1"})` with either Gymnasium or AEC. Later `reset()` calls keep sampling that pinned set; repeating an explicit seed repeats the selection. Supply a custom `PositionSet` instead of a bundled name, or use `{"position_set": None}` to return to initial positions. An explicit `position` also clears the active curriculum. A set must match the environment game and normalized configuration.
105+
106+
`info["curriculum"]` records only the set ID, content hash, and selected position ID. Expected puzzle answers are excluded. Gymnasium checkpoints include the pinned dataset and RNG continuation; AEC clones preserve both. Invalid selections, imported positions, and failed opponent openings preserve environment and RNG state. A caller-owned opponent model must manage its own mutable state.

‎python/gamesforai/aec_env.py‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from pettingzoo import AECEnv
1212

1313
from ._native import NativeEnv
14+
from . import curriculum
1415

1516

1617
class AECGameEnv(AECEnv):
@@ -36,6 +37,8 @@ def __init__(
3637
"action_mask": spaces.MultiBinary(self.native.action_space_size),
3738
})
3839
self.agents = []
40+
self._position_set = None
41+
self._position_id = None
3942
self._rng, _ = seeding.np_random(None)
4043

4144
def observation_space(self, agent):
@@ -50,23 +53,26 @@ def observe(self, agent):
5053

5154
def _refresh_infos(self):
5255
self.infos = {
53-
agent: {"seat": self._seats[agent], "text": self.native.frame(self._seats[agent])["text"]}
56+
agent: {"seat": self._seats[agent], "text": self.native.frame(self._seats[agent])["text"],
57+
**curriculum.origin_info(self._position_set, self._position_id)}
5458
for agent in self.agents
5559
}
5660

5761
def reset(self, seed=None, options=None):
5862
options = options or {}
5963
# PettingZoo callers may pass options intended for other wrappers.
60-
position = options.get("position")
61-
if position is not None and not isinstance(position, str):
62-
raise ValueError("position must be a notation string")
63-
if seed is not None:
64-
self._rng, _ = seeding.np_random(seed)
65-
native_seed = int(self._rng.integers(0, 2**64, dtype=np.uint64)) if seed is None else seed
66-
self.native.reset(native_seed, position)
67-
actors = self.native.current_players()
64+
rng = copy.deepcopy(self._rng) if seed is None else seeding.np_random(seed)[0]
65+
position, dataset, position_id = curriculum.resolve_start(
66+
self.native, options, rng, self._position_set
67+
)
68+
native_seed = int(rng.integers(0, 2**64, dtype=np.uint64)) if seed is None else seed
69+
candidate = self.native.clone()
70+
candidate.reset(native_seed, position)
71+
actors = candidate.current_players()
6872
if len(actors) > 1:
6973
raise ValueError("AEC adapter currently requires sequential turns")
74+
self.native, self._rng = candidate, rng
75+
self._position_set, self._position_id = dataset, position_id
7076
self.agents = self.possible_agents[:]
7177
self.rewards = {agent: 0.0 for agent in self.agents}
7278
self._cumulative_rewards = self.rewards.copy()

‎python/gamesforai/curriculum.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Shared reset selection; puzzle answers never enter policy observations."""
2+
from .positions import PositionSet
3+
4+
5+
def resolve_start(native, options, rng, previous):
6+
if "position" in options and "position_set" in options:
7+
raise ValueError("choose position or position_set, not both")
8+
position = options.get("position")
9+
if position is not None and not isinstance(position, str):
10+
raise ValueError("position must be a notation string")
11+
dataset = None if "position" in options else previous
12+
if "position_set" in options:
13+
dataset = options["position_set"]
14+
if isinstance(dataset, str):
15+
dataset = PositionSet.load(dataset)
16+
if dataset is not None and not isinstance(dataset, PositionSet):
17+
raise ValueError("position_set must be name@version, a PositionSet, or None")
18+
if dataset is not None:
19+
dataset.validate_for(native)
20+
entry = dataset.sample(rng)
21+
return entry.position, dataset, entry.id
22+
return position, None, None
23+
24+
25+
def origin_info(dataset, position_id):
26+
if dataset is None:
27+
return {}
28+
return {"curriculum": {
29+
"position_set": dataset.identifier,
30+
"sha256": dataset.sha256,
31+
"position_id": position_id,
32+
}}
33+
34+
35+
def checkpoint(dataset, position_id):
36+
if dataset is None:
37+
return None
38+
return {**dataset.checkpoint(), "position_id": position_id}
39+
40+
41+
def restore(payload, native):
42+
if payload is None:
43+
return None, None
44+
if not isinstance(payload, dict) or set(payload) != {"manifest", "data", "position_id"}:
45+
raise ValueError("invalid curriculum checkpoint")
46+
dataset = PositionSet.from_jsonl(payload["manifest"], payload["data"])
47+
dataset.validate_for(native)
48+
if payload["position_id"] not in {entry.id for entry in dataset.entries}:
49+
raise ValueError("curriculum checkpoint position is not in the pinned set")
50+
return dataset, payload["position_id"]

‎python/gamesforai/gym_env.py‎

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from gymnasium.error import ResetNeeded
1313

1414
from ._native import NativeEnv
15+
from . import curriculum
1516

1617
Policy = Callable[[np.ndarray, dict[str, Any]], int]
1718

@@ -51,6 +52,8 @@ def __init__(
5152
-np.inf, np.inf, shape=frame["observation"].shape, dtype=np.float32
5253
)
5354
self._needs_reset = True
55+
self._position_set = None
56+
self._position_id = None
5457

5558
def _frame(self, seat: int) -> tuple[np.ndarray, dict[str, Any]]:
5659
frame = self.native.frame(seat)
@@ -67,6 +70,7 @@ def _frame(self, seat: int) -> tuple[np.ndarray, dict[str, Any]]:
6770
"to_act": self.native.current_players(),
6871
"terminated": terminated,
6972
"truncated": truncated,
73+
**curriculum.origin_info(self._position_set, self._position_id),
7074
}
7175
return frame["observation"], info
7276

@@ -97,15 +101,29 @@ def reset(
97101
self, *, seed: int | None = None, options: dict[str, Any] | None = None
98102
) -> tuple[np.ndarray, dict[str, Any]]:
99103
options = options or {}
100-
if set(options) - {"position"}:
101-
raise ValueError("supported reset option: position")
102-
position = options.get("position")
103-
if position is not None and not isinstance(position, str):
104-
raise ValueError("position must be a notation string")
105-
super().reset(seed=seed)
106-
native_seed = int(self.np_random.integers(0, 2**64, dtype=np.uint64)) if seed is None else seed
107-
self.native.reset(native_seed, position)
108-
self._opponents()
104+
if set(options) - {"position", "position_set"}:
105+
raise ValueError("supported reset options: position, position_set")
106+
saved_native = self.native
107+
saved_curriculum = self._position_set, self._position_id
108+
saved_rng = copy.deepcopy(self._np_random)
109+
saved_rng_seed = self._np_random_seed
110+
try:
111+
super().reset(seed=seed)
112+
position, dataset, position_id = curriculum.resolve_start(
113+
self.native, options, self.np_random, self._position_set
114+
)
115+
native_seed = int(self.np_random.integers(0, 2**64, dtype=np.uint64)) if seed is None else seed
116+
self.native = self.native.clone()
117+
self.native.reset(native_seed, position)
118+
self._position_set, self._position_id = dataset, position_id
119+
self._opponents()
120+
except Exception:
121+
self.native = saved_native
122+
self._position_set, self._position_id = saved_curriculum
123+
self._np_random = saved_rng
124+
self._np_random_seed = saved_rng_seed
125+
raise
126+
self._position_set, self._position_id = dataset, position_id
109127
self._needs_reset = any(self.native.flags())
110128
return self._frame(self.seat)
111129

@@ -148,31 +166,36 @@ def clone(self) -> GameEnv:
148166
def get_state(self) -> dict[str, Any]:
149167
"""Trusted checkpoint containing private engine state and environment RNG."""
150168
return {
151-
"version": 1,
169+
"version": 2,
152170
"seat": self.seat,
153171
"native": json.loads(self.native.get_state()),
154172
"rng": copy.deepcopy(self.np_random.bit_generator.state),
155173
"rng_seed": self.np_random_seed,
156174
"needs_reset": self._needs_reset,
175+
"curriculum": curriculum.checkpoint(self._position_set, self._position_id),
157176
}
158177

159178
def set_state(self, state: dict[str, Any]) -> None:
160179
"""Atomically restore this wrapper; external policy state is not included."""
161180
required = {"version", "seat", "native", "rng", "rng_seed", "needs_reset"}
162-
if set(state) != required or state["version"] != 1 or state["seat"] != self.seat:
181+
if state.get("version") == 2:
182+
required.add("curriculum")
183+
if set(state) != required or state["version"] not in (1, 2) or state["seat"] != self.seat:
163184
raise ValueError("incompatible environment checkpoint")
164185
if not isinstance(state["needs_reset"], bool):
165186
raise ValueError("invalid checkpoint episode flag")
166187
candidate = self.native.clone()
167188
candidate.set_state(json.dumps(state["native"]))
168189
if any(candidate.flags()) and not state["needs_reset"]:
169190
raise ValueError("finished checkpoint cannot be marked active")
191+
dataset, position_id = curriculum.restore(state.get("curriculum"), candidate)
170192
generator = np.random.default_rng()
171193
generator.bit_generator.state = copy.deepcopy(state["rng"])
172194
self.native = candidate
173195
self._np_random = generator
174196
self._np_random_seed = state["rng_seed"]
175197
self._needs_reset = state["needs_reset"]
198+
self._position_set, self._position_id = dataset, position_id
176199

177200

178201
def make(game: str, **kwargs: Any) -> GameEnv:

‎python/tests/test_curriculum.py‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import copy
2+
import json
3+
4+
import numpy as np
5+
import pytest
6+
7+
from gamesforai import PositionSet, aec, make
8+
9+
10+
def test_gym_curriculum_repeats_seed_and_continues_rng_after_restore():
11+
env = make("connect4")
12+
first, info = env.reset(seed=82, options={"position_set": "connect4-solved-positions@1"})
13+
origin = info["curriculum"]
14+
assert set(origin) == {"position_set", "sha256", "position_id"}
15+
assert "expected" not in json.dumps(info, default=lambda array: array.tolist())
16+
second, second_info = env.reset(seed=82)
17+
np.testing.assert_array_equal(first, second)
18+
assert second_info["curriculum"] == origin
19+
snapshot = env.get_state()
20+
continuation = [env.reset()[1]["curriculum"]["position_id"] for _ in range(20)]
21+
env.set_state(snapshot)
22+
assert continuation == [env.reset()[1]["curriculum"]["position_id"] for _ in range(20)]
23+
env.set_state(snapshot)
24+
clone = env.clone()
25+
assert env.reset()[1]["curriculum"] == clone.reset()[1]["curriculum"]
26+
assert "curriculum" not in env.reset(options={"position_set": None})[1]
27+
28+
29+
def test_curriculum_config_and_invalid_reset_preserve_checkpoint():
30+
env = make("connect4")
31+
env.reset(seed=4)
32+
before = env.get_state()
33+
for options in [
34+
{"position_set": "chess-endgames-basic@1"},
35+
{"position": "bad"},
36+
{"position": "bad", "position_set": "connect4-solved-positions@1"},
37+
]:
38+
with pytest.raises(ValueError):
39+
env.reset(seed=999, options=options)
40+
assert env.get_state() == before
41+
env.reset(seed=4, options={"position_set": PositionSet.load("connect4-solved-positions@1")})
42+
before = env.get_state()
43+
broken = copy.deepcopy(before)
44+
broken["curriculum"]["data"] += " "
45+
with pytest.raises(ValueError, match="hash"):
46+
env.set_state(broken)
47+
assert env.get_state() == before
48+
with pytest.raises(ValueError, match="config"):
49+
make("chess", config={"max_plies": 8}).reset(
50+
seed=1, options={"position_set": "chess-endgames-basic@1"}
51+
)
52+
53+
54+
def test_older_gym_checkpoints_remain_loadable():
55+
env = make("tictactoe")
56+
env.reset(seed=1)
57+
snapshot = env.get_state()
58+
snapshot["version"] = 1
59+
del snapshot["curriculum"]
60+
env.set_state(snapshot)
61+
assert env.get_state()["native"] == snapshot["native"]
62+
63+
64+
def test_aec_curricula_clone_and_failed_reset_are_reproducible():
65+
env = aec("chess")
66+
env.reset(seed=91, options={"position_set": "chess-endgames-basic@1"})
67+
origin = env.infos[env.agent_selection]["curriculum"]
68+
clone = env.clone()
69+
seen = []
70+
for _ in range(20):
71+
env.reset()
72+
clone.reset()
73+
assert env.native.get_state() == clone.native.get_state()
74+
assert env.agent_selection == clone.agent_selection
75+
assert env.infos == clone.infos
76+
seen.append(env.infos[env.agent_selection]["curriculum"]["position_id"])
77+
assert len(set(seen)) > 1
78+
before = env.clone()
79+
with pytest.raises(ValueError):
80+
env.reset(seed=123, options={"position": "invalid FEN"})
81+
assert env.native.get_state() == before.native.get_state()
82+
env.reset()
83+
before.reset()
84+
assert env.native.get_state() == before.native.get_state()
85+
env.reset(seed=91)
86+
assert env.infos[env.agent_selection]["curriculum"] == origin
87+
assert "expected" not in json.dumps(env.infos)
88+
89+
90+
def test_opponent_opening_sees_current_origin_and_failure_rolls_back():
91+
received = []
92+
fail = False
93+
94+
def policy(obs, info):
95+
received.append(info.get("curriculum"))
96+
if fail:
97+
raise ValueError("opponent failure")
98+
return int(np.flatnonzero(info["action_mask"])[0])
99+
100+
env = make("connect4", seat=1, opponent=policy)
101+
env.reset(seed=8, options={"position_set": "connect4-solved-positions@1"})
102+
assert received[-1]["position_set"] == "connect4-solved-positions@1"
103+
assert received[-1]["position_id"] == env.get_state()["curriculum"]["position_id"]
104+
before = env.get_state()
105+
fail = True
106+
with pytest.raises(ValueError, match="opponent failure"):
107+
env.reset(seed=999)
108+
assert env.get_state() == before

0 commit comments

Comments
 (0)