Skip to content

Commit c5c56fe

Browse files
authored
[envpool] fix multiplayer players.env_id inference (#347)
## Summary - Problem: the simplified Python multiplayer action path was filling `players.env_id` with the batch `env_id`, so flattened per-player actions were grouped against the wrong environments when an env contributed more than one player action. - Scope: infer `players.env_id` from the incoming Python action payload, reuse the latest observed player-to-env mapping when player counts vary by env, and add Python-side regression coverage for the multiplayer wrapper path. - Outcome: multiplayer actions sent through the shorthand Python API now preserve the correct per-player env mapping instead of silently slicing the action buffer incorrectly. This fixes the Python wrapper bug behind issue #296 without changing the C++ action parser contract. ## Technical Details - Approach: teach `EnvPoolMixin._from()` to derive `players.env_id` from player-shaped action arrays, fall back to the cached `info:players.env_id` mapping from the last `recv()`, and raise when the mapping is ambiguous instead of fabricating a wrong one. - Code pointers: - `envpool/python/envpool.py`: adds `players.env_id` inference and caches `info:players.env_id` on `recv()` for variable-player batches. - `envpool/dummy/dummy_py_envpool_test.py`: adds regression coverage around the real dummy DM wrapper for uniform multiplayer, cached variable-player, explicit mapping, and ambiguous-input cases. - `envpool/dummy/BUILD`: wires the dummy Python test to the Python API wrapper target. - Notes: the regression test now uses the existing dummy env wrapper rather than a standalone fake mixin harness. ## Test Plan ### Automated - `python3 -m py_compile envpool/python/envpool.py envpool/dummy/dummy_py_envpool_test.py`: passed. ### Suggested Manual - `USE_BAZEL_VERSION=8.6.0 bazelisk test //envpool/dummy:dummy_py_envpool_test --config=test --spawn_strategy=local --test_output=errors`: exercise the real dummy wrapper path on Linux. - Re-run the issue #296 repro against this branch on `dev-0`: confirm the flattened multiplayer action now expands to the expected `players.env_id` sequence instead of the old one-element-per-env mapping.
1 parent ffd7508 commit c5c56fe

4 files changed

Lines changed: 173 additions & 5 deletions

File tree

envpool/dummy/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ py_test(
5151
deps = [
5252
requirement("numpy"),
5353
requirement("absl-py"),
54+
"//envpool/python:api",
5455
],
5556
)
5657

envpool/dummy/dummy_py_envpool_test.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,45 @@
1515

1616
import os
1717
import time
18+
from typing import Any
1819

1920
import numpy as np
2021
from absl import logging
2122
from absl.testing import absltest
2223
from envpool.dummy.dummy_envpool import _DummyEnvPool, _DummyEnvSpec
2324

25+
from envpool.python.api import py_env
26+
from envpool.python.protocol import EnvPool
27+
28+
DummyEnvSpec, _DummyDMEnvPool, _, _ = py_env(_DummyEnvSpec, _DummyEnvPool)
29+
30+
31+
def _make_dummy_dm_env() -> EnvPool:
32+
config = DummyEnvSpec.gen_config(
33+
num_envs=2,
34+
batch_size=2,
35+
max_num_players=4,
36+
)
37+
return _DummyDMEnvPool(DummyEnvSpec(config))
38+
39+
40+
def _make_multiplayer_action(
41+
player_count: int,
42+
players_env_id: np.ndarray | None = None,
43+
) -> dict[str, object]:
44+
players: dict[str, np.ndarray] = {
45+
"id": np.arange(player_count, dtype=np.int32),
46+
"action": np.arange(player_count, dtype=np.int32),
47+
}
48+
action: dict[str, Any] = {
49+
"env_id": np.array([0, 1], dtype=np.int32),
50+
"list_action": np.zeros((2, 6), dtype=np.float64),
51+
"players": players,
52+
}
53+
if players_env_id is not None:
54+
players["env_id"] = players_env_id
55+
return action
56+
2457

2558
class _DummyEnvPoolTest(absltest.TestCase):
2659
def test_config(self) -> None:
@@ -121,5 +154,44 @@ def test_xla(self) -> None:
121154
self.assertTrue(xla_failed)
122155

123156

157+
class _EnvPoolMixinRegressionTest(absltest.TestCase):
158+
def test_from_repeats_env_id_for_uniform_multiplayer_action(self) -> None:
159+
env = _make_dummy_dm_env()
160+
action = _make_multiplayer_action(player_count=6)
161+
converted = env._from(action)
162+
np.testing.assert_array_equal(
163+
converted[1],
164+
np.array([0, 0, 0, 1, 1, 1], dtype=np.int32),
165+
)
166+
167+
def test_recv_cache_handles_variable_player_counts(self) -> None:
168+
env = _make_dummy_dm_env()
169+
env._last_players_env_id = np.array([0, 0, 1, 1, 1], dtype=np.int32)
170+
action = _make_multiplayer_action(player_count=5)
171+
converted = env._from(action)
172+
np.testing.assert_array_equal(
173+
converted[1], np.array([0, 0, 1, 1, 1], dtype=np.int32)
174+
)
175+
176+
def test_from_preserves_explicit_players_env_id(self) -> None:
177+
env = _make_dummy_dm_env()
178+
action = _make_multiplayer_action(
179+
player_count=5,
180+
players_env_id=np.array([0, 0, 1, 1, 1], dtype=np.int32),
181+
)
182+
converted = env._from(action)
183+
np.testing.assert_array_equal(
184+
converted[1], np.array([0, 0, 1, 1, 1], dtype=np.int32)
185+
)
186+
187+
def test_from_raises_when_players_env_id_is_ambiguous(self) -> None:
188+
env = _make_dummy_dm_env()
189+
action = _make_multiplayer_action(player_count=5)
190+
with self.assertRaisesRegex(
191+
RuntimeError, "Cannot infer players.env_id"
192+
):
193+
env._from(action)
194+
195+
124196
if __name__ == "__main__":
125197
absltest.main()

envpool/python/envpool.py

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,76 @@ class EnvPoolMixin(ABC):
3030

3131
_spec: EnvSpec
3232

33+
def _player_action_count(
34+
self: EnvPool, adict: dict[str, Any]
35+
) -> int | None:
36+
"""Infer how many player actions are present in the current input."""
37+
player_count = None
38+
for key, spec in self.spec.action_array_spec.items():
39+
if key in ("env_id", "players.env_id") or key not in adict:
40+
continue
41+
shape = tuple(spec.shape)
42+
if len(shape) == 0 or shape[0] != -1:
43+
continue
44+
value_shape = np.shape(adict[key])
45+
count = 1 if len(value_shape) == 0 else int(value_shape[0])
46+
if player_count is None:
47+
player_count = count
48+
elif player_count != count:
49+
raise RuntimeError(
50+
"Inconsistent leading dimensions across player actions."
51+
)
52+
return player_count
53+
54+
def _cached_players_env_id(
55+
self: EnvPool, env_id: np.ndarray, player_count: int
56+
) -> np.ndarray | None:
57+
"""Reuse the last recv/reset mapping when player counts vary by env."""
58+
if not hasattr(self, "_last_players_env_id"):
59+
return None
60+
cached = np.asarray(self._last_players_env_id, dtype=np.int32)
61+
segments = []
62+
for eid in env_id.tolist():
63+
matches = cached[cached == eid]
64+
if matches.size == 0:
65+
return None
66+
segments.append(matches)
67+
if not segments:
68+
return np.empty(0, dtype=np.int32)
69+
players_env_id = np.concatenate(segments)
70+
if players_env_id.shape[0] != player_count:
71+
return None
72+
return players_env_id
73+
74+
def _infer_players_env_id(
75+
self: EnvPool, adict: dict[str, Any]
76+
) -> np.ndarray:
77+
"""Fill in players.env_id for the simplified multiplayer API."""
78+
env_id = np.asarray(adict["env_id"], dtype=np.int32)
79+
if env_id.ndim == 0:
80+
env_id = env_id.reshape(1)
81+
if self.config.get("max_num_players", 1) == 1:
82+
return env_id
83+
player_count = self._player_action_count(adict)
84+
if player_count is None or player_count == env_id.shape[0]:
85+
return env_id
86+
cached = self._cached_players_env_id(env_id, player_count)
87+
if cached is not None:
88+
return cached
89+
if env_id.shape[0] == 0 or player_count % env_id.shape[0] != 0:
90+
raise RuntimeError(
91+
"Cannot infer players.env_id for multiplayer action; "
92+
"pass a dict action with explicit players.env_id."
93+
)
94+
players_per_env = player_count // env_id.shape[0]
95+
max_num_players = self.config.get("max_num_players", 1)
96+
if players_per_env > max_num_players:
97+
raise RuntimeError(
98+
"Cannot infer players.env_id for multiplayer action; "
99+
"per-env player count exceeds max_num_players."
100+
)
101+
return np.repeat(env_id, players_per_env).astype(np.int32, copy=False)
102+
33103
def _check_action(self: EnvPool, actions: list[np.ndarray]) -> None:
34104
if hasattr(self, "_check_action_finished"): # only check once
35105
return
@@ -74,20 +144,20 @@ def _from(
74144
if isinstance(action, np.ndarray):
75145
# else it could be a jax array, when using xla
76146
action = action.astype(
77-
self._last_action_type, # type: ignore
147+
self._last_action_type,
78148
order="C",
79149
)
80-
adict = {self._last_action_name: action} # type: ignore
150+
adict = {self._last_action_name: action}
81151
if env_id is None:
82152
if "env_id" not in adict:
83153
adict["env_id"] = self.all_env_ids
84154
else:
85155
adict["env_id"] = env_id.astype(np.int32)
86156
if "players.env_id" not in adict:
87-
adict["players.env_id"] = adict["env_id"]
157+
adict["players.env_id"] = self._infer_players_env_id(adict)
88158
if not hasattr(self, "_action_names"):
89159
self._action_names = self._spec._action_keys
90-
return [adict[k] for k in self._action_names] # type: ignore
160+
return [adict[k] for k in self._action_names]
91161

92162
def __len__(self: EnvPool) -> int:
93163
"""Return the number of environments."""
@@ -100,7 +170,7 @@ def all_env_ids(self: EnvPool) -> np.ndarray:
100170
self._all_env_ids = np.arange(
101171
self.config["num_envs"], dtype=np.int32
102172
)
103-
return self._all_env_ids # type: ignore
173+
return self._all_env_ids
104174

105175
@property
106176
def is_async(self: EnvPool) -> bool:
@@ -135,6 +205,13 @@ def recv(
135205
) -> TimeStep | tuple:
136206
"""Recv a batch state from EnvPool."""
137207
state_list = self._recv()
208+
if not hasattr(self, "_state_names"):
209+
self._state_names = self._state_keys
210+
state = dict(zip(self._state_names, state_list, strict=False))
211+
if "info:players.env_id" in state:
212+
self._last_players_env_id = np.array(
213+
state["info:players.env_id"], copy=True
214+
)
138215
return self._to(state_list, reset, return_info)
139216

140217
def async_reset(self: EnvPool) -> None:

envpool/python/protocol.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,14 @@ class EnvPool(Protocol):
121121
"""Cpp PyEnvpool class interface."""
122122

123123
_state_keys: list[str]
124+
_state_names: list[str]
124125
_action_keys: list[str]
126+
_action_names: list[str]
127+
_check_action_finished: bool
128+
_all_env_ids: np.ndarray
129+
_last_action_name: str
130+
_last_action_type: Any
131+
_last_players_env_id: np.ndarray
125132
spec: Any
126133

127134
def __init__(self, spec: EnvSpec):
@@ -141,6 +148,17 @@ def _action_spec(self) -> list:
141148
def _check_action(self, actions: list) -> None:
142149
"""Check action shapes."""
143150

151+
def _player_action_count(self, adict: dict[str, Any]) -> int | None:
152+
"""Infer the leading player-action dimension."""
153+
154+
def _cached_players_env_id(
155+
self, env_id: np.ndarray, player_count: int
156+
) -> np.ndarray | None:
157+
"""Reuse cached player-to-env mapping when available."""
158+
159+
def _infer_players_env_id(self, adict: dict[str, Any]) -> np.ndarray:
160+
"""Infer players.env_id for simplified multiplayer actions."""
161+
144162
def _recv(self) -> list[np.ndarray]:
145163
"""Cpp private _recv method."""
146164

0 commit comments

Comments
 (0)