Skip to content

Commit 080f78a

Browse files
committed
chore(training): integrate pinned curricula and native opponents
2 parents ef31b2e + 3170471 commit 080f78a

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
@@ -99,6 +99,12 @@ of numeric output per batch. Throughput still needs measurement against the PRD
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.
101101

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.
107+
102108
## Built-in training opponents
103109

104110
Use `make("connect4", opponent="minimax:3")` or `opponent="mcts:3"` for the native built-in search algorithms at levels 1–10. Training uses fixed node/depth budgets and RNG seeds, so choices do not depend on machine speed. Search reconstructs a planning state from the opponent seat’s observation and never mutates the live environment. Gymnasium checkpoints preserve the opponent seed stream. These are the existing provisional resource levels; their playing strength has not yet been calibrated.

‎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
@@ -13,6 +13,7 @@
1313
from gymnasium.error import ResetNeeded
1414

1515
from ._native import NativeEnv
16+
from . import curriculum
1617

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

@@ -64,6 +65,8 @@ def __init__(
6465
-np.inf, np.inf, shape=frame["observation"].shape, dtype=np.float32
6566
)
6667
self._needs_reset = True
68+
self._position_set = None
69+
self._position_id = None
6770

6871
def _frame(self, seat: int) -> tuple[np.ndarray, dict[str, Any]]:
6972
frame = self.native.frame(seat)
@@ -80,6 +83,7 @@ def _frame(self, seat: int) -> tuple[np.ndarray, dict[str, Any]]:
8083
"to_act": self.native.current_players(),
8184
"terminated": terminated,
8285
"truncated": truncated,
86+
**curriculum.origin_info(self._position_set, self._position_id),
8387
}
8488
return frame["observation"], info
8589

@@ -114,15 +118,29 @@ def reset(
114118
self, *, seed: int | None = None, options: dict[str, Any] | None = None
115119
) -> tuple[np.ndarray, dict[str, Any]]:
116120
options = options or {}
117-
if set(options) - {"position"}:
118-
raise ValueError("supported reset option: position")
119-
position = options.get("position")
120-
if position is not None and not isinstance(position, str):
121-
raise ValueError("position must be a notation string")
122-
super().reset(seed=seed)
123-
native_seed = int(self.np_random.integers(0, 2**64, dtype=np.uint64)) if seed is None else seed
124-
self.native.reset(native_seed, position)
125-
self._opponents()
121+
if set(options) - {"position", "position_set"}:
122+
raise ValueError("supported reset options: position, position_set")
123+
saved_native = self.native
124+
saved_curriculum = self._position_set, self._position_id
125+
saved_rng = copy.deepcopy(self._np_random)
126+
saved_rng_seed = self._np_random_seed
127+
try:
128+
super().reset(seed=seed)
129+
position, dataset, position_id = curriculum.resolve_start(
130+
self.native, options, self.np_random, self._position_set
131+
)
132+
native_seed = int(self.np_random.integers(0, 2**64, dtype=np.uint64)) if seed is None else seed
133+
self.native = self.native.clone()
134+
self.native.reset(native_seed, position)
135+
self._position_set, self._position_id = dataset, position_id
136+
self._opponents()
137+
except Exception:
138+
self.native = saved_native
139+
self._position_set, self._position_id = saved_curriculum
140+
self._np_random = saved_rng
141+
self._np_random_seed = saved_rng_seed
142+
raise
143+
self._position_set, self._position_id = dataset, position_id
126144
self._needs_reset = any(self.native.flags())
127145
return self._frame(self.seat)
128146

@@ -171,31 +189,36 @@ def clone(self) -> GameEnv:
171189
def get_state(self) -> dict[str, Any]:
172190
"""Trusted checkpoint containing private engine state and environment RNG."""
173191
return {
174-
"version": 1,
192+
"version": 2,
175193
"seat": self.seat,
176194
"native": json.loads(self.native.get_state()),
177195
"rng": copy.deepcopy(self.np_random.bit_generator.state),
178196
"rng_seed": self.np_random_seed,
179197
"needs_reset": self._needs_reset,
198+
"curriculum": curriculum.checkpoint(self._position_set, self._position_id),
180199
}
181200

182201
def set_state(self, state: dict[str, Any]) -> None:
183202
"""Atomically restore this wrapper; external policy state is not included."""
184203
required = {"version", "seat", "native", "rng", "rng_seed", "needs_reset"}
185-
if set(state) != required or state["version"] != 1 or state["seat"] != self.seat:
204+
if state.get("version") == 2:
205+
required.add("curriculum")
206+
if set(state) != required or state["version"] not in (1, 2) or state["seat"] != self.seat:
186207
raise ValueError("incompatible environment checkpoint")
187208
if not isinstance(state["needs_reset"], bool):
188209
raise ValueError("invalid checkpoint episode flag")
189210
candidate = self.native.clone()
190211
candidate.set_state(json.dumps(state["native"]))
191212
if any(candidate.flags()) and not state["needs_reset"]:
192213
raise ValueError("finished checkpoint cannot be marked active")
214+
dataset, position_id = curriculum.restore(state.get("curriculum"), candidate)
193215
generator = np.random.default_rng()
194216
generator.bit_generator.state = copy.deepcopy(state["rng"])
195217
self.native = candidate
196218
self._np_random = generator
197219
self._np_random_seed = state["rng_seed"]
198220
self._needs_reset = state["needs_reset"]
221+
self._position_set, self._position_id = dataset, position_id
199222

200223

201224
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)