env-ssl-wrapper 0.5.0__tar.gz → 0.5.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/PKG-INFO +25 -7
  2. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/README.md +23 -0
  3. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/env_ssl_wrapper/__init__.py +7 -11
  4. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/env_ssl_wrapper/action_chunk.py +4 -3
  5. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/env_ssl_wrapper/evaluate.py +59 -8
  6. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/env_ssl_wrapper/memory_trace.py +5 -2
  7. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/env_ssl_wrapper/standardize/__init__.py +5 -0
  8. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/env_ssl_wrapper/standardize/action_transform_wrapper.py +16 -10
  9. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/env_ssl_wrapper/standardize/adapters.py +50 -27
  10. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/env_ssl_wrapper/standardize/auto_batched_wrapper.py +8 -19
  11. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/env_ssl_wrapper/standardize/episode_padding_wrapper.py +1 -2
  12. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/env_ssl_wrapper/standardize/flatten_obs_wrapper.py +2 -11
  13. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/env_ssl_wrapper/standardize/helpers.py +2 -3
  14. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/env_ssl_wrapper/standardize/image_wrapper.py +1 -5
  15. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/env_ssl_wrapper/standardize/mocks.py +47 -0
  16. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/env_ssl_wrapper/standardize/spaces.py +1 -1
  17. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/env_ssl_wrapper/standardize/standardize_env_wrapper.py +1 -1
  18. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/env_ssl_wrapper/standardize/tensor_wrapper.py +26 -6
  19. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/env_ssl_wrapper/standardize/vector.py +4 -2
  20. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/pyproject.toml +2 -7
  21. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/.gitignore +0 -0
  22. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/LICENSE +0 -0
  23. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/env_ssl_wrapper/standardize/done_tracker_wrapper.py +0 -0
  24. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/env_ssl_wrapper/standardize/standardize_wrapper.py +0 -0
  25. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/env_ssl_wrapper/standardize/time_limit_wrapper.py +0 -0
  26. {env_ssl_wrapper-0.5.0 → env_ssl_wrapper-0.5.2}/env_ssl_wrapper/standardize/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: env-ssl-wrapper
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: One torch-native interface for any MDP environment
5
5
  Project-URL: Homepage, https://pypi.org/project/env-ssl-wrapper/
6
6
  Project-URL: Repository, https://codeberg.org/lucidrains/env-ssl-wrapper
@@ -34,16 +34,11 @@ Classifier: License :: OSI Approved :: MIT License
34
34
  Classifier: Programming Language :: Python :: 3.10
35
35
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
36
36
  Requires-Python: >=3.10
37
- Requires-Dist: discrete-continuous-embed-readout
38
37
  Requires-Dist: einops>=0.8.1
39
- Requires-Dist: einx>=0.3.0
40
38
  Requires-Dist: imageio
41
39
  Requires-Dist: imageio-ffmpeg
42
- Requires-Dist: memmap-replay-buffer
43
- Requires-Dist: torch-einops-utils>=0.0.29
40
+ Requires-Dist: numpy>=1.24
44
41
  Requires-Dist: torch>=2.5
45
- Requires-Dist: x-mlps-pytorch
46
- Requires-Dist: x-transformers
47
42
  Provides-Extra: examples
48
43
  Requires-Dist: fire; extra == 'examples'
49
44
  Provides-Extra: test
@@ -277,6 +272,29 @@ with MultiprocessingVecEnv('CartPole-v1', num_envs = 8) as env:
277
272
  obs, info = env.reset()
278
273
  ```
279
274
 
275
+ ## Evaluation
276
+
277
+ `evaluate_actor` rolls a policy through any standardized env and returns per-episode stats, optionally recording videos.
278
+
279
+ ```python
280
+ import gymnasium as gym
281
+ from env_ssl_wrapper import StandardizeEnvWrapper, evaluate_actor
282
+
283
+ env = StandardizeEnvWrapper(gym.make('CartPole-v1'))
284
+
285
+ stats = evaluate_actor(policy, env, episodes = 10, seed = 0)
286
+
287
+ stats.mean # mean return
288
+ stats.std # std of returns
289
+ stats.min, stats.max
290
+ stats.success_rate(475.) # fraction of episodes above a threshold
291
+ stats.summary() # dict(episodes, mean, std, min, max)
292
+
293
+ # deterministic = False samples from distributions instead of collapsing to the mode
294
+ # an actor exposing `.dist` takes precedence over calling it directly
295
+ # video_path = 'eval.mp4' records the first video_episodes episodes (single env only)
296
+ ```
297
+
280
298
  ## Tests
281
299
 
282
300
  ```bash
@@ -212,6 +212,29 @@ with MultiprocessingVecEnv('CartPole-v1', num_envs = 8) as env:
212
212
  obs, info = env.reset()
