Skip to content

Commit 9887bbd

Browse files
authored
Merge pull request #80 from CurtisTechSolutions/feat/vector-training-curricula
feat(training): add reproducible vector curricula
2 parents 0df4715 + ce09756 commit 9887bbd

3 files changed

Lines changed: 230 additions & 17 deletions

File tree

‎examples/vector-curricula.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Batched curriculum training
2+
3+
```python
4+
from gamesforai import VectorEnv
5+
6+
envs = VectorEnv("connect4", 256, threads=4)
7+
batch = envs.reset(42, options={"position_set": "connect4-solved-positions@1"})
8+
origins = envs.origins # per-row dataset id, content hash and position id
9+
checkpoint = envs.get_state()
10+
envs.reset_at([0, 12], [100, 101]) # retain each selected row's curriculum
11+
envs.set_state(checkpoint)
12+
```
13+
14+
Every row samples with its explicit reset seed, so results are independent of
15+
thread scheduling. Full resets use `seed + row`, or accept a seed list.
16+
`options={"position_set": None}` clears curricula for selected rows.
17+
Explicit `positions=[...]` also clears curricula for those rows.
18+
Failed resets preserve both engine state and dataset identities.
19+
20+
Numeric frames keep their existing array-only schema. Read public origin
21+
metadata separately through `origins`; expected answers stay in trusted datasets.
22+
Curriculum checkpoints use a version 2 dictionary with each dataset stored once
23+
and per-row references. Batches without curricula retain the original list of
24+
native snapshots. Both forms are accepted by `set_state`.

‎python/gamesforai/vector_env.py‎

Lines changed: 121 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,13 @@
33

44
import copy
55
import json
6+
import operator
67

7-
from ._native import NativeVectorEnv
8+
import numpy as np
9+
10+
from ._native import NativeEnv, NativeVectorEnv
11+
from . import curriculum
12+
from .positions import PositionSet
813

914

