Skip to content

Commit e91095a

Browse files
authored
Merge pull request #64 from CurtisTechSolutions/feat/python-gymnasium-env
feat(python): support Gymnasium training with user policies
2 parents b92d524 + 948338e commit e91095a

5 files changed

Lines changed: 305 additions & 3 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ version = "0.1.0"
88
description = "Native game environments for model training and evaluation"
99
requires-python = ">=3.10"
1010
license = "GPL-3.0-only"
11-
dependencies = ["numpy>=1.24,<3"]
11+
dependencies = ["numpy>=1.24,<3", "gymnasium>=1.2,<2"]
1212

1313
[project.optional-dependencies]
1414
test = ["pytest>=8,<9"]

‎python/README.md‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,23 @@ This unit exposes the native bridge. Gymnasium/AEC convenience wrappers and para
2222

2323
The new PyO3 and rust-numpy dependencies provide the CPython ABI boundary and owned NumPy buffers; no hand-written unsafe code is needed. Packaging follows [Maturin's mixed project layout](https://www.maturin.rs/project_layout.html). Native calls use [PyO3 interpreter detachment](https://pyo3.rs/v0.29.2/parallelism), and arrays use [rust-numpy ownership transfer](https://docs.rs/numpy/0.29.0/numpy/array/struct.PyArray.html).
2424

25-
PyO3's target detection uses target-lexicon, licensed under Apache-2.0 with the LLVM exception; the dependency allowlist records that exact expression.
25+
## Gymnasium and your policy
26+
27+
```python
28+
import numpy as np
29+
import gamesforai
30+
31+
env = gamesforai.make("connect4")
32+
obs, info = env.reset(seed=42)
33+
while True:
34+
action = your_policy(obs, info["action_mask"])
35+
obs, reward, terminated, truncated, info = env.step(action)
36+
if terminated or truncated:
37+
break
38+
```
39+
40+
The default opponent samples legal actions from the environment's seeded random generator. Select `seat=1` to train from the second seat; the opponent plays its opening during reset. Supply `opponent=my_policy` to play against a Python callable accepting `(observation, info)` and returning a discrete index. Each opponent receives its own seat's view.
41+
42+
Masks are required: a fixed action space includes actions that are illegal in a particular position. Invalid actions raise `ValueError` without committing a learner/opponent exchange. Call `reset` after termination or truncation. Position curricula can use `reset(options={"position": notation})`.
43+
44+
Wrapper checkpoints include native state and the environment RNG, so random-opponent continuations repeat exactly. A cloned wrapper owns independent game, RNG, and action-space state. A custom policy callable and its model state remain caller-owned; checkpoint those separately for reproducible self-play.

‎python/gamesforai/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Native GamesForAI environments for training and evaluation."""
22
from ._native import NativeEnv, games
3+
from .gym_env import GameEnv, make
34

4-
__all__ = ["NativeEnv", "games"]
5+
__all__ = ["GameEnv", "NativeEnv", "games", "make"]

‎python/gamesforai/gym_env.py‎

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"""Gymnasium interface for a policy controlling one seat."""
2+
from __future__ import annotations
3+
4+
import copy
5+
import json
6+
from collections.abc import Callable
7+
from typing import Any
8+
9+
import gymnasium as gym
10+
import numpy as np
11+
from gymnasium import spaces
12+
from gymnasium.error import ResetNeeded
13+
14+
from ._native import NativeEnv
15+
16+
Policy = Callable[[np.ndarray, dict[str, Any]], int]
17+
18+
19+
class GameEnv(gym.Env):
20+
"""One learner seat against random opponents or a caller-supplied policy.
21+
22+
A callable opponent receives only its own observation and info (including
23+
the legal mask). Its external model state remains the caller's responsibility.
24+
"""
25+
26+
metadata = {"render_modes": ["ansi"]}
27+
28+
def __init__(
29+
self,
30+
game: str,
31+
*,
32+
config: dict[str, Any] | None = None,
33+
seat: int = 0,
34+
opponent: str | Policy = "random",
35+
render_mode: str | None = None,
36+
) -> None:
37+
if render_mode not in (None, "ansi"):
38+
raise ValueError("render_mode must be None or 'ansi'")
39+
if opponent != "random" and not callable(opponent):
40+
raise ValueError("opponent must be 'random' or a Python policy callable")
41+
self.native = NativeEnv(game, json.dumps(config or {}))
42+
if isinstance(seat, bool) or not isinstance(seat, int) or not 0 <= seat < self.native.num_players:
43+
raise ValueError("seat is outside this game's player range")
44+
self.seat = seat
45+
self.opponent = opponent
46+
self.render_mode = render_mode
47+
frame = self.native.frame(seat)
48+
self.action_space = spaces.Discrete(self.native.action_space_size)
49+
self.observation_space = spaces.Box(
50+
-np.inf, np.inf, shape=frame["observation"].shape, dtype=np.float32
51+
)
52+
self._needs_reset = True
53+
54+
def _frame(self, seat: int) -> tuple[np.ndarray, dict[str, Any]]:
55+
frame = self.native.frame(seat)
56+
terminated, truncated = self.native.flags()
57+
info = {
58+
"action_mask": frame["action_mask"],
59+
"text": frame["text"],
60+
"board": json.loads(frame["board_json"]),
61+
"seat": seat,
62+
"to_act": self.native.current_players(),
63+
"terminated": terminated,
64+
"truncated": truncated,
65+
}
66+
return frame["observation"], info
67+
68+
def _opponents(self) -> float:
69+
reward = 0.0
70+
while actors := self.native.current_players():
71+
if len(actors) != 1:
72+
raise ValueError("Gym single-seat play requires sequential turns; use a multi-agent interface")
73+
seat = actors[0]
74+
if seat == self.seat:
75+
break
76+
observation, info = self._frame(seat)
77+
if callable(self.opponent):
78+
action = self.opponent(observation, info)
79+
if isinstance(action, (bool, np.bool_)) or not isinstance(action, (int, np.integer)):
80+
raise ValueError("opponent policy must return an integer action index")
81+
action = int(action)
82+
else:
83+
legal = np.flatnonzero(info["action_mask"])
84+
if not legal.size:
85+
raise ValueError("active opponent has no legal action")
86+
action = int(self.np_random.choice(legal))
87+
rewards, _, _ = self.native.step(seat, action)
88+
reward += rewards[self.seat]
89+
return reward
90+
91+
def reset(
92+
self, *, seed: int | None = None, options: dict[str, Any] | None = None
93+
) -> tuple[np.ndarray, dict[str, Any]]:
94+
options = options or {}
95+
if set(options) - {"position"}:
96+
raise ValueError("supported reset option: position")
97+
position = options.get("position")
98+
if position is not None and not isinstance(position, str):
99+
raise ValueError("position must be a notation string")
100+
super().reset(seed=seed)
101+
native_seed = int(self.np_random.integers(0, 2**64, dtype=np.uint64)) if seed is None else seed
102+
self.native.reset(native_seed, position)
103+
self._opponents()
104+
self._needs_reset = any(self.native.flags())
105+
return self._frame(self.seat)
106+
107+
def step(self, action: int) -> tuple[np.ndarray, float, bool, bool, dict[str, Any]]:
108+
if self._needs_reset:
109+
raise ResetNeeded("Call reset before stepping this episode")
110+
if isinstance(action, (bool, np.bool_)) or not isinstance(action, (int, np.integer)):
111+
raise ValueError("action must be an integer index")
112+
# Roll back the entire learner/opponent exchange if a custom policy fails.
113+
saved = self.native.clone()
114+
random_state = copy.deepcopy(self.np_random.bit_generator.state)
115+
try:
116+
rewards, _, _ = self.native.step(self.seat, int(action))
117+
reward = rewards[self.seat] + self._opponents()
118+
except Exception:
119+
self.native = saved
120+
self.np_random.bit_generator.state = random_state
121+
raise
122+
terminated, truncated = self.native.flags()
123+
self._needs_reset = terminated or truncated
124+
observation, info = self._frame(self.seat)
125+
return observation, float(reward), terminated, truncated, info
126+
127+
def render(self) -> str:
128+
return self.native.frame(self.seat)["text"]
129+
130+
def close(self) -> None:
131+
pass
132+
133+
def clone(self) -> GameEnv:
134+
"""Clone engine and RNG state; an external policy callable is shared."""
135+
result = copy.copy(self)
136+
result.native = self.native.clone()
137+
result._np_random = copy.deepcopy(self.np_random)
138+
result._np_random_seed = self.np_random_seed
139+
result.action_space = copy.deepcopy(self.action_space)
140+
result.observation_space = copy.deepcopy(self.observation_space)
141+
return result
142+
143+
def get_state(self) -> dict[str, Any]:
144+
"""Trusted checkpoint containing private engine state and environment RNG."""
145+
return {
146+
"version": 1,
147+
"seat": self.seat,
148+
"native": json.loads(self.native.get_state()),
149+
"rng": copy.deepcopy(self.np_random.bit_generator.state),
150+
"rng_seed": self.np_random_seed,
151+
"needs_reset": self._needs_reset,
152+
}
153+
154+
def set_state(self, state: dict[str, Any]) -> None:
155+
"""Atomically restore this wrapper; external policy state is not included."""
156+
required = {"version", "seat", "native", "rng", "rng_seed", "needs_reset"}
157+
if set(state) != required or state["version"] != 1 or state["seat"] != self.seat:
158+
raise ValueError("incompatible environment checkpoint")
159+
if not isinstance(state["needs_reset"], bool):
160+
raise ValueError("invalid checkpoint episode flag")
161+
candidate = self.native.clone()
162+
candidate.set_state(json.dumps(state["native"]))
163+
if any(candidate.flags()) and not state["needs_reset"]:
164+
raise ValueError("finished checkpoint cannot be marked active")
165+
generator = np.random.default_rng()
166+
generator.bit_generator.state = copy.deepcopy(state["rng"])
167+
self.native = candidate
168+
self._np_random = generator
169+
self._np_random_seed = state["rng_seed"]
170+
self._needs_reset = state["needs_reset"]
171+
172+
173+
def make(game: str, **kwargs: Any) -> GameEnv:
174+
"""Create a Gymnasium environment for one model's seat."""
175+
return GameEnv(game, **kwargs)

‎python/tests/test_gym.py‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import copy
2+
import numpy as np
3+
import pytest
4+
from gymnasium.error import ResetNeeded
5+
from gymnasium.utils.env_checker import check_env
6+
7+
from gamesforai import make
8+
9+
10+
@pytest.mark.parametrize("game", ["tictactoe", "connect4"])
11+
def test_gymnasium_contract(game):
12+
check_env(make(game, render_mode="ansi"), skip_render_check=False)
13+
14+
15+
@pytest.mark.parametrize("game,config", [
16+
("tictactoe", {}),
17+
("connect4", {}),
18+
("chess", {"max_plies": 12}),
19+
("sudoku", {"size": 4, "max_moves": 8, "allow_notes": False}),
20+
])
21+
def test_seeded_masked_episodes_and_shapes(game, config):
22+
first = make(game, config=config)
23+
second = make(game, config=config)
24+
obs, info = first.reset(seed=7)
25+
other, other_info = second.reset(seed=7)
26+
np.testing.assert_array_equal(obs, other)
27+
for _ in range(100):
28+
assert first.observation_space.contains(obs)
29+
assert info["action_mask"].dtype == np.int8
30+
action = int(np.flatnonzero(info["action_mask"])[0])
31+
left = first.step(action)
32+
right = second.step(action)
33+
np.testing.assert_array_equal(left[0], right[0])
34+
np.testing.assert_array_equal(left[4]["action_mask"], right[4]["action_mask"])
35+
assert left[1:4] == right[1:4]
36+
obs, _, terminated, truncated, info = left
37+
if terminated or truncated:
38+
break
39+
else:
40+
pytest.fail("bounded episode did not end")
41+
with pytest.raises(ResetNeeded):
42+
first.step(0)
43+
44+
45+
def test_second_seat_receives_opponent_opening():
46+
env = make("tictactoe", seat=1)
47+
_, info = env.reset(seed=19)
48+
assert info["to_act"] == [1]
49+
assert info["action_mask"].sum() == 8
50+
51+
52+
def test_python_policy_terminal_reward_and_clone_restore():
53+
def opponent(observation, info):
54+
assert info["seat"] == 1
55+
assert observation.dtype == np.float32
56+
return int(np.flatnonzero(info["action_mask"])[-1])
57+
58+
env = make("tictactoe", opponent=opponent)
59+
env.reset(seed=9)
60+
saved = env.get_state()
61+
clone = env.clone()
62+
for action in [0, 1]:
63+
assert env.step(action)[1:4] == (0.0, False, False)
64+
assert env.step(2)[1:4] == (1.0, True, False)
65+
assert clone.get_state() == saved
66+
env.set_state(saved)
67+
for action in [0, 1]:
68+
env.step(action)
69+
assert env.step(2)[1:4] == (1.0, True, False)
70+
71+
72+
def test_random_opponent_and_invalid_policy_checkpoint_atomicity():
73+
env = make("connect4")
74+
env.reset(seed=81)
75+
saved = env.get_state()
76+
expected = env.step(3)
77+
expected_state = env.get_state()
78+
env.set_state(saved)
79+
actual = env.step(3)
80+
np.testing.assert_array_equal(actual[0], expected[0])
81+
assert actual[1:4] == expected[1:4]
82+
assert env.get_state() == expected_state
83+
invalid = copy.deepcopy(saved)
84+
invalid["native"]["engine_version"] = "wrong"
85+
with pytest.raises(ValueError):
86+
env.set_state(invalid)
87+
assert env.get_state() == expected_state
88+
89+
bad = make("tictactoe", opponent=lambda obs, info: 999)
90+
bad.reset(seed=3)
91+
before = bad.get_state()
92+
with pytest.raises(ValueError):
93+
bad.step(0)
94+
assert bad.get_state() == before
95+
96+
97+
def test_invalid_parameters_and_reset_options_are_explicit():
98+
with pytest.raises(ValueError):
99+
make("tictactoe", seat=2)
100+
env = make("tictactoe")
101+
with pytest.raises(ResetNeeded):
102+
env.step(0)
103+
with pytest.raises(ValueError):
104+
env.reset(options={"unsupported": True})
105+
env.reset(seed=0)
106+
with pytest.raises(ValueError):
107+
env.step(True)

0 commit comments

Comments
 (0)