Skip to content

Commit d2e0e9c

Browse files
authored
fix(queries,tasks): return per-env (num_envs,) shape for contact query and draw _terminated (#781)
ContactData raw-MuJoCo branch rebound the (num_envs,) array to a bare `True` on a detected contact, returning a 0-d tensor while the MJX branch returns (num_env,) — same query, divergent shape. Write `has_contact[0] = True` (raw MuJoCo is single-env; MujocoHandler rejects num_envs>1) and return via from_numpy to keep the (num_envs,) shape. draw_svg/draw_triangle _terminated returned torch.tensor([False]) — fixed shape (1,) on the default device — mismatching the (num_envs,) done/timeout bookkeeping for num_envs>1 and crashing on GPU (CPU terminated vs CUDA timeout). Return torch.zeros(self.num_envs, dtype=torch.bool, device=self.device), matching the base _terminated contract. Adds general regression tests (stub mujoco handler for the query; __new__ tasks for _terminated). Red on pre-fix.
1 parent 684132a commit d2e0e9c

5 files changed

Lines changed: 97 additions & 4 deletions

File tree

roboverse_pack/queries/contact.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,12 @@ def __call__(self):
6868
con.geom1 == self._geom2_id and con.geom2 == self._geom1_id
6969
):
7070
if con.dist < 1e-6:
71-
has_contact = True
71+
has_contact[0] = True
7272
break
7373

74-
return torch.tensor(has_contact, dtype=torch.float32, device=self.handler.device)
74+
# Keep the per-env array shape (num_envs,) to match the MJX branch.
75+
# Rebinding ``has_contact`` to a bare ``True`` produced a 0-d tensor,
76+
# diverging from MJX's (num_env,) output for the same query.
77+
return torch.from_numpy(has_contact).to(dtype=torch.float32, device=self.handler.device)
7578

7679
raise ValueError(f"Unsupported handler type: {type(self.handler)} for ContactData query")

roboverse_pack/tasks/maniskill/draw_svg.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class DrawSvgTask(ManiskillBaseTask):
2424
# rewrite terminate
2525
def _terminated(self, states: TensorState) -> torch.Tensor:
2626
"""No terminate condition yet. Will terminate when time is up."""
27-
return torch.tensor([False])
27+
return torch.zeros(self.num_envs, dtype=torch.bool, device=self.device)
2828

2929
# rewrite checker
3030
def reset(self, states=None, env_ids=None):

roboverse_pack/tasks/maniskill/draw_triangle.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class DrawTriangleTask(ManiskillBaseTask):
2424
# rewrite terminate
2525
def _terminated(self, states: TensorState) -> torch.Tensor:
2626
"""No terminate condition yet. Will terminate when time is up."""
27-
return torch.tensor([False])
27+
return torch.zeros(self.num_envs, dtype=torch.bool, device=self.device)
2828

2929
# rewrite checker
3030
def reset(self, states=None, env_ids=None):

tests/test_contact_query_shape.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Regression: ContactData returns a per-env (num_envs,) tensor on raw MuJoCo.
2+
3+
The raw-MuJoCo branch built ``has_contact = np.zeros(num_envs)`` but, on a
4+
detected contact, rebound the name to a bare ``True`` — so ``torch.tensor(True)``
5+
returned a 0-d scalar, while the MJX branch returns shape ``(num_env,)``. Same
6+
query, divergent output shape across backends. This pins the (num_envs,) shape
7+
in both the contact-present and contact-absent cases, with a stub handler (no
8+
mujoco/jax/GPU needed).
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from types import SimpleNamespace
14+
15+
import pytest
16+
import torch
17+
18+
19+
def _mujoco_stub(contacts):
20+
"""A handler whose module looks like the raw-MuJoCo backend."""
21+
stub_cls = type("StubMujocoHandler", (), {})
22+
stub_cls.__module__ = "metasim.sim.mujoco.stub" # selects the raw-MuJoCo branch
23+
h = stub_cls()
24+
h.num_envs = 1
25+
h.device = "cpu"
26+
h.data = SimpleNamespace(ncon=len(contacts), contact=contacts)
27+
return h
28+
29+
30+
def _query(handler):
31+
from roboverse_pack.queries.contact import ContactData
32+
33+
q = ContactData("geom_a", "geom_b")
34+
q._geom1_id = 10
35+
q._geom2_id = 20
36+
q.handler = handler # bypass bind_handler (which needs a real mj model)
37+
return q
38+
39+
40+
@pytest.mark.general
41+
def test_contact_present_returns_per_env_tensor():
42+
contacts = [SimpleNamespace(geom1=10, geom2=20, dist=0.0)] # matching pair, in contact
43+
out = _query(_mujoco_stub(contacts))()
44+
assert out.shape == (1,), f"expected (num_envs,) = (1,), got {tuple(out.shape)}"
45+
assert out.dtype == torch.float32
46+
assert out[0] == 1.0
47+
48+
49+
@pytest.mark.general
50+
def test_contact_absent_returns_per_env_tensor():
51+
out = _query(_mujoco_stub([]))() # no contacts
52+
assert out.shape == (1,)
53+
assert out[0] == 0.0
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Regression: draw_svg / draw_triangle _terminated returns a per-env (num_envs,) tensor.
2+
3+
Both tasks returned ``torch.tensor([False])`` — a fixed shape ``(1,)`` on the
4+
default device — regardless of num_envs, mismatching the ``(num_envs,)`` timeout
5+
/ done bookkeeping when num_envs > 1. They now return
6+
``torch.zeros(self.num_envs, dtype=torch.bool, device=self.device)``. Backend-free:
7+
``_terminated`` ignores its ``states`` argument, so it can be called on a
8+
``__new__`` instance with num_envs/device set.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import pytest
14+
import torch
15+
16+
17+
@pytest.mark.general
18+
@pytest.mark.parametrize(
19+
"module_name,class_name",
20+
[
21+
("roboverse_pack.tasks.maniskill.draw_svg", "DrawSvgTask"),
22+
("roboverse_pack.tasks.maniskill.draw_triangle", "DrawTriangleTask"),
23+
],
24+
)
25+
def test_draw_terminated_is_per_env(module_name, class_name):
26+
import importlib
27+
28+
cls = getattr(importlib.import_module(module_name), class_name)
29+
task = cls.__new__(cls) # bypass __init__/backend
30+
task.num_envs = 4
31+
task.device = torch.device("cpu")
32+
33+
out = task._terminated(None) # _terminated ignores states
34+
35+
assert out.shape == (4,), f"{class_name}._terminated must be (num_envs,), got {tuple(out.shape)}"
36+
assert out.dtype == torch.bool
37+
assert not out.any()

0 commit comments

Comments
 (0)