Skip to content

Commit a3c5364

Browse files
authored
Merge pull request #70 from CurtisTechSolutions/feat/python-model-action-views
feat(python): expose readable legal actions to model policies
2 parents 0131e1b + 32933c8 commit a3c5364

6 files changed

Lines changed: 89 additions & 2 deletions

File tree

‎crates/apps/gfa-py/src/lib.rs‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ impl NativeEnv {
6565
Ok((step.rewards, step.terminated, step.truncated))
6666
}
6767

68+
fn spec_json(&self) -> PyResult<String> {
69+
serde_json::to_string(&self.inner.spec()).map_err(error)
70+
}
71+
6872
fn current_players(&self) -> Vec<usize> {
6973
self.inner
7074
.current_players()
@@ -82,11 +86,12 @@ impl NativeEnv {
8286
}
8387

8488
fn frame<'py>(&self, py: Python<'py>, seat: u8) -> PyResult<Bound<'py, PyDict>> {
85-
let (observation, mask) = py
89+
let (observation, mask, actions) = py
8690
.detach(|| {
8791
Ok::<_, GameError>((
8892
self.inner.observe(Viewer::Player(seat))?,
8993
self.inner.action_mask(seat)?,
94+
self.inner.action_catalog(seat)?,
9095
))
9196
})
9297
.map_err(error)?;
@@ -106,6 +111,13 @@ impl NativeEnv {
106111
),
107112
)?;
108113
dict.set_item("text", observation.text)?;
114+
dict.set_item(
115+
"legal_actions",
116+
actions
117+
.into_iter()
118+
.map(|action| (action.index, action.string))
119+
.collect::<Vec<_>>(),
120+
)?;
109121
dict.set_item(
110122
"board_json",
111123
serde_json::to_string(&observation.json).map_err(error)?,

‎crates/core/gfa-core/src/training.rs‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
use crate::{Env, EnvSnapshot, EnvStep, Game, GameError, GameSpec, Observation, PlayerId, Viewer};
1+
use crate::{
2+
Env, EnvSnapshot, EnvStep, Game, GameError, GameSpec, LegalAction, Observation, PlayerId,
3+
Viewer,
4+
};
25

36
/// Object-safe native environments for Python bindings and parallel batches.
47
/// Implementations retain typed engine state between steps.
@@ -21,6 +24,12 @@ pub trait TrainingEnv: Send + Sync {
2124
fn observe(&self, viewer: Viewer) -> Result<Observation, GameError>;
2225
/// Fixed discrete action mask.
2326
fn action_mask(&self, player: PlayerId) -> Result<Vec<bool>, GameError>;
27+
/// Optional readable actions for text policies, scoped to the selected seat.
28+
fn action_catalog(&self, _player: PlayerId) -> Result<Vec<LegalAction>, GameError> {
29+
Err(GameError::position(
30+
"This environment has no readable action catalog",
31+
))
32+
}
2433
/// Native discrete action step.
2534
fn step_index(&mut self, player: PlayerId, index: u32) -> Result<EnvStep, GameError>;
2635
/// Canonical notation step.
@@ -66,6 +75,21 @@ where
6675
fn action_mask(&self, player: PlayerId) -> Result<Vec<bool>, GameError> {
6776
Env::action_mask(self, player)
6877
}
78+
fn action_catalog(&self, player: PlayerId) -> Result<Vec<LegalAction>, GameError> {
79+
let mut actions = self
80+
.legal_actions(player)?
81+
.iter()
82+
.map(|action| {
83+
Ok(LegalAction {
84+
string: G::action_to_string(self.state(), action),
85+
json: serde_json::to_value(action)?,
86+
index: G::action_to_index(action),
87+
})
88+
})
89+
.collect::<Result<Vec<_>, GameError>>()?;
90+
actions.sort_by(|left, right| left.string.cmp(&right.string));
91+
Ok(actions)
92+
}
6993
fn step_index(&mut self, player: PlayerId, index: u32) -> Result<EnvStep, GameError> {
7094
Env::step_index(self, player, index)
7195
}

‎python/README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,7 @@ of numeric output per batch. Throughput still needs measurement against the PRD
9494
## Versioned positions
9595

9696
`PositionSet.load("chess-endgames-basic@1")` loads a bundled, hash-verified dataset and validates every entry using the native engine. `PositionSet.from_files(manifest_path, jsonl_path)` loads a custom set with the same checks. Entries are immutable; use `sample(numpy_rng)` to choose a deterministic starting position. Pass its `position` to a native or Gymnasium reset. Expected puzzle answers stay in trusted dataset metadata, outside policy observations. See [position data](../positions/README.md) for the format and provenance.
97+
98+
## Readable actions for model policies
99+
100+
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.

‎python/gamesforai/_native.pyi‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class NativeEnv:
1010
def reset(self, seed: int = 0, position: str | None = None) -> None: ...
1111
def step(self, seat: int, action: int) -> tuple[list[float], bool, bool]: ...
1212
def step_string(self, seat: int, action: str) -> tuple[list[float], bool, bool]: ...
13+
def spec_json(self) -> str: ...
1314
def current_players(self) -> list[int]: ...
1415
def flags(self) -> tuple[bool, bool]: ...
1516
def returns(self) -> list[float]: ...

‎python/gamesforai/gym_env.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def __init__(
4141
self.native = NativeEnv(game, json.dumps(config or {}))
4242
if isinstance(seat, bool) or not isinstance(seat, int) or not 0 <= seat < self.native.num_players:
4343
raise ValueError("seat is outside this game's player range")
44+
self._spec = json.loads(self.native.spec_json())
4445
self.seat = seat
4546
self.opponent = opponent
4647
self.render_mode = render_mode
@@ -56,6 +57,10 @@ def _frame(self, seat: int) -> tuple[np.ndarray, dict[str, Any]]:
5657
terminated, truncated = self.native.flags()
5758
info = {
5859
"action_mask": frame["action_mask"],
60+
"legal_actions": frame["legal_actions"],
61+
"game_id": self._spec["id"],
62+
"rules": self._spec["rules_markdown"],
63+
"action_notation": self._spec["action_notation"],
5964
"text": frame["text"],
6065
"board": json.loads(frame["board_json"]),
6166
"seat": seat,

‎python/tests/test_policy_views.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import json
2+
3+
import numpy as np
4+
import pytest
5+
6+
from gamesforai import NativeEnv, games, make
7+
8+
9+
@pytest.mark.parametrize("game", games())
10+
def test_action_catalog_matches_mask_and_all_encodings(game):
11+
env = NativeEnv(game)
12+
spec = json.loads(env.spec_json())
13+
assert spec["id"] == game and spec["rules_markdown"] and spec["action_notation"]
14+
seat = env.current_players()[0]
15+
frame = env.frame(seat)
16+
catalog = frame["legal_actions"]
17+
assert len({index for index, _ in catalog}) == len(catalog)
18+
assert sorted(index for index, _ in catalog) == np.flatnonzero(frame["action_mask"]).tolist()
19+
assert [text for _, text in catalog] == sorted(text for _, text in catalog)
20+
for index, text in catalog[:16]:
21+
by_index, by_text = env.clone(), env.clone()
22+
assert by_index.step(seat, index) == by_text.step_string(seat, text)
23+
assert by_index.get_state() == by_text.get_state()
24+
if env.num_players == 2:
25+
assert env.frame(1 - seat)["legal_actions"] == []
26+
27+
28+
def test_gym_opponent_and_learner_receive_readable_seat_scoped_moves():
29+
received = []
30+
def opponent(obs, info):
31+
received.append(info)
32+
return info["legal_actions"][0][0]
33+
34+
env = make("chess", opponent=opponent)
35+
_, info = env.reset(seed=1)
36+
assert info["game_id"] == "chess"
37+
assert "e2e4" in [text for _, text in info["legal_actions"]]
38+
env.step(next(index for index, text in info["legal_actions"] if text == "e2e4"))
39+
assert received[0]["seat"] == 1
40+
assert "e7e5" in [text for _, text in received[0]["legal_actions"]]
41+
assert info["rules"] and info["action_notation"]

0 commit comments

Comments
 (0)