1015
class VectorEnv:
@@ -15,10 +20,18 @@ class VectorEnv:
1520
"""
1621

1722
def __init__(self, game, n, *, config=None, seed=0, threads=0):
18-
self.native = NativeVectorEnv(game, n, json.dumps(config or {}), seed, threads)
23+
config_json = json.dumps(config or {})
24+
self.native = NativeVectorEnv(game, n, config_json, seed, threads)
1925
self.n = self.native.n
2026
self.num_players = self.native.num_players
2127
self.action_space_size = self.native.action_space_size
28+
self._prototype = NativeEnv(game, config_json)
29+
self._curricula = [(None, None)] * self.n
30+
31+
@property
32+
def origins(self):
33+
"""Public dataset identities per row; puzzle answers are excluded."""
34+
return [curriculum.origin_info(dataset, position_id) for dataset, position_id in self._curricula]
2235

2336
def frame(self, seats=None):
2437
"""Return owned numeric arrays; select a view per row with seats."""
@@ -28,28 +41,119 @@ def step(self, actions):
2841
"""Apply a whole batch atomically; None is required for finished rows."""
2942
return self.native.step(list(actions))
3043

31-
def reset(self, seed=0, *, positions=None):
32-
"""Reset every row using seed + row, or an explicit list of seeds."""
33-
seeds = [seed + index for index in range(self.n)] if isinstance(seed, int) else list(seed)
34-
return self.native.reset_at(
35-
list(range(self.n)), seeds, [None] * self.n if positions is None else list(positions)
36-
)
44+
def reset(self, seed=0, *, positions=None, options=None):
45+
"""Reset all rows with seed + row, or an explicit list of seeds."""
46+
if isinstance(seed, (int, np.integer)):
47+
if isinstance(seed, (bool, np.bool_)):
48+
raise ValueError("seed must be an unsigned 64-bit integer")
49+
seeds = [int(seed) + index for index in range(self.n)]
50+
else:
51+
seeds = list(seed)
52+
return self.reset_at(range(self.n), seeds, positions=positions, options=options)
3753

38-
def reset_at(self, indices, seeds, *, positions=None):
39-
"""Reset selected rows without discarding other episodes."""
40-
indices = list(indices)
41-
return self.native.reset_at(
42-
indices, list(seeds), [None] * len(indices) if positions is None else list(positions)
43-
)
54+
def reset_at(self, indices, seeds, *, positions=None, options=None):
55+
"""Reset selected rows, optionally sampling a pinned position set."""
56+
indices, seeds = list(indices), list(seeds)
57+
options = dict(options or {})
58+
if set(options) - {"position", "position_set"}:
59+
raise ValueError("supported reset options: position, position_set")
60+
if positions is not None and options:
61+
raise ValueError("choose positions or reset options, not both")
62+
if len(seeds) != len(indices):
63+
raise ValueError("reset seeds must match indices")
64+
normalized = []
65+
for index, seed in zip(indices, seeds):
66+
if isinstance(index, (bool, np.bool_)) or isinstance(seed, (bool, np.bool_)):
67+
raise ValueError("indices and seeds must be integers")
68+
try:
69+
index, seed = operator.index(index), operator.index(seed)
70+
except TypeError as error:
71+
raise ValueError("indices and seeds must be integers") from error
72+
if not 0 <= index < self.n or not 0 <= seed < 2**64:
73+
raise ValueError("reset index or seed is out of range")
74+
normalized.append((index, seed))
75+
if len({index for index, _ in normalized}) != len(indices):
76+
raise ValueError("reset indices must be unique")
77+
indices = [index for index, _ in normalized]
78+
seeds = [seed for _, seed in normalized]
79+
positions = None if positions is None else list(positions)
80+
if positions is not None and len(positions) != len(indices):
81+
raise ValueError("reset positions must match indices")
82+
if isinstance(options.get("position_set"), str):
83+
options["position_set"] = PositionSet.load(options["position_set"])
84+
starts, origins = [], self._curricula[:]
85+
for offset, (index, seed) in enumerate(normalized):
86+
row_options = {"position": positions[offset]} if positions is not None else options
87+
position, dataset, position_id = curriculum.resolve_start(
88+
self._prototype, row_options, np.random.default_rng(seed), self._curricula[index][0]
89+
)
90+
starts.append(position)
91+
origins[index] = dataset, position_id
92+
result = self.native.reset_at(indices, seeds, starts)
93+
self._curricula = origins
94+
return result
4495

4596
def clone(self):
4697
result = copy.copy(self)
4798
result.native = self.native.clone()
99+
result._curricula = self._curricula[:]
48100
return result
49101

50102
def get_state(self):
51-
"""Full private checkpoints for trusted training code."""
52-
return self.native.get_state()
103+
"""Private checkpoints; curriculum datasets are stored once per batch."""
104+
native = self.native.get_state()
105+
if not any(dataset is not None for dataset, _ in self._curricula):
106+
return native
107+
datasets, rows = {}, []
108+
for dataset, position_id in self._curricula:
109+
if dataset is None:
110+
rows.append(None)
111+
else:
112+
key = dataset.identifier + ":" + dataset.sha256
113+
datasets[key] = dataset.checkpoint()
114+
rows.append({"dataset": key, "position_id": position_id})
115+
return {"version": 2, "native": native, "datasets": datasets, "rows": rows}
53116

54117
def set_state(self, snapshots):
55-
self.native.set_state(list(snapshots))
118+
"""Restore native state and per-row curricula as one atomic operation."""
119+
origins = [(None, None)] * self.n
120+
if isinstance(snapshots, dict):
121+
if (
122+
set(snapshots) != {"version", "native", "datasets", "rows"}
123+
or type(snapshots["version"]) is not int or snapshots["version"] != 2
124+
or not isinstance(snapshots["datasets"], dict)
125+
or len(snapshots["datasets"]) > self.n
126+
or not isinstance(snapshots["rows"], list) or len(snapshots["rows"]) != self.n
127+
):
128+
raise ValueError("invalid vector curriculum checkpoint")
129+
datasets = {}
130+
for key, payload in snapshots["datasets"].items():
131+
if not isinstance(payload, dict) or set(payload) != {"manifest", "data"}:
132+
raise ValueError("invalid vector position-set checkpoint")
133+
dataset = PositionSet.from_jsonl(payload["manifest"], payload["data"])
134+
dataset.validate_for(self._prototype)
135+
if key != dataset.identifier + ":" + dataset.sha256:
136+
raise ValueError("vector checkpoint dataset identity mismatch")
137+
datasets[key] = dataset
138+
used = set()
139+
for index, row in enumerate(snapshots["rows"]):
140+
if row is None:
141+
continue
142+
if (
143+
not isinstance(row, dict) or set(row) != {"dataset", "position_id"}
144+
or not isinstance(row["dataset"], str) or row["dataset"] not in datasets
145+
):
146+
raise ValueError("invalid vector checkpoint curriculum row")
147+
dataset = datasets[row["dataset"]]
148+
if row["position_id"] not in {entry.id for entry in dataset.entries}:
149+
raise ValueError("unknown vector checkpoint position")
150+
origins[index] = dataset, row["position_id"]
151+
used.add(row["dataset"])
152+
if used != set(datasets):
153+
raise ValueError("unused vector checkpoint dataset")
154+
snapshots = snapshots["native"]
155+
if not isinstance(snapshots, (list, tuple)):
156+
raise ValueError("vector native checkpoints must be a list")
157+
candidate = self.native.clone()
158+
candidate.set_state(list(snapshots))
159+
self.native, self._curricula = candidate, origins
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import copy
2+
import json
3+
4+
import numpy as np
5+
import pytest
6+
7+
from gamesforai import NativeEnv, PositionSet, VectorEnv
8+
9+
10+
def test_seeded_position_batches_match_individual_imports_and_threads():
11+
dataset = PositionSet.load("connect4-solved-positions@1")
12+
entries = {entry.id: entry for entry in dataset.entries}
13+
serial = VectorEnv("connect4", 16, threads=1)
14+
parallel = VectorEnv("connect4", 16, threads=4)
15+
left = serial.reset(41, options={"position_set": dataset})
16+
right = parallel.reset(41, options={"position_set": dataset.identifier})
17+
assert serial.get_state() == parallel.get_state()
18+
assert serial.origins == parallel.origins
19+
for key in left:
20+
np.testing.assert_array_equal(left[key], right[key])
21+
for index, origin in enumerate(serial.origins):
22+
entry = entries[origin["curriculum"]["position_id"]]
23+
single = NativeEnv("connect4")
24+
single.reset(41 + index, entry.position)
25+
np.testing.assert_array_equal(left["observation"][index], single.frame(0)["observation"])
26+
assert "expected" not in json.dumps(serial.origins)
27+
28+
29+
def test_partial_reset_preserves_other_rows_and_cloned_curricula():
30+
env = VectorEnv("connect4", 4)
31+
env.reset(3, options={"position_set": "connect4-solved-positions@1"})
32+
before, origins = env.get_state(), env.origins
33+
clone = env.clone()
34+
clone.reset_at([1], [91], options={"position_set": None})
35+
assert clone.origins[1] == {}
36+
assert env.origins == origins
37+
for index in [0, 2, 3]:
38+
assert clone.get_state()["native"][index] == before["native"][index]
39+
assert clone.origins[index] == origins[index]
40+
clone.reset_at([1], [95])
41+
assert clone.origins[1] == {}
42+
assert len(env.get_state()["datasets"]) == 1
43+
44+
45+
def test_restore_repeats_curriculum_resets_and_legacy_clears_them():
46+
env = VectorEnv("connect4", 8)
47+
legacy = env.get_state()
48+
env.reset(82, options={"position_set": "connect4-solved-positions@1"})
49+
snapshot = json.loads(json.dumps(env.get_state()))
50+
other = VectorEnv("connect4", 8)
51+
other.set_state(snapshot)
52+
for seed in range(6):
53+
env.reset(seed)
54+
other.reset(seed)
55+
assert env.get_state() == other.get_state()
56+
other.set_state(legacy)
57+
assert other.get_state() == legacy
58+
assert other.origins == [{}] * 8
59+
assert isinstance(other.get_state(), list)
60+
61+
62+
def test_failed_reset_and_restore_leave_native_and_curricula_unchanged():
63+
env = VectorEnv("connect4", 4)
64+
env.reset(31, options={"position_set": "connect4-solved-positions@1"})
65+
before = env.get_state()
66+
for kwargs in [
67+
{"indices": [0, 0], "seeds": [1, 2]},
68+
{"indices": [0], "seeds": [True]},
69+
{"indices": [0], "seeds": [2**64]},
70+
{"indices": [0], "seeds": [1], "options": {"position_set": "chess-endgames-basic@1"}},
71+
{"indices": [0, 1], "seeds": [1, 2], "positions": [None, "bad"]},
72+
]:
73+
with pytest.raises(ValueError):
74+
env.reset_at(**kwargs)
75+
assert env.get_state() == before
76+
bad = copy.deepcopy(before)
77+
bad["rows"][0]["position_id"] = "missing"
78+
with pytest.raises(ValueError):
79+
env.set_state(bad)
80+
assert env.get_state() == before
81+
bad = copy.deepcopy(before)
82+
bad["native"][2] = "{}"
83+
with pytest.raises(ValueError):
84+
env.set_state(bad)
85+
assert env.get_state() == before

0 commit comments

Comments
 (0)