213
213
  ```
214
214
 
215
+ ## Evaluation
216
+
217
+ `evaluate_actor` rolls a policy through any standardized env and returns per-episode stats, optionally recording videos.
218
+
219
+ ```python
220
+ import gymnasium as gym
221
+ from env_ssl_wrapper import StandardizeEnvWrapper, evaluate_actor
222
+
223
+ env = StandardizeEnvWrapper(gym.make('CartPole-v1'))
224
+
225
+ stats = evaluate_actor(policy, env, episodes = 10, seed = 0)
226
+
227
+ stats.mean # mean return
228
+ stats.std # std of returns
229
+ stats.min, stats.max
230
+ stats.success_rate(475.) # fraction of episodes above a threshold
231
+ stats.summary() # dict(episodes, mean, std, min, max)
232
+
233
+ # deterministic = False samples from distributions instead of collapsing to the mode
234
+ # an actor exposing `.dist` takes precedence over calling it directly
235
+ # video_path = 'eval.mp4' records the first video_episodes episodes (single env only)
236
+ ```
237
+
215
238
  ## Tests
216
239
 
217
240
  ```bash
@@ -1,18 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import sys
4
+
4
5
  from . import standardize
5
- from .standardize import *
6
+ from .standardize import * # noqa: F403
6
7
 
7
8
  from .memory_trace import MemoryTraceWrapper
8
9
  from .action_chunk import ActionChunkWrapper
9
10
  from .evaluate import evaluate_actor, EpisodeStats
10
11
 
11
- # Wire backwards-compatibility aliases in sys.modules and module globals
12
- # so imports like `from env_ssl_wrapper.done_tracker_wrapper import DoneTrackerWrapper`
13
- # or `import env_ssl_wrapper.mocks` continue to work without breaking.
12
+ # backwards compatibility — the standardize wrappers used to live at the top
13
+ # level, so keep the old module paths (e.g. `env_ssl_wrapper.mocks`) importable
14
14
 
