Skip to content

Commit e9b5c6e

Browse files
authored
fix(tasks): review-loop fixes — parity revert + close Native* guardrail hole + CI enforcement (#801)
* revert(tasks): keep mujoco_playground pick/open_cabinet as step() overrides The #798 migration of these two delta-control tasks to _process_action was NOT strictly behavior-preserving at episode boundaries: the old step() rebinds self._last_action = real_actions AFTER super().step() runs auto-reset, so for envs that terminate this step the next-episode delta baseline is the final action; moving the latch into the pre-step _process_action hook makes the reset pose survive instead. A multi-angle review flagged this as a behavior change smuggled into a 'behaviour identical' refactor (and handover, unmigrated, now disagreed). Per the repo's load-bearing-parity rule, revert to the exact step() overrides and document why; a genuine delta-reset fix should be a separate, parity-tested change. Re-adds both to KNOWN_STEP_OVERRIDES. 7/7 guardrail tests pass; ruff clean. * fix(tests): close Native* guardrail bypass, seed NativeLiberoEnv, enforce in CI Multi-angle clean-context review found two real guardrail gaps: - The Tier-3 exemption was NAME-based (Native*/Passthrough*), so NativeLiberoEnv (libero/native_libero.py) — a first-party BaseTaskEnv subclass with a seed-less reset + step + close — silently dodged the contract. Make the exemption PATH-only (_native/ , _passthrough); in-contract classes are now always checked. Add seed to NativeLiberoEnv.reset (deterministic native replay; forwarded best-effort to handler.set_seed). Allowlist its native-port step/close explicitly. - The guardrail ran in NO CI (repo only had deploy-docs). Add .github/workflows/task-contract.yml running the backend-free guardrail on PRs touching tasks/ — so the contract is actually enforced, not manual-only. Also harden test_unified_bases_accept_seed to catch async reset, and drop a stray benchmark artifact swept into the revert commit. 7/7 guardrail tests pass; ruff clean; workflow YAML valid.
1 parent 6904649 commit e9b5c6e

5 files changed

Lines changed: 77 additions & 22 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: Task contract guardrail
2+
3+
# Enforces the backend-free task-architecture conformance guardrail
4+
# (tests/test_task_reset_seed_contract.py): no Tier-1 task may drop reset(seed)
5+
# or add a new step()/close() override beyond the shrink-only allowlists.
6+
# Pure-AST, no simulator/torch — fast and runs on every PR.
7+
8+
on:
9+
pull_request:
10+
paths:
11+
- "roboverse_pack/tasks/**"
12+
- "tests/test_task_reset_seed_contract.py"
13+
- ".github/workflows/task-contract.yml"
14+
push:
15+
branches: [main, develop]
16+
workflow_dispatch:
17+
18+
jobs:
19+
task-contract:
20+
runs-on: ubuntu-latest
21+
steps:
22+
- uses: actions/checkout@v4
23+
- uses: actions/setup-python@v5
24+
with:
25+
python-version: "3.11"
26+
- run: python -m pip install --upgrade pip pytest
27+
- name: Run task-contract guardrail
28+
run: python -m pytest tests/test_task_reset_seed_contract.py -v

roboverse_pack/tasks/libero/native_libero.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,13 @@ def _terminated(self, env_states=None) -> torch.Tensor:
165165
def _get_initial_states(self):
166166
return None
167167

168-
def reset(self, states=None, env_ids=None):
168+
def reset(self, states=None, env_ids=None, seed=None):
169+
# Native LIBERO replay is deterministic (fixed qpos0/qvel0, no RNG); ``seed`` is
170+
# accepted for the env.reset(seed=) contract and forwarded best-effort to the handler.
171+
if seed is not None:
172+
set_seed = getattr(self.handler, "set_seed", None)
173+
if callable(set_seed):
174+
set_seed(seed)
169175
nq, nv = self._m.nq, self._m.nv
170176
with self.handler.physics.reset_context():
171177
self._d.qpos[:] = self._qpos0[:nq]

roboverse_pack/tasks/mujoco_playground/open_cabinet.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -161,21 +161,23 @@ def reset(self, states=None, env_ids=None, seed=None):
161161

162162
return super().reset(states=states, env_ids=env_ids, seed=seed)
163163

164-
def _process_action(self, actions):
165-
"""Delta position control (sanctioned action hook).
164+
def step(self, actions):
165+
"""Step with delta control.
166166
167-
Replaces the old ``step`` override: ``RLTaskEnv.step`` calls
168-
``_process_action`` before clamping/applying, so behaviour is identical
169-
(delta + clamp, latch ``_last_action``) without overriding step.
167+
Kept as a ``step`` override (not ``_process_action``) to preserve the
168+
episode-boundary ``_last_action`` semantics: it is rebound to
169+
``real_actions`` *after* ``super().step()``'s auto-reset, so a migration
170+
to the pre-step hook would change behaviour for terminating envs.
170171
"""
171172
# mj: delta = action * self._config.action_scale
172173
# mj: ctrl = state.data.ctrl + delta
173174
# mj: ctrl = jp.clip(ctrl, self._lowers, self._uppers)
174175
delta_actions = actions * self._action_scale
175176
new_actions = self._last_action + delta_actions
176177
real_actions = torch.maximum(torch.minimum(new_actions, self._action_high), self._action_low)
178+
obs, reward, terminated, time_out, info = super().step(real_actions)
177179
self._last_action = real_actions
178-
return real_actions
180+
return obs, reward, terminated, time_out, info
179181

180182
def _reward_handle_target(self, env_states) -> torch.Tensor:
181183
"""Reward for bringing handle to target position."""

roboverse_pack/tasks/mujoco_playground/pick.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -125,18 +125,22 @@ def reset(self, states=None, env_ids=None, seed=None):
125125
self._last_action[env_ids] = self._initial_states.robots[self.robot_name].joint_pos[env_ids, :]
126126
return super().reset(states=states, env_ids=env_ids, seed=seed)
127127

128-
def _process_action(self, actions):
129-
"""Delta position control (sanctioned action hook).
130-
131-
Replaces the old ``step`` override: ``RLTaskEnv.step`` calls
132-
``_process_action`` before clamping/applying, so behaviour is identical
133-
(delta + clamp, latch ``_last_action``) without overriding step.
128+
def step(self, actions):
129+
"""Step with delta control.
130+
131+
NOTE: kept as a ``step`` override (not migrated to ``_process_action``)
132+
because ``_last_action`` is rebound to ``real_actions`` *after*
133+
``super().step()`` runs auto-reset — so for envs that terminate this
134+
step, the next-episode delta baseline is the final action, not the reset
135+
pose. Moving the latch into ``_process_action`` (pre-step) would change
136+
that episode-boundary behaviour, so this stays explicit and allowlisted.
134137
"""
135138
delta_actions = actions * self._action_scale
136139
new_actions = self._last_action + delta_actions
137140
real_actions = torch.maximum(torch.minimum(new_actions, self._action_high), self._action_low)
141+
obs, reward, terminated, time_out, info = super().step(real_actions)
138142
self._last_action = real_actions
139-
return real_actions
143+
return obs, reward, terminated, time_out, info
140144

141145
def _reward_box_target(self, env_states) -> torch.Tensor:
142146
"""Reward for bringing box to target position and orientation."""

tests/test_task_reset_seed_contract.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
1111
* **Tier 1** — classes in the unified contract: they (transitively) subclass
1212
``BaseTaskEnv`` / ``RLTaskEnv`` / ``ManagerBasedRVEnv``. Only these are checked.
13-
* **Tier 3** — external/native passthrough adapters (modules under ``_native``/
14-
``_passthrough`` or classes named ``Native*``/``Passthrough*``) are an
15-
explicitly-exempt compatibility tier and are skipped.
13+
* **Tier 3** — external/native passthrough adapters under dedicated ``_native``/
14+
``_passthrough`` paths are an explicitly-exempt compatibility tier and skipped.
15+
(Exemption is PATH-based only — a first-party in-contract task is checked even
16+
if its class name starts with ``Native``; see ``_is_tier3``.)
1617
* Non-task helpers (controllers, sensors, command/actuator managers, success
1718
evaluators, sessions) are not in the inheritance graph to ``BaseTaskEnv`` and
1819
are therefore ignored.
@@ -68,6 +69,9 @@
6869
# blocks any NEW Tier-1 step()/close() override so the architecture cannot degrade
6970
# while the existing ones are migrated. Remove an entry when you drop its override.
7071
KNOWN_STEP_OVERRIDES = frozenset({
72+
"libero/native_libero.py", # native LIBERO replay port (Tier-1 by base, kept explicit)
73+
"mujoco_playground/open_cabinet.py",
74+
"mujoco_playground/pick.py",
7175
"beyondmimic/metasim/envs/base_legged_robot.py",
7276
"calvin/base_table.py",
7377
"humanoid/base/base_legged_robot.py",
@@ -89,6 +93,7 @@
8993
"simpler_env/_metasim/place_task.py",
9094
})
9195
KNOWN_CLOSE_OVERRIDES = frozenset({
96+
"libero/native_libero.py",
9297
"robosuite/robosuite_env.py",
9398
})
9499

@@ -126,11 +131,17 @@ def _in_contract(name: str, graph: dict[str, set[str]], seen: set[str] | None =
126131

127132

128133
def _is_tier3(rel_path: str, class_name: str) -> bool:
129-
"""Native/passthrough adapters are an explicitly-exempt compatibility tier."""
134+
"""Native/passthrough adapters are an explicitly-exempt compatibility tier.
135+
136+
Exemption is PATH-based only (dedicated ``_native``/``_passthrough`` dirs/files).
137+
A NAME-based exemption (``Native*``/``Passthrough*``) was removed: it let
138+
first-party in-contract tasks (e.g. ``NativeLiberoEnv(BaseTaskEnv)``, which
139+
lives in ``libero/native_libero.py`` — not a ``_native/`` dir) silently dodge
140+
the contract. In-contract classes are now always checked regardless of name;
141+
only genuine adapters under ``_native``/``_passthrough`` paths are exempt.
142+
"""
130143
p = "/" + rel_path
131-
if "/_native/" in p or "_passthrough" in rel_path:
132-
return True
133-
return class_name.startswith(("Native", "Passthrough"))
144+
return "/_native/" in p or "_passthrough" in rel_path
134145

135146

136147
def _reset_lacks_seed(fn: ast.FunctionDef) -> bool:
@@ -207,7 +218,11 @@ def test_unified_bases_accept_seed():
207218
tree = ast.parse((_TASKS / rel).read_text(encoding="utf-8"))
208219
for cls in (n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)):
209220
for fn in cls.body:
210-
if isinstance(fn, ast.FunctionDef) and fn.name == "reset" and _reset_lacks_seed(fn):
221+
if (
222+
isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef))
223+
and fn.name == "reset"
224+
and _reset_lacks_seed(fn)
225+
):
211226
regressed.append(rel)
212227
assert not regressed, f"Base classes regressed to reset() without seed: {sorted(set(regressed))}"
213228

0 commit comments

Comments
 (0)