Skip to content

Commit dbaf58a

Browse files
authored
Merge pull request #92 from CurtisTechSolutions/feat/remote-python-environments
feat(python): connect local policies to remote environments
2 parents a4beaef + 51f9e8f commit dbaf58a

9 files changed

Lines changed: 729 additions & 4 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Compare the installed wheel's remote wrappers with a real local server."""
2+
import json
3+
from pathlib import Path
4+
import re
5+
import selectors
6+
import subprocess
7+
import tempfile
8+
9+
import numpy as np
10+
from gamesforai import NativeEnv, connect, make
11+
12+
with tempfile.TemporaryDirectory() as directory:
13+
process = subprocess.Popen([
14+
str(Path("target/debug/gfa").resolve()), "serve", "--sqlite",
15+
str(Path(directory) / "remote.sqlite"), "--port", "0",
16+
], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
17+
try:
18+
with selectors.DefaultSelector() as selector:
19+
selector.register(process.stdout, selectors.EVENT_READ)
20+
if not selector.select(timeout=30):
21+
raise RuntimeError("server startup timed out")
22+
line = process.stdout.readline()
23+
match = re.search(r"http://127\.0\.0\.1:\d+", line)
24+
assert match, "server did not publish a local address"
25+
client = connect(match.group(), timeout=15)
26+
for game in ["tictactoe", "connect4", "chess"]:
27+
remote, local = client.native(game, seed=42), NativeEnv(game, seed=42)
28+
for _ in range(12):
29+
assert json.loads(remote.get_state()) == json.loads(local.get_state())
30+
if any(local.flags()):
31+
break
32+
seat = local.current_players()[0]
33+
left, right = remote.frame(seat), local.frame(seat)
34+
np.testing.assert_array_equal(left["observation"], right["observation"])
35+
np.testing.assert_array_equal(left["action_mask"], right["action_mask"])
36+
action = int(np.flatnonzero(right["action_mask"])[0])
37+
assert remote.step(seat, action) == local.step(seat, action)
38+
for seat in [0, 1]:
39+
remote = client.make("tictactoe", seat=seat, opponent="minimax:3")
40+
local = make("tictactoe", seat=seat, opponent="minimax:3")
41+
left, _ = remote.reset(seed=19)
42+
right, _ = local.reset(seed=19)
43+
np.testing.assert_array_equal(left, right)
44+
while not any(remote.native.flags()):
45+
action = int(np.flatnonzero(local.action_masks())[0])
46+
a, b = remote.step(action), local.step(action)
47+
np.testing.assert_array_equal(a[0], b[0])
48+
assert a[1:4] == b[1:4]
49+
env = client.aec("tictactoe")
50+
env.reset(seed=0)
51+
checkpoint = env.get_state()
52+
env.step(0)
53+
env.set_state(checkpoint)
54+
for action in [0, 3, 1, 4, 2]:
55+
env.step(action)
56+
assert all(env.terminations.values())
57+
assert env.rewards == {"player_0": 1.0, "player_1": -1.0}
58+
assert isinstance(env.render(), str)
59+
env = client.make("connect4")
60+
obs, info = env.reset(seed=42, options={"position_set": "connect4-solved-positions@1"})
61+
assert info["curriculum"]["position_set"] == "connect4-solved-positions@1"
62+
finally:
63+
process.terminate()
64+
try:
65+
process.wait(timeout=15)
66+
except subprocess.TimeoutExpired:
67+
process.kill()
68+
process.wait()
69+
raise
70+
assert process.returncode == 0, "server shutdown failed"
71+
print("Remote/native checkpoints, frames, Gym/AEC rewards and curricula verified")

‎.github/workflows/python.yml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ jobs:
4141
env:
4242
POSITION_BASE_SHA: ${{ github.event.pull_request.base.sha }}
4343
- run: python -m pytest python/tests -q
44+
- name: Build local server for remote SDK integration
45+
if: runner.os == 'Linux'
46+
env:
47+
SQLX_OFFLINE: true
48+
run: cargo build -p gfa-cli --locked
49+
- name: Compare remote and native SDK against real server
50+
if: runner.os == 'Linux'
51+
run: python .github/scripts/python-remote-smoke.py
4452
- run: python examples/vector_throughput.py --output vector-benchmark.json
4553
- uses: actions/upload-artifact@v4
4654
with:

‎examples/remote-training.md‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Use the SDK against a server
2+
3+
Start `gfa serve --sqlite training.sqlite` and connect to its printed address:
4+
5+
```python
6+
import os
7+
import gamesforai as gfa
8+
9+
client = gfa.connect("http://127.0.0.1:8080")
10+
# For an authenticated deployment: gfa.connect(url, os.environ["GFA_API_KEY"])
11+
env = client.make("connect4", seat=0, opponent="random")
12+
obs, info = env.reset(seed=42)
13+
obs, reward, terminated, truncated, info = env.step(3)
14+
15+
checkpoint = env.get_state()
16+
branch = env.clone()
17+
branch.set_state(checkpoint)
18+
19+
multi = client.aec("tictactoe")
20+
multi.reset(seed=42)
21+
native = client.native("chess")
22+
```
23+
24+
Gymnasium and PettingZoo use the same wrappers as local training, including
25+
curriculum resets, reward semantics and wrapper checkpoints. Python callbacks
26+
and `ChatPolicy` run in your training process using only the requested seat's
27+
observation and legal moves. Random opponents use the wrapper's checkpointed
28+
RNG. Native minimax/MCTS recommendations use the server's analysis endpoint
29+
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.
32+
33+
`client.batch(operations)` exposes the [batch REST contract](../docs/training-batches.md)
34+
directly, with up to 64 independent create/reset/step/observe operations. It
35+
returns all row results, including errors. Callers decide whether to accept
36+
successful rows or treat their whole local batch as atomic.
37+
38+
Remote environments keep **full private checkpoints in the trusted controller**.
39+
They do not create database matches. The server validates each supplied
40+
checkpoint and returns a separately projected frame. Never give checkpoints
41+
to a model, opponent, spectator, or benchmark participant. Policies receive
42+
only their seat-visible observation and legal mask.
43+
44+
A remote transition validates the returned checkpoint, rewards, catalog, mask
45+
and tensor, then fetches all player frames before updating local state.
46+
Transport errors or a failure fetching any frame preserve the previous state.
47+
Gym also rolls back the entire learner/opponent exchange, including its RNG.
48+
Clones hold independent checkpoints and share connection configuration.
49+
50+
Use native environments for peak throughput: remote stepping serializes full
51+
checkpoints and incurs HTTP round trips. Each environment controller is intended
52+
for one caller thread. `close()` has no server environment handle to release.
53+
Arrays and checkpoint dictionaries returned to callers are independent copies.
54+
55+
The client sends an optional key only as a Bearer header, follows no redirects,
56+
performs no implicit retries, and caps requests at 4 MiB and responses at 8 MiB.
57+
`RemoteError.code` and `status` expose stable failures without response bodies,
58+
credentials or endpoint URLs. Timeout is a socket timeout, not a total run
59+
deadline. The current server is local-only and does not validate API keys;
60+
do not expose it through a public proxy. Use TLS for future authenticated
61+
remote deployments. Server and SDK engine versions must be compatible.

‎python/README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,7 @@ ChatPolicy uses the chat-completions protocol implemented by servers such as [vL
127127
Credentials are read from the named environment variable on each call; omit `api_key_env` for a server without authentication. No model or provider account is required by the engine. Structured output uses a JSON Schema with the legal moves as an enum, as supported by [vLLM structured outputs](https://docs.vllm.ai/en/latest/features/structured_outputs/). Set `structured=False` for a server that accepts the chat protocol but not that parameter; the reply must still be the exact JSON object.
128128

129129
Requests and replies are capped at 64 KiB; output tokens and the socket timeout are configurable. Calls do not retry or follow redirects. `PolicyError.code` distinguishes malformed/illegal replies, authentication, rate limits, timeouts, and connection failures without exposing response bodies or credentials. A Gymnasium opponent failure rolls back the complete learner/opponent exchange. This synchronous adapter is for local training/evaluation code; durable server-side LLM transcripts and budgets are a separate integration.
130+
131+
## Remote environments
132+
133+
Use `client = gamesforai.connect(server_url)` and `client.make(...)` or `client.aec(...)` for the same wrapper API over stateless REST training. See [remote training](../examples/remote-training.md) for checkpoint ownership, batch calls, limits and current server mode.

‎python/gamesforai/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@
66
from .vector_env import VectorEnv
77
from .gym_env import GameEnv, make
88
from .aec_env import AECGameEnv, aec
9+
from .remote import Client, RemoteError, RemoteNativeEnv, connect
910

10-
__all__ = ["AECGameEnv", "ChatPolicy", "PolicyError", "GameEnv", "NativeEnv", "PositionSet", "StockfishPool", "VectorEnv", "aec", "games", "make"]
11+
__all__ = ["Client", "RemoteError", "RemoteNativeEnv", "connect", "AECGameEnv", "ChatPolicy", "PolicyError", "GameEnv", "NativeEnv", "PositionSet", "StockfishPool", "VectorEnv", "aec", "games", "make"]

‎python/gamesforai/aec_env.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@ class AECGameEnv(AECEnv):
2121
metadata = {"render_modes": ["ansi"], "name": "gamesforai_v0", "is_parallelizable": False}
2222

2323
def __init__(
24-
self, game: str, *, config: dict[str, Any] | None = None, render_mode: str | None = None
24+
self, game: str, *, config: dict[str, Any] | None = None, render_mode: str | None = None,
25+
_native_factory=NativeEnv
2526
) -> None:
2627
if render_mode not in (None, "ansi"):
2728
raise ValueError("render_mode must be None or 'ansi'")
28-
self.native = NativeEnv(game, json.dumps(config or {}))
29+
self.native = _native_factory(game, json.dumps(config or {}))
2930
self.render_mode = render_mode
3031
self.possible_agents = [f"player_{seat}" for seat in range(self.native.num_players)]
3132
self._seats = {agent: seat for seat, agent in enumerate(self.possible_agents)}

‎python/gamesforai/gym_env.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def __init__(
3737
opponent: str | Policy = "random",
3838
stockfish_pool: StockfishPool | None = None,
3939
render_mode: str | None = None,
40+
_native_factory=NativeEnv,
4041
) -> None:
4142
if render_mode not in (None, "ansi"):
4243
raise ValueError("render_mode must be None or 'ansi'")
@@ -60,7 +61,7 @@ def __init__(
6061
raise ValueError("opponent must be a supported name or Python policy callable")
6162
if stockfish_pool is not None and self._stockfish is None:
6263
raise ValueError("stockfish_pool requires a stockfish opponent")
63-
self.native = NativeEnv(game, json.dumps(config or {}))
64+
self.native = _native_factory(game, json.dumps(config or {}))
6465
if isinstance(seat, bool) or not isinstance(seat, int) or not 0 <= seat < self.native.num_players:
6566
raise ValueError("seat is outside this game's player range")
6667
self._spec = json.loads(self.native.spec_json())

0 commit comments

Comments
 (0)