Skip to content

Commit bcc90a1

Browse files
committed
feat(python): batch remote vector environments atomically
1 parent 51f9e8f commit bcc90a1

6 files changed

Lines changed: 333 additions & 6 deletions

File tree

‎.github/scripts/python-remote-smoke.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import tempfile
88

99
import numpy as np
10-
from gamesforai import NativeEnv, connect, make
10+
from gamesforai import NativeEnv, VectorEnv, connect, make
1111

1212
with tempfile.TemporaryDirectory() as directory:
1313
process = subprocess.Popen([
@@ -46,6 +46,17 @@
4646
a, b = remote.step(action), local.step(action)
4747
np.testing.assert_array_equal(a[0], b[0])
4848
assert a[1:4] == b[1:4]
49+
remote_batch = client.vector("connect4", 65, seed=31, batch_size=32)
50+
local_batch = VectorEnv("connect4", 65, seed=31)
51+
for _ in range(4):
52+
left = remote_batch.step([3] * 65)
53+
right = local_batch.step([3] * 65)
54+
for key in left:
55+
np.testing.assert_array_equal(left[key], right[key])
56+
checkpoints = remote_batch.get_state()
57+
remote_batch.reset_at([0, 64], [77, 88])
58+
remote_batch.set_state(checkpoints)
59+
assert remote_batch.get_state() == checkpoints
4960
env = client.aec("tictactoe")
5061
env.reset(seed=0)
5162
checkpoint = env.get_state()

‎examples/remote-training.md‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ branch.set_state(checkpoint)
1919
multi = client.aec("tictactoe")
2020
multi.reset(seed=42)
2121
native = client.native("chess")
22+
batch = client.vector("connect4", 128, seed=42, batch_size=32)
2223
```
2324

2425
Gymnasium and PettingZoo use the same wrappers as local training, including
@@ -27,8 +28,8 @@ and `ChatPolicy` run in your training process using only the requested seat's
2728
observation and legal moves. Random opponents use the wrapper's checkpointed
2829
RNG. Native minimax/MCTS recommendations use the server's analysis endpoint
2930
and its configured budgets; the same local assistance guards apply. Remote
30-
Stockfish Gym configuration and vector wrappers are not yet available; native
31-
Stockfish/VectorEnv remain available for in-process training.
31+
Stockfish Gym configuration is not yet available; native Stockfish remains
32+
available for in-process training.
3233

3334
`client.batch(operations)` exposes the [batch REST contract](../docs/training-batches.md)
3435
directly, with up to 64 independent create/reset/step/observe operations. It
@@ -59,3 +60,23 @@ credentials or endpoint URLs. Timeout is a socket timeout, not a total run
5960
deadline. The current server is local-only and does not validate API keys;
6061
do not expose it through a public proxy. Use TLS for future authenticated
6162
remote deployments. Server and SDK engine versions must be compatible.
63+
64+
## Remote vector batches
65+
66+
`client.vector(game, n, config=..., seed=..., batch_size=32)` uses the same
67+
VectorEnv API and curricula as local training. Numeric output shapes/dtypes,
68+
per-seat rewards, explicit terminal resets and clone/checkpoint behavior match
69+
the native wrapper. The default view is each row's next actor, or seat 0 for
70+
finished rows; `frame(seats)` requests explicit information sets.
71+
72+
Calls split into ordered HTTP requests of at most `batch_size` rows (1..64)
73+
and 4 MiB. This is serial HTTP batching, not a local Rayon thread pool;
74+
leave `threads=0`. A transition commits **all** local checkpoints only after
75+
every chunk and required observation succeeds. A failure in a later chunk
76+
preserves the entire previous batch; the stateless server has no partial
77+
environment mutation to undo. Retrying the same input is safe.
78+
79+
The native limits of 1..4096 environments and 128 MiB of numeric arrays apply.
80+
Large JSON checkpoint/frame payloads can still hit the server's row/response
81+
limits; lower `batch_size` when needed. These serialized/array limits are not
82+
limits on Python object memory. Use in-process VectorEnv for high-volume RL.

‎python/gamesforai/remote.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,15 @@ def make(self, game, **kwargs):
132132
raise ValueError("remote Gym currently supports random, minimax/mcts or a Python/HTTP policy")
133133
return GameEnv(game, _native_factory=self.native, **kwargs)
134134

135+
def vector(self, game, n, *, batch_size=32, **kwargs):
136+
from .remote_vector import RemoteNativeVectorEnv
137+
from .vector_env import VectorEnv
138+
139+
def factory(game, n, config_json, seed, threads):
140+
return RemoteNativeVectorEnv(self, game, n, config_json, seed, threads,
141+
batch_size=batch_size)
142+
return VectorEnv(game, n, _native_factory=factory, _prototype_factory=self.native, **kwargs)
143+
135144
def aec(self, game, **kwargs):
136145
from .aec_env import AECGameEnv
137146
return AECGameEnv(game, _native_factory=self.native, **kwargs)

‎python/gamesforai/remote_vector.py‎

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
"""Atomic local batches over the stateless remote training endpoint."""
2+
from __future__ import annotations
3+
4+
import copy
5+
import json
6+
import math
7+
8+
import numpy as np
9+
10+
from .remote import RemoteError, _code, _integer
11+
from .http_policy import _json
12+
13+
14+
class RemoteNativeVectorEnv:
15+
def __init__(self, client, game, n, config_json="{}", seed=0, threads=0, *, batch_size=32):
16+
self._client = client
17+
self.n = _integer(n, 4096)
18+
self._batch_size = _integer(batch_size, 64)
19+
if not self.n or not self._batch_size:
20+
raise ValueError("n must be 1..4096 and batch_size 1..64")
21+
if _integer(threads, 64) != 0:
22+
raise ValueError("remote workers are controlled by the server; leave threads=0")
23+
seed = _integer(seed, 2**64 - self.n)
24+
prototype = client.native(game, config_json, seed)
25+
self.num_players = prototype.num_players
26+
self.action_space_size = prototype.action_space_size
27+
self._shape = prototype._shape
28+
row_bytes = math.prod(self._shape) * 4 + self.action_space_size + self.num_players * 8 + 6
29+
if row_bytes * self.n > 128 * 1024 * 1024:
30+
raise ValueError("batch numeric arrays exceed 128 MiB")
31+
candidate = [prototype] + [prototype.clone() for _ in range(self.n - 1)]
32+
operations = [
33+
(index, 0, {"op": "create", "game_id": game, "config": _json(config_json),
34+
"seed": seed + index, "seat": 0})
35+
for index in range(1, self.n)
36+
]
37+
self._run(candidate, operations)
38+
self._project(candidate, None)
39+
self._envs = candidate
40+
41+
def _chunks(self, jobs):
42+
batch, size = [], 32
43+
for job in jobs:
44+
length = len(json.dumps(job[2], allow_nan=False, separators=(",", ":")).encode()) + 1
45+
if length + 32 > 4 * 1024 * 1024:
46+
raise RemoteError("REQUEST_TOO_LARGE")
47+
if batch and (len(batch) >= self._batch_size or size + length > 4 * 1024 * 1024):
48+
yield batch
49+
batch, size = [], 32
50+
batch.append(job)
51+
size += length
52+
if batch:
53+
yield batch
54+
55+
def _run(self, candidate, jobs, rewards=None, *, unchanged=False):
56+
for chunk in self._chunks(jobs):
57+
results = self._client.batch([job[2] for job in chunk])
58+
for (index, seat, _), result in zip(chunk, results):
59+
if result["status"] == "error":
60+
raise RemoteError(_code(result))
61+
candidate[index]._accept(result, seat, unchanged=unchanged)
62+
if rewards is not None:
63+
rewards[index] = result["frame"]["rewards"]
64+
if candidate[index].num_players != self.num_players or candidate[index]._shape != self._shape:
65+
raise RemoteError("INVALID_RESPONSE")
66+
67+
def _seats(self, candidate, seats):
68+
if seats is not None and len(seats) != self.n:
69+
raise ValueError("seat count must equal batch size")
70+
result = []
71+
for index, env in enumerate(candidate):
72+
actors = env.current_players()
73+
if len(actors) > 1:
74+
raise ValueError("VectorEnv requires sequential turns")
75+
result.append((actors[0] if actors else 0) if seats is None
76+
else _integer(seats[index], self.num_players - 1))
77+
return result
78+
79+
def _project(self, candidate, seats):
80+
seats = self._seats(candidate, seats)
81+
jobs = [
82+
(index, seat, {"op": "observe", "checkpoint": env._checkpoint, "seat": seat})
83+
for index, (env, seat) in enumerate(zip(candidate, seats))
84+
if seat not in env._frames
85+
]
86+
self._run(candidate, jobs, unchanged=True)
87+
# Vector inference needs one view per row; keep Python mask storage bounded.
88+
for env, seat in zip(candidate, seats):
89+
env._frames = {seat: env._frames[seat]}
90+
return seats
91+
92+
def _arrays(self, candidate, seats=None, rewards=None):
93+
seats = self._project(candidate, seats)
94+
output = {
95+
"observation": np.empty((self.n, *self._shape), dtype=np.float32),
96+
"action_mask": np.empty((self.n, self.action_space_size), dtype=np.int8),
97+
"seat": np.array(seats, dtype=np.int16),
98+
"to_act": np.empty(self.n, dtype=np.int16),
99+
"terminated": np.empty(self.n, dtype=bool),
100+
"truncated": np.empty(self.n, dtype=bool),
101+
"rewards": np.zeros((self.n, self.num_players), dtype=np.float64) if rewards is None else rewards.copy(),
102+
}
103+
for index, (env, seat) in enumerate(zip(candidate, seats)):
104+
raw = env._frames[seat]
105+
tensor = raw["observation"]["tensor"]
106+
output["observation"][index] = np.array(tensor["values"], dtype=np.float32).reshape(self._shape)
107+
output["action_mask"][index] = raw["action_mask"]
108+
actors = raw["to_act"]
109+
output["to_act"][index] = actors[0] if actors else -1
110+
output["terminated"][index] = raw["terminated"]
111+
output["truncated"][index] = raw["truncated"]
112+
return output
113+
114+
def frame(self, seats=None):
115+
candidate = [env.clone() for env in self._envs]
116+
result = self._arrays(candidate, seats)
117+
self._envs = candidate
118+
return result
119+
120+
def step(self, actions):
121+
if len(actions) != self.n:
122+
raise ValueError("action count must equal batch size")
123+
candidate = [env.clone() for env in self._envs]
124+
jobs = []
125+
for index, (env, action) in enumerate(zip(candidate, actions)):
126+
actors = env.current_players()
127+
if not actors and any(env.flags()) and action is None:
128+
continue
129+
if len(actors) != 1 or action is None:
130+
raise ValueError("use legal indices for active rows and None for finished rows")
131+
seat = actors[0]
132+
jobs.append((index, seat, {"op": "step", "checkpoint": env._checkpoint,
133+
"seat": seat, "action": _integer(action, self.action_space_size - 1)}))
134+
rewards = np.zeros((self.n, self.num_players), dtype=np.float64)
135+
self._run(candidate, jobs, rewards)
136+
result = self._arrays(candidate, rewards=rewards)
137+
self._envs = candidate
138+
return result
139+
140+
def reset_at(self, indices, seeds, positions):
141+
if len(indices) != len(seeds) or len(indices) != len(positions):
142+
raise ValueError("indices, seeds, and positions must have equal lengths")
143+
candidate = [env.clone() for env in self._envs]
144+
jobs, seen = [], set()
145+
for index, seed, position in zip(indices, seeds, positions):
146+
index, seed = _integer(index, self.n - 1), _integer(seed, 2**64 - 1)
147+
if index in seen or (position is not None and not isinstance(position, str)):
148+
raise ValueError("indices must be unique and positions must be strings or None")
149+
seen.add(index)
150+
jobs.append((index, 0, {"op": "reset", "checkpoint": candidate[index]._checkpoint,
151+
"seed": seed, "position": position, "seat": 0}))
152+
self._run(candidate, jobs)
153+
result = self._arrays(candidate)
154+
self._envs = candidate
155+
return result
156+
157+
def clone(self):
158+
result = copy.copy(self)
159+
result._envs = [env.clone() for env in self._envs]
160+
return result
161+
162+
def get_state(self):
163+
return [env.get_state() for env in self._envs]
164+
165+
def set_state(self, snapshots):
166+
if len(snapshots) != self.n or any(not isinstance(item, str) for item in snapshots):
167+
raise ValueError("checkpoint count or type is invalid")
168+
if sum(len(item.encode()) for item in snapshots) > 128 * 1024 * 1024:
169+
raise ValueError("batch checkpoints exceed 128 MiB")
170+
candidate, jobs = [env.clone() for env in self._envs], []
171+
for index, (env, snapshot) in enumerate(zip(candidate, snapshots)):
172+
checkpoint = _json(snapshot)
173+
if (not isinstance(checkpoint, dict) or checkpoint.get("game_id") != env.game_id
174+
or checkpoint.get("config") != env._checkpoint["config"]):
175+
raise ValueError("checkpoint must match game and configuration")
176+
jobs.append((index, 0, {"op": "observe", "checkpoint": checkpoint, "seat": 0}))
177+
self._run(candidate, jobs)
178+
self._project(candidate, None)
179+
self._envs = candidate

‎python/gamesforai/vector_env.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@ class VectorEnv:
1919
per seat. Finished rows receive None actions until explicitly reset.
2020
"""
2121

22-
def __init__(self, game, n, *, config=None, seed=0, threads=0):
22+
def __init__(self, game, n, *, config=None, seed=0, threads=0,
23+
_native_factory=NativeVectorEnv, _prototype_factory=NativeEnv):
2324
config_json = json.dumps(config or {})
24-
self.native = NativeVectorEnv(game, n, config_json, seed, threads)
25+
self.native = _native_factory(game, n, config_json, seed, threads)
2526
self.n = self.native.n
2627
self.num_players = self.native.num_players
2728
self.action_space_size = self.native.action_space_size
28-
self._prototype = NativeEnv(game, config_json)
29+
self._prototype = _prototype_factory(game, config_json)
2930
self._curricula = [(None, None)] * self.n
3031

3132
@property

‎python/tests/test_remote_vector.py‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import json
2+
3+
import numpy as np
4+
import pytest
5+
6+
from gamesforai import VectorEnv, connect
7+
from gamesforai.remote import RemoteError
8+
from test_remote import server # Shared loopback protocol fixture.
9+
10+
11+
def equal(left, right):
12+
assert left.keys() == right.keys()
13+
for key in left:
14+
assert left[key].dtype == right[key].dtype
15+
np.testing.assert_array_equal(left[key], right[key])
16+
17+
18+
def test_remote_vector_matches_native_complete_games_and_explicit_views(server):
19+
url, _ = server
20+
remote = connect(url).vector("tictactoe", 4, seed=42, batch_size=2)
21+
local = VectorEnv("tictactoe", 4, seed=42)
22+
equal(remote.frame(), local.frame())
23+
equal(remote.frame([1, 0, 1, 0]), local.frame([1, 0, 1, 0]))
24+
for _ in range(10):
25+
frame = local.frame()
26+
actions = [None if ended else int(np.flatnonzero(mask)[0])
27+
for ended, mask in zip(frame["terminated"] | frame["truncated"], frame["action_mask"])]
28+
equal(remote.step(actions), local.step(actions))
29+
assert [json.loads(value) for value in remote.get_state()] == [
30+
json.loads(value) for value in local.get_state()]
31+
if np.all(local.frame()["terminated"]):
32+
break
33+
assert np.all(remote.frame()["terminated"])
34+
equal(remote.step([None] * 4), local.step([None] * 4))
35+
36+
37+
def test_late_chunk_failure_preserves_every_checkpoint_and_can_retry(server):
38+
url, state = server
39+
env = connect(url).vector("tictactoe", 65, batch_size=32)
40+
env.step([0] * 65)
41+
before = env.get_state()
42+
calls = len(state.requests)
43+
# The occupied square fails in the third chunk, after earlier chunks succeeded.
44+
with pytest.raises(RemoteError, match="ILLEGAL_ACTION"):
45+
env.step([1] * 64 + [0])
46+
assert env.get_state() == before
47+
requests = [body for _, _, body in state.requests[calls:] if body is not None]
48+
assert [len(body["operations"]) for body in requests] == [32, 32, 1]
49+
env.step([1] * 65)
50+
assert env.get_state() != before
51+
52+
53+
def test_partial_reset_curriculum_and_checkpoint_clone_parity(server):
54+
url, _ = server
55+
remote = connect(url).vector("connect4", 3, seed=8)
56+
local = VectorEnv("connect4", 3, seed=8)
57+
options = {"position_set": "connect4-solved-positions@1"}
58+
equal(remote.reset(seed=42, options=options), local.reset(seed=42, options=options))
59+
assert remote.origins == local.origins
60+
checkpoint = remote.get_state()
61+
clone = remote.clone()
62+
equal(remote.reset_at([2, 0], [8, 7]), local.reset_at([2, 0], [8, 7]))
63+
assert clone.get_state() == checkpoint
64+
assert remote.get_state() != checkpoint
65+
remote.set_state(checkpoint)
66+
equal(remote.frame(), clone.frame())
67+
bad = remote.get_state()
68+
bad["native"][2] = "{}"
69+
with pytest.raises(ValueError):
70+
remote.set_state(bad)
71+
assert remote.get_state() == checkpoint
72+
73+
74+
def test_projection_failure_does_not_commit_and_arrays_are_owned(server):
75+
url, state = server
76+
env = connect(url).vector("tictactoe", 3)
77+
before = env.get_state()
78+
state.fail_observe = True
79+
with pytest.raises(RemoteError):
80+
env.step([0, 1, 2])
81+
assert env.get_state() == before
82+
state.fail_observe = False
83+
frame = env.frame()
84+
frame["action_mask"][:] = 0
85+
frame["observation"][:] = 99
86+
assert env.frame()["action_mask"].all()
87+
assert not (env.frame()["observation"] == 99).any()
88+
89+
90+
def test_vector_rejects_invalid_batches_before_committing(server):
91+
url, _ = server
92+
client = connect(url)
93+
for kwargs in [{"n": 0}, {"n": 4097}, {"n": 1, "batch_size": 0},
94+
{"n": 1, "threads": 2}, {"n": 2, "seed": 2**64 - 1}]:
95+
with pytest.raises(ValueError):
96+
client.vector("tictactoe", **kwargs)
97+
env = client.vector("tictactoe", 2)
98+
before = env.get_state()
99+
for actions in [[0], [True, 1], [None, None], [0, 10]]:
100+
with pytest.raises(ValueError):
101+
env.step(actions)
102+
with pytest.raises(ValueError):
103+
env.reset_at([0, 0], [1, 2])
104+
with pytest.raises(ValueError):
105+
env.frame([2, 0])
106+
assert env.get_state() == before

0 commit comments

Comments
 (0)