15
- _STANDARDIZE_SUBMODULES = (
15
+ _LEGACY_MODULES = (
16
16
  'adapters',
17
17
  'auto_batched_wrapper',
18
18
  'action_transform_wrapper',
@@ -31,11 +31,8 @@ _STANDARDIZE_SUBMODULES = (
31
31
  'vector',
32
32
  )
33
33
 
34
- for _name in _STANDARDIZE_SUBMODULES:
35
- _mod = getattr(standardize, _name, None)
36
- if _mod is not None:
37
- sys.modules[f'{__name__}.{_name}'] = _mod
38
- globals()[_name] = _mod
34
+ for _name in _LEGACY_MODULES:
35
+ sys.modules[f'{__name__}.{_name}'] = getattr(standardize, _name)
39
36
 
40
37
  __all__ = [
41
38
  *standardize.__all__,
@@ -49,4 +46,3 @@ def __getattr__(name):
49
46
  if hasattr(standardize, name):
50
47
  return getattr(standardize, name)
51
48
  raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
52
-
@@ -12,6 +12,7 @@ from .standardize.helpers import (
12
12
  any_true,
13
13
  dones_of,
14
14
  first_existing,
15
+ get_adapter,
15
16
  get_attr,
16
17
  is_vectorized,
17
18
  to_numpy,
@@ -70,7 +71,7 @@ class ActionChunkWrapper(EnvWrapper):
70
71
  self.action_shape = tuple(get_attr(action_space, 'shape', ()) or ())
71
72
 
72
73
  self.expects_batch = is_vectorized(env)
73
- self.raw_env = not isinstance(env, EnvWrapper)
74
+ self.convert_in = not isinstance(env, EnvWrapper) and not get_adapter(env).torch_native
74
75
 
75
76
  @property
76
77
  def chunk_action_shape(self):
@@ -86,9 +87,9 @@ class ActionChunkWrapper(EnvWrapper):
86
87
  if not (is_tensor(actions) or isinstance(actions, np.ndarray)):
87
88
  actions = np.asarray(actions)
88
89
 
89
- # raw simulators only understand numpy - wrapper chains from this package handle torch actions
90
+ # raw simulators only understand numpy — wrapper chains and torch-native sims take torch
90
91
 
91
- if self.raw_env and is_tensor(actions):
92
+ if is_tensor(actions) and self.convert_in:
92
93
  actions = to_numpy(actions)
93
94
 
94
95
  chunk_axis = actions.ndim - 1 - len(self.action_shape)
@@ -9,6 +9,7 @@ from torch import nn, is_tensor
9
9
  from torch.distributions import Categorical, Distribution
10
10
  from torch.utils._pytree import tree_flatten, tree_map
11
11
 
12
+ from .standardize.spaces import action_space_is_discrete
12
13
  from .standardize.helpers import (
13
14
  EnvWrapper,
14
15
  env_autoresets,
@@ -93,7 +94,19 @@ def to_action(env, out, deterministic = True):
93
94
  if not deterministic:
94
95
  return out.sample()
95
96
 
96
- return out.probs.argmax(dim = -1) if exists(get_attr(out, 'probs')) else out.mean
97
+ probs = get_attr(out, 'probs')
98
+
99
+ # categorical-style probs carry a trailing action axis to argmax over
100
+
101
+ if exists(probs) and probs.ndim >= 2 and probs.shape[-1] > 1:
102
+ return probs.argmax(dim = -1)
103
+
104
+ # bernoulli-style probabilities threshold at 0.5
105
+
106
+ if exists(probs):
107
+ return (probs > 0.5).long()
108
+
109
+ return out.mean
97
110
 
98
111
  if is_tensor(out) and out.is_floating_point() and out.ndim > 0:
99
112
  num_actions = get_attr(get_adapter(env).action_space, 'n')
@@ -128,15 +141,29 @@ def to_env_action(env, action):
128
141
  device = get_attr(env, 'device')
129
142
  action = action.to(device) if exists(device) else action
130
143
 
131
- # raw simulators take unbatched numpy
144
+ # raw simulators take unbatched numpy — torch-native sims take torch as-is (zero-copy)
132
145
 
133
146
  if not isinstance(env, EnvWrapper):
147
+ if get_adapter(env).torch_native:
148
+ return action
149
+
134
150
  action = to_numpy(action)
135
151
 
136
- if action.ndim > 1 and action.shape[0] == 1:
137
- action = action[0]
152
+ # single raw sims get the leading batch dim stripped, while discrete
153
+ # actions additionally collapse to scalars
154
+
155
+ if not is_vectorized(env):
156
+ if action.ndim > 1 and action.shape[0] == 1:
157
+ action = action[0]
158
+
159
+ if action_space_is_discrete(get_adapter(env).action_space):
160
+ if action.ndim > 0 and action.shape[0] == 1:
161
+ action = action[0]
162
+
163
+ if action.ndim == 0:
164
+ action = action.item()
138
165
 
139
- return action.item() if action.ndim == 0 else action
166
+ return action
140
167
 
141
168
  # wrappers take batched torch actions - pad up to the declared rank
142
169
 
@@ -166,6 +193,9 @@ def capture_frame(env):
166
193
  except Exception:
167
194
  return None
168
195
 
196
+ if not exists(frame):
197
+ return None
198
+
169
199
  if is_tensor(frame):
170
200
  frame = frame.detach().cpu().numpy()
171
201
 
@@ -197,6 +227,17 @@ def video_path_at(path, index):
197
227
  path = Path(path)
198
228
  return str(path if index == 0 else path.with_name(f'{path.stem}_ep{index}{path.suffix}'))
199
229
 
230
+ def safe_reset(env, seed = None):
231
+ # gymnasium resets accept seed; legacy sims reset without it
232
+
233
+ if exists(seed):
234
+ try:
235
+ return normalize_reset_out(env.reset(seed = seed))
236
+ except TypeError:
237
+ pass
238
+
239
+ return normalize_reset_out(env.reset())
240
+
200
241
  # evaluate
201
242
 
202
243
  @torch.no_grad()
@@ -238,7 +279,7 @@ def evaluate_actor(
238
279
 
239
280
  assert not (record and num_envs > 1), 'video recording requires a single env'
240
281
 
241
- obs, info = normalize_reset_out(env.reset(seed = seed) if exists(seed) else env.reset())
282
+ obs, info = safe_reset(env, seed)
242
283
  batched = is_vectorized(env) or all(map(is_tensor, tree_flatten(obs)[0]))
243
284
 
244
285
  goal = goal.expand(num_envs, -1) if exists(goal) and goal.shape[0] == 1 and num_envs > 1 else goal
@@ -246,6 +287,7 @@ def evaluate_actor(
246
287
  returns = torch.zeros(num_envs)
247
288
  lengths = torch.zeros(num_envs)
248
289
  steps = torch.zeros(num_envs, dtype = torch.long)
290
+ active = np.ones(num_envs, dtype = bool)
249
291
 
250
292
  done_returns, done_lengths, videos, frames = [], [], [], []
251
293
 
@@ -282,7 +324,12 @@ def evaluate_actor(
282
324
  if exists(max_steps):
283
325
  done |= to_numpy(steps >= max_steps).reshape(-1)
284
326
 
285
- for i in np.where(done)[0]:
327
+ # a slot is only recorded once per episode — non-autoreset slots
328
+ # latch their done flag until the whole vector env resets
329
+
330
+ newly_done = done & active
331
+
332
+ for i in np.where(newly_done)[0]:
286
333
  if len(done_returns) >= episodes:
287
334
  break
288
335
 
@@ -306,6 +353,7 @@ def evaluate_actor(
306
353
  returns[i] = 0.
307
354
  lengths[i] = 0.
308
355
  steps[i] = 0
356
+ active[i] = False
309
357
 
310
358
  if done.any() and len(done_returns) < episodes:
311
359
  if record:
@@ -315,7 +363,10 @@ def evaluate_actor(
315
363
 
316
364
  if not env_autoresets(env) and (num_envs == 1 or done.all() or bool(get_attr(env, 'needs_reset', False))):
317
365
  reset_seed = seed + len(done_returns) if exists(seed) and num_envs == 1 else None
318
- obs, info = normalize_reset_out(env.reset(seed = reset_seed) if exists(reset_seed) else env.reset())
366
+ obs, info = safe_reset(env, reset_seed)
367
+ active[:] = True
368
+ elif env_autoresets(env):
369
+ active[newly_done] = True
319
370
  finally:
320
371
  if exists(module) and exists(was_training):
321
372
  module.train(was_training)
@@ -3,7 +3,6 @@ from typing import Sequence
3
3
 
4
4
  import torch
5
5
  from torch import is_tensor
6
- from torch_einops_utils import pad_right_ndim_to
7
6
 
8
7
  from .standardize.helpers import (
9
8
  TransformObservationWrapper,
@@ -27,7 +26,11 @@ def calc_lerp_weight(lam, done, x):
27
26
  done = torch.as_tensor(done, device = x.device)
28
27
 
29
28
  weight = torch.where(done, 1., 1. - lam).to(x)
30
- return pad_right_ndim_to(weight, x.ndim)
29
+
30
+ if x.ndim <= weight.ndim:
31
+ return weight
32
+
33
+ return weight.reshape(*weight.shape, *((1,) * (x.ndim - weight.ndim)))
31
34
 
32
35
  # Partially Observable Reinforcement Learning with Memory Traces - Eberhard et al.
33
36
  # https://arxiv.org/abs/2503.15200
@@ -34,6 +34,7 @@ from .adapters import (
34
34
  BaseEnvAdapter,
35
35
  get_adapter,
36
36
  register_adapter,
37
+ MjlabAdapter,
37
38
  MujocoWarpAdapter,
38
39
  IsaacAdapter,
39
40
  PyBulletAdapter,
@@ -68,6 +69,7 @@ from .mocks import (
68
69
  AutoresetVectorMockEnv,
69
70
  PufferVectorMockEnv,
70
71
  PufferTensorMockEnv,
72
+ MjlabMockEnv,
71
73
  ManiSkillMockEnv,
72
74
  BraxMockEnv,
73
75
  MjxMockEnv,
@@ -118,9 +120,11 @@ __all__ = [
118
120
  'MultiprocessingVecEnv',
119
121
  'StandardizeEnvWrapper',
120
122
  'StandardizeEnv',
123
+ 'StandardizedEnv',
121
124
  'BaseEnvAdapter',
122
125
  'get_adapter',
123
126
  'register_adapter',
127
+ 'MjlabAdapter',
124
128
  'MujocoWarpAdapter',
125
129
  'IsaacAdapter',
126
130
  'PyBulletAdapter',
@@ -149,6 +153,7 @@ __all__ = [
149
153
  'AutoresetVectorMockEnv',
150
154
  'PufferVectorMockEnv',
151
155
  'PufferTensorMockEnv',
156
+ 'MjlabMockEnv',
152
157
  'ManiSkillMockEnv',
153
158
  'BraxMockEnv',
154
159
  'MjxMockEnv',
@@ -5,7 +5,6 @@ import torch
5
5
  from torch import is_tensor
6
6
  from torch.utils._pytree import tree_map
7
7
  from functools import partial
8
- from einops import rearrange
9
8
 
10
9
  from .helpers import EnvWrapper, copy_leaf, default, exists, first_existing, get_attr
11
10
  from .spaces import space_from_action_spec
@@ -95,7 +94,7 @@ class ActionTransformWrapper(EnvWrapper):
95
94
  was_scalar = t.ndim == 0
96
95
 
97
96
  if was_scalar:
98
- t = rearrange(t, '-> 1')
97
+ t = t[None]
99
98
 
100
99
  # bounds may be unbatched (single_action_space) or already batched
101
100
  # (vector envs exposing only a batched action_space)
@@ -105,22 +104,28 @@ class ActionTransformWrapper(EnvWrapper):
105
104
  low = np.broadcast_to(low, (dim,))
106
105
  high = np.broadcast_to(high, (dim,))
107
106
 
107
+ # invalid (unbounded) dims pass through — neutral bounds keep inf out of the arithmetic
108
+
108
109
  valid = np.isfinite(low) & np.isfinite(high)
109
110
 
110
111
  if is_tensor(t):
111
- low = torch.tensor(low, device = t.device, dtype = t.dtype)
112
- high = torch.tensor(high, device = t.device, dtype = t.dtype)
113
- valid = torch.tensor(valid, device = t.device)
112
+ valid_t = torch.as_tensor(valid, device = t.device)
113
+ low = torch.as_tensor(np.where(valid, low, 0.), device = t.device, dtype = t.dtype)
114
+ high = torch.as_tensor(np.where(valid, high, 1.), device = t.device, dtype = t.dtype)
115
+ else:
116
+ valid_t = valid
117
+ low = np.where(valid, low, 0.)
118
+ high = np.where(valid, high, 1.)
114
119
 
115
120
  rescaled = rescale(t, self.from_range, (low, high))
116
121
 
117
122
  if is_tensor(t):
118
- rescaled = torch.where(valid, torch.clamp(rescaled, low, high), t)
123
+ rescaled = torch.where(valid_t, torch.clamp(rescaled, low, high), t)
119
124
  else:
120
- rescaled = np.where(valid, np.clip(rescaled, low, high), t)
125
+ rescaled = np.where(valid_t, np.clip(rescaled, low, high), t)
121
126
 
122
127
  if was_scalar:
123
- rescaled = rearrange(rescaled, '1 ->')
128
+ rescaled = rescaled[0]
124
129
 
125
130
  return rescaled
126
131
 
@@ -129,8 +134,6 @@ class ActionTransformWrapper(EnvWrapper):
129
134
  if not is_float_dtype(t):
130
135
  return t
131
136
 
132
- t = copy_leaf(t)
133
-
134
137
  for ind, transform in enumerate(self.transforms):
135
138
  indices = transform.get('indices', ind if len(self.transforms) > 1 else None)
136
139
 
@@ -141,6 +144,9 @@ class ActionTransformWrapper(EnvWrapper):
141
144
  fn = partial(rescale, from_range = from_range, to_range = to_range)
142
145
 
143
146
  if exists(indices):
147
+ # copy only when writing in place, so the caller's action is never mutated
148
+
149
+ t = copy_leaf(t)
144
150
  t[..., indices] = fn(t[..., indices])
145
151
  else:
146
152
  t = fn(t)
@@ -6,18 +6,20 @@ from .helpers import (
6
6
  exists,
7
7
  first_existing,
8
8
  get_attr,
9
- is_time_step,
10
9
  normalize_reset_out,
11
10
  normalize_step_out,
12
11
  safe_close,
13
12
  truthy_attr,
14
- zero_like,
15
13
  )
16
14
  from .spaces import space_from_action_spec
17
15
 
18
16
  # base adapter
19
17
 
20
18
  class BaseEnvAdapter:
19
+ # torch-native sims consume torch actions directly on device — no numpy roundtrip
20
+
21
+ torch_native = False
22
+
21
23
  @classmethod
22
24
  def matches(cls, env) -> bool:
23
25
  return False
@@ -148,6 +150,10 @@ class WrapperAdapter(BaseEnvAdapter):
148
150
  val = first_existing(self.env, 'autoreset', 'autoresets', 'autoreset_mode')
149
151
  return truthy_attr(val) if exists(val) else self.inner_adapter.autoresets
150
152
 
153
+ @property
154
+ def torch_native(self) -> bool:
155
+ return self.inner_adapter.torch_native
156
+
151
157
  @property
152
158
  def action_space(self):
153
159
  return default(
@@ -194,13 +200,8 @@ class DMControlAdapter(BaseEnvAdapter):
194
200
  def action_space(self):
195
201
  return default(super().action_space, space_from_action_spec(self.env))
196
202
 
197
- @property
198
- def is_vectorized(self) -> bool:
199
- return False
200
-
201
- @property
202
- def autoresets(self) -> bool:
203
- return False
203
+ is_vectorized = False
204
+ autoresets = False
204
205
 
205
206
  # pybullet adapter
206
207
 
@@ -227,17 +228,32 @@ class PyBulletAdapter(BaseEnvAdapter):
227
228
  return
228
229
  super().seed(seed)
229
230
 
230
- @property
231
- def is_vectorized(self) -> bool:
232
- return False
231
+ is_vectorized = False
232
+ autoresets = False
233
+
234
+ # mjlab adapter (manager-based rl envs on mujoco warp)
235
+
236
+ class MjlabAdapter(BaseEnvAdapter):
237
+ torch_native = True
238
+ is_vectorized = True
239
+
240
+ @classmethod
241
+ def matches(cls, env):
242
+ mod = getattr(type(env), '__module__', '')
243
+ name = type(env).__name__
244
+ return 'mjlab' in mod.lower() or name == 'ManagerBasedRlEnv'
233
245
 
234
246
  @property
235
247
  def autoresets(self) -> bool:
236
- return False
248
+ return truthy_attr(get_attr(get_attr(self.env, 'cfg'), 'auto_reset', True))
237
249
 
238
250
  # isaac sim adapter (isaac gym / isaac lab / omniverse)
239
251
 
240
252
  class IsaacAdapter(BaseEnvAdapter):
253
+ torch_native = True
254
+ is_vectorized = True
255
+ autoresets = True
256
+
241
257
  @classmethod
242
258
  def matches(cls, env):
243
259
  mod = getattr(type(env), '__module__', '')
@@ -245,14 +261,6 @@ class IsaacAdapter(BaseEnvAdapter):
245
261
  isaac_kw = ('isaac', 'omni.isaac', 'isaacgym', 'isaaclab')
246
262
  return any(k in mod.lower() for k in isaac_kw) or 'Isaac' in name or exists(get_attr(env, 'sim_device')) or exists(get_attr(env, 'physics_sim_view'))
247
263
 
248
- @property
249
- def is_vectorized(self) -> bool:
250
- return True
251
-
252
- @property
253
- def autoresets(self) -> bool:
254
- return True
255
-
256
264
  # mujoco warp / warp / brax / mjx adapter
257
265
 
258
266
  class MujocoWarpAdapter(BaseEnvAdapter):
@@ -276,16 +284,14 @@ class MujocoWarpAdapter(BaseEnvAdapter):
276
284
  # pufferlib adapter
277
285
 
278
286
  class PufferLibAdapter(BaseEnvAdapter):
287
+ is_vectorized = True
288
+
279
289
  @classmethod
280
290
  def matches(cls, env):
281
291
  mod = getattr(type(env), '__module__', '')
282
292
  name = type(env).__name__
283
293
  return 'Puffer' in name or 'pufferlib' in mod or exists(get_attr(env, 'puffer_env'))
284
294
 
285
- @property
286
- def is_vectorized(self) -> bool:
287
- return True
288
-
289
295
  @property
290
296
  def autoresets(self) -> bool:
291
297
  return truthy_attr(first_existing(self.env, 'autoreset', 'autoresets', 'autoreset_mode'))
@@ -350,6 +356,18 @@ class GymnasiumAdapter(BaseEnvAdapter):
350
356
  pass
351
357
  return self.num_envs > 1
352
358
 
359
+ def unwrapped_adapter(self):
360
+ # foreign gym wrappers (TimeLimit, RecordEpisodeStatistics, ...) inherit
361
+ # the unwrapped sim's adapter traits
362
+
363
+ inner = get_attr(self.env, 'unwrapped')
364
+ return get_adapter(inner) if exists(inner) and inner is not self.env else None
365
+
366
+ @property
367
+ def torch_native(self) -> bool:
368
+ inner = self.unwrapped_adapter()
369
+ return exists(inner) and inner.torch_native
370
+
353
371
  @property
354
372
  def autoresets(self) -> bool:
355
373
  mode = first_existing(self.env, 'autoreset', 'autoresets', 'autoreset_mode')
@@ -359,9 +377,13 @@ class GymnasiumAdapter(BaseEnvAdapter):
359
377
 
360
378
  try:
361
379
  from gymnasium.vector import VectorEnv
362
- return isinstance(self.env, VectorEnv)
380
+ if isinstance(self.env, VectorEnv):
381
+ return True
363
382
  except ImportError:
364
- return False
383
+ pass
384
+
385
+ inner = self.unwrapped_adapter()
386
+ return exists(inner) and inner.autoresets
365
387
 
366
388
  def seed(self, seed: int):
367
389
  try:
@@ -400,6 +422,7 @@ class DefaultAdapter(BaseEnvAdapter):
400
422
 
401
423
  ADAPTER_REGISTRY: list[type[BaseEnvAdapter]] = [
402
424
  WrapperAdapter,
425
+ MjlabAdapter,
403
426
  MujocoWarpAdapter,
404
427
  IsaacAdapter,
405
428
  PyBulletAdapter,
@@ -5,7 +5,6 @@ from functools import partial
5
5
  import numpy as np
6
6
  import torch
7
7
  from torch.utils._pytree import tree_map
8
- from einops import rearrange
9
8
 
10
9
  from .helpers import (
11
10
  FINAL_OBSERVATION_KEYS,
@@ -19,7 +18,6 @@ from .helpers import (
19
18
  is_tensor,
20
19
  is_vectorized,
21
20
  )
22
- from .spaces import space_from_action_spec
23
21
 
24
22
  # helper functions
25
23
 
@@ -41,22 +39,12 @@ def maybe_expand_dim(x):
41
39
 
42
40
  def _expand(t):
43
41
  arr = to_numeric_array(t)
42
+ return arr[None] if is_array(arr) else t
44
43
 
45
- if not is_array(arr):
46
- return t
44
+ if is_array(x):
45
+ return x[None]
47
46
 
48
- if arr.ndim == 0:
49
- return rearrange(arr, '-> 1')
50
-
51
- return rearrange(arr, '... -> 1 ...')
52
-
53
- if is_tensor(x):
54
- return rearrange(x, '-> 1') if x.ndim == 0 else rearrange(x, '... -> 1 ...')
55
-
56
- if isinstance(x, np.ndarray) and x.dtype.kind in 'biufc':
57
- return rearrange(x, '-> 1') if x.ndim == 0 else rearrange(x, '... -> 1 ...')
58
-
59
- return tree_map(_expand, x)
47
+ return tree_map(_expand, x) if isinstance(x, (dict, list, tuple)) else _expand(x)
60
48
 
61
49
  def is_integer_dtype(t):
62
50
  if is_tensor(t):
@@ -121,11 +109,11 @@ def heuristic_leaf(t, is_vector = False):
121
109
  return arr
122
110
 
123
111
  if not is_vector and arr.ndim > 1 and arr.shape[0] == 1:
124
- arr = rearrange(arr, '1 ... -> ...')
112
+ arr = arr.reshape(*arr.shape[1:])
125
113
 
126
114
  if is_integer_dtype(arr):
127
115
  while arr.ndim > 1 and arr.shape[-1] == 1:
128
- arr = rearrange(arr, '... 1 -> ...')
116
+ arr = arr.reshape(*arr.shape[:-1])
129
117
 
130
118
  if not is_vector and (arr.numel() if is_tensor(arr) else arr.size) == 1:
131
119
  return arr.item()
@@ -207,7 +195,8 @@ class AutoBatchedWrapper(EnvWrapper):
207
195
  if self.is_vector:
208
196
  return out
209
197
 
210
- obs, reward, terminated, truncated, info = *maybe_expand_dim(out[:4]), out[4]
198
+ obs, reward, terminated, truncated, info = out
199
+ obs, reward, terminated, truncated = (maybe_expand_dim(t) for t in (obs, reward, terminated, truncated))
211
200
 
212
201
  if isinstance(info, dict):
213
202
  for key in FINAL_OBSERVATION_KEYS:
@@ -6,7 +6,6 @@ import numpy as np
6
6
  import torch
7
7
  from torch import is_tensor
8
8
  from torch.utils._pytree import tree_map
9
- from einops import rearrange
10
9
 
11
10
  from .helpers import (
12
11
  EnvWrapper,
@@ -28,7 +27,7 @@ def broadcast_mask(mask, x):
28
27
  m = torch.as_tensor(mask, device = x.device, dtype = torch.bool)
29
28
  diff = x.ndim - m.ndim
30
29
 
31
- return rearrange(m, f'... -> ... {" ".join(["1"] * diff)}') if diff > 0 else m
30
+ return m.reshape(*m.shape, *((1,) * diff)) if diff > 0 else m
32
31
 
33
32
  def zero_mask(x, mask, fill_scalar = None):
34
33
  if is_tensor(x):
@@ -4,7 +4,6 @@ import numpy as np
4
4
  import torch
5
5
  from torch import is_tensor
6
6
  from torch.utils._pytree import tree_flatten
7
- from einops import rearrange
8
7
 
9
8
  from .helpers import (
10
9
  TransformObservationWrapper,
@@ -30,17 +29,9 @@ def flatten_leaf(t, is_vector = False):
30
29
  t = np.asarray(t)
31
30
 
32
31
  if not is_vector:
33
- if t.ndim == 0:
34
- return rearrange(t, '-> 1')
35
- return rearrange(t, '... -> (...)')
32
+ return t.reshape(1) if t.ndim == 0 else t.reshape(-1)
36
33
 
37
- if t.ndim == 0:
38
- return rearrange(t, '-> 1 1')
39
-
40
- if t.ndim == 1:
41
- return rearrange(t, 'b -> b 1')
42
-
43
- return rearrange(t, 'b ... -> b (...)')
34
+ return t.reshape(1, 1) if t.ndim == 0 else t.reshape(t.shape[0], -1)
44
35
 
45
36
  def concat_leaves(leaves, is_vector = False):
46
37
  axis = -1 if is_vector else 0
@@ -84,9 +84,6 @@ def copy_leaf(x):
84
84
 
85
85
  return x
86
86
 
87
- def copy_tree(tree):
88
- return tree_map(copy_leaf, tree)
89
-
90
87
  def dones_of(terminated, truncated):
91
88
  if not isinstance(terminated, (dict, list, tuple)):
92
89
  return terminated | truncated
@@ -138,6 +135,8 @@ def _zero_leaf(x):
138
135
  return False
139
136
  if isinstance(x, (int, float, np.number)):
140
137
  return np.zeros_like(x)
138
+ if exists(get_attr(x, '__array__')):
139
+ return np.zeros_like(np.asarray(x))
141
140
  return 0
142
141
 
143
142
  def _stack_leaves(leaves):
@@ -39,14 +39,10 @@ def render_frame(env, image_size = (64, 64), camera = None):
39
39
 
40
40
  img = env.render()
41
41
 
42
- if isinstance(img, (list, tuple)):
43
- img = np.ascontiguousarray(img)
44
-
45
42
  if isinstance(img, torch.Tensor):
46
43
  img = img.detach().cpu().numpy()
47
44
 
48
- img = np.ascontiguousarray(img)
49
- img = torch.from_numpy(img)
45
+ img = torch.from_numpy(np.ascontiguousarray(img))
50
46
 
51
47
  if img.ndim == 4:
52
48
  return rearrange(img, 'b h w c -> b c h w'), True
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from types import SimpleNamespace
3
4
  from typing import NamedTuple
4
5
 
5
6
  import numpy as np
@@ -391,6 +392,52 @@ class IsaacLabMockEnv(MockEnv):
391
392
 
392
393
  return self.obs(), torch.randn(self.num_envs), dones, torch.zeros(self.num_envs, dtype = torch.bool), {}
393
394
 
395
+ # mjlab — manager-based RL on mujoco warp: always vectorized, torch-native
396
+ # (step takes torch tensors only), autoresets within step, dict obs of torch tensors
397
+
398
+ class MjlabMockEnv(MockEnv):
399
+ num_envs = 4
400
+ is_vector = True
401
+ is_vector_env = True
402
+
403
+ def __init__(self, num_envs = 4, auto_reset = True, seed = 0):
404
+ self.num_envs = num_envs
405
+ self.cfg = SimpleNamespace(auto_reset = auto_reset)
406
+ super().__init__(seed)
407
+
408
+ def reset(self, *, seed = None, env_ids = None, options = None):
409
+ if seed is not None:
410
+ self.seed(seed)
411
+ else:
412
+ self.reset_state()
413
+ return self.obs(), {}
414
+
415
+ def obs(self):
416
+ return dict(
417
+ policy = torch.randn(self.num_envs, self.obs_dim),
418
+ critic = torch.randn(self.num_envs, self.obs_dim * 2)
419
+ )
420
+
421
+ def step(self, action):
422
+ if not is_tensor(action):
423
+ raise TypeError(f'mjlab envs step on torch tensors, got {type(action).__name__}')
424
+
425
+ self.advance(action)
426
+ self.last_action = action
427
+
428
+ dones = torch.from_numpy(self.is_done())
429
+
430
+ if self.cfg.auto_reset:
431
+ for ind in np.where(dones.numpy())[0]:
432
+ self.t[ind] = 0
433
+ self.state[ind] = 0
434
+
435
+ return self.obs(), torch.ones(self.num_envs), dones, torch.zeros(self.num_envs, dtype = torch.bool), {}
436
+
437
+ # live in the real module path so the adapter recognizes the mock
438
+
439
+ MjlabMockEnv.__module__ = 'mjlab.envs.manager_based_rl_env'
440
+
394
441
  # maniskill — gymnasium-compliant, but always batched even at num_envs = 1:
395
442
  # obs / reward / dones are torch tensors with a leading batch dim, and the
396
443
  # env exposes single_action_space / single_observation_space (unbatched)
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import numpy as np
4
4
  from torch.utils._pytree import tree_map
5
5
 
6
- from .helpers import default, exists, first_existing, get_attr, to_numpy
6
+ from .helpers import default, exists, get_attr, to_numpy
7
7
 
8
8
  # stand-in for environments that do not expose gymnasium spaces
9
9
 
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import torch
4
- from .helpers import EnvWrapper, exists, default, is_vectorized
4
+ from .helpers import EnvWrapper, exists, is_vectorized
5
5
  from .utils import compose_env
6
6
 
7
7
  class StandardizeEnvWrapper(EnvWrapper):
@@ -12,6 +12,7 @@ from .helpers import (
12
12
  FINAL_OBSERVATION_KEYS,
13
13
  FINAL_OBSERVATION_MASK_KEYS,
14
14
  exists,
15
+ get_adapter,
15
16
  get_attr,
16
17
  is_scalar,
17
18
  unpack_vector_observations,
@@ -31,6 +32,10 @@ def to_torch_leaf(t, device, cast_obs_to_float = True):
31
32
  return t
32
33
 
33
34
  dtype = torch.float32 if cast_obs_to_float and t.dtype != torch.bool else t.dtype
35
+
36
+ if t.dtype == dtype and t.device == device:
37
+ return t
38
+
34
39
  return t.to(device = device, dtype = dtype)
35
40
 
36
41
  def numpy_to_torch(x, device, cast_obs_to_float = True):
@@ -71,7 +76,11 @@ def torch_to_numpy(x):
71
76
  def contract(t, to_float = False):
72
77
  if not is_tensor(t):
73
78
  return t
74
- return t.float() if to_float else t.bool()
79
+
80
+ if to_float:
81
+ return t if t.dtype == torch.float32 else t.float()
82
+
83
+ return t if t.dtype == torch.bool else t.bool()
75
84
 
76
85
  # class
77
86
 
@@ -96,10 +105,15 @@ class TensorWrapper(EnvWrapper):
96
105
  self.convert_in = convert_in
97
106
  self.convert_out = convert_out
98
107
  self.cast_obs_to_float = cast_obs_to_float
108
+
109
+ # torch-native sims (mjlab, isaac) take torch actions as-is — zero-copy passthrough
110
+
111
+ self.torch_native = get_adapter(env).torch_native
112
+
99
113
  self.cast = partial(numpy_to_torch, device = self.device, cast_obs_to_float = self.cast_obs_to_float)
100
114
 
101
115
  def cast_info(self, info):
102
- if not isinstance(info, dict):
116
+ if not isinstance(info, dict) or not info:
103
117
  return
104
118
 
105
119
  for key in FINAL_OBSERVATION_KEYS:
@@ -120,13 +134,19 @@ class TensorWrapper(EnvWrapper):
120
134
  return obs, info
121
135
 
122
136
  def to_contract(self, t, to_float = False):
123
- if not isinstance(t, (dict, list, tuple)):
124
- leaf = to_torch_leaf(t, self.device, cast_obs_to_float = False)
137
+ def convert(leaf):
138
+ leaf = to_torch_leaf(leaf, self.device, cast_obs_to_float = False)
125
139
  return contract(leaf, to_float = to_float)
126
- return contract(self.cast(t), to_float = to_float)
140
+
141
+ if not isinstance(t, (dict, list, tuple)):
142
+ return convert(t)
143
+
144
+ return tree_map(convert, t)
127
145
 
128
146
  def step(self, action):
129
- action = torch_to_numpy(action) if self.convert_in else action
147
+ if self.convert_in and not self.torch_native:
148
+ action = torch_to_numpy(action)
149
+
130
150
  obs, reward, terminated, truncated, info = self.env.step(action)
131
151
 
132
152
  if not self.convert_out:
@@ -2,9 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  from multiprocessing import get_context
4
4
  import numpy as np
5
- import torch
6
5
  from torch import is_tensor
7
- from torch.utils._pytree import tree_flatten, tree_map, tree_structure, tree_unflatten
6
+ from torch.utils._pytree import tree_map
8
7
 
9
8
  from .helpers import (
10
9
  any_true,
@@ -119,6 +118,9 @@ class _InlineConn:
119
118
  def recv(self):
120
119
  return self._result
121
120
 
121
+ def close(self):
122
+ pass
123
+
122
124
  def _safe_send(conn, msg):
123
125
  """Send that swallows broken-pipe — for broadcast patterns where _recv_all reports the error."""
124
126
  try:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "env-ssl-wrapper"
3
- version = "0.5.0"
3
+ version = "0.5.2"
4
4
  description = "One torch-native interface for any MDP environment"
5
5
  authors = [
6
6
  { name = "Phil Wang", email = "lucidrains@gmail.com" }
@@ -24,16 +24,11 @@ classifiers=[
24
24
  ]
25
25
 
26
26
  dependencies = [
27
- "discrete-continuous-embed-readout",
28
- "einx>=0.3.0",
29
27
  "einops>=0.8.1",
30
28
  "imageio",
31
29
  "imageio-ffmpeg",
32
- "memmap-replay-buffer",
30
+ "numpy>=1.24",
33
31
  "torch>=2.5",
34
- "torch-einops-utils>=0.0.29",
35
- "x-transformers",
36
- "x-mlps-pytorch",
37
32
  ]
38
33
 
39
34
  [project.urls]
File without changes