Skip to content

Commit 975b32f

Browse files
committed
feat(sim,training): actuator-keyed policies, vertical-camera fix, RL parity (evaluate/vec/gym/staged_reward)
Sim bug fixes (found by running policies across embodiments, each pinned): - robot_action_keys(): policies were keyed by ALL joint names incl. passive/ mimic followers and missed tendon actuators, so g1/aloha/xarm7/stretch/hands silently no-op'd. Now keyed by the robot's actuators (what send_action resolves). MuJoCo override + SimEngine ABC default (other backends unchanged). - spec_builder._target_quat: returned None for top-down/vertical cameras (forward parallel to world +Z -> degenerate cross). Falls back to +Y up. - mujoco render(output_path=...): persist PNG for verifiable renders, with path-traversal / shell-metachar guard on the LLM-supplied path. - eval_policy: auto-resolves the single robot (API parity with run_policy). - DeclarativeBenchmark.on_episode_start: reset stateful reward terms (no phase leak across episodes). RL training parity (4 gaps closed, CPU-tested, no new required deps): - predicates.staged_reward: declarative monotonic phase-machine reward (StatefulRewardTerm). Curricula authored as DATA via the closed predicate registry - never eval, never shipped task code. - base_algo.evaluate()/load_checkpoint()/_deterministic_action(): deterministic eval peer of train(), frozen normalizer, success_rate; works for PPO + SAC. - SimEnv.n_substeps (default 5): position-target PD needs substeps to track; n_substeps=1 was the "nothing learns" bug. Resets stateful reward terms. - PpoTrainer: vectorized rollout path; num_envs>1 now accepted (was hard- rejected). N=1 path byte-identical (dispatch on _vectorized). - VecSimEnv: N independent SimEnv, one reused thread pool, autoreset with terminal_obs capture (load-bearing for GAE bootstrap), homogeneity guard. - GymSimEnv: gymnasium adapter with correct terminated-vs-truncated split (the SB3 TimeLimit footgun fixed at the source) + critic-obs passthrough. Tests: 38 new CPU tests (vec_env, evaluate, vectorized-ppo, gym_env, staged_reward) + SO-101 e2e wiring/convergence integ. Updated 3 existing tests that encoded the old contracts (num_envs==1 rejection, eval_policy robot_name).
1 parent df1e3b2 commit 975b32f

24 files changed

Lines changed: 2266 additions & 26 deletions

strands_robots/simulation/base.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,17 @@ def list_robots(self) -> list[str]:
245245
"""
246246
...
247247

248+
def robot_action_keys(self, robot_name: str) -> list[str]:
249+
"""DRIVEABLE action keys a policy should emit (default: all joint names).
250+
251+
Backends with a distinct actuator set (e.g. MuJoCo, where passive/mimic
252+
joints have no actuator and tendons are actuators-not-joints) SHOULD
253+
override this to return the keys :meth:`send_action` actually resolves.
254+
The default mirrors :meth:`robot_joint_names` so existing backends keep
255+
their behaviour.
256+
"""
257+
return self.robot_joint_names(robot_name)
258+
248259
@abstractmethod
249260
def robot_joint_names(self, robot_name: str) -> list[str]:
250261
"""Return ordered joint names for ``robot_name``.
@@ -821,7 +832,7 @@ def run_policy(
821832
# Caller is responsible for policy.set_robot_state_keys(...) if needed,
822833
# but we set it here defensively so the semantics match the provider path.
823834
policy = policy_object
824-
policy.set_robot_state_keys(self.robot_joint_names(robot_name))
835+
policy.set_robot_state_keys(self.robot_action_keys(robot_name))
825836
self.bind_policy_sim_context(policy, robot_name)
826837

827838
# Auto-install any action controller this policy needs to run correctly
@@ -1449,14 +1460,17 @@ def eval_policy(
14491460
for cuRobo / MoveIt2 - the issue #300 contract). Without it the eval ran
14501461
such a policy with an empty goal and reported a meaningless success rate.
14511462
"""
1452-
if not robot_name:
1453-
return {
1454-
"status": "error",
1455-
"content": [{"text": "eval_policy requires 'robot_name'."}],
1456-
}
1463+
# Resolve an optional robot_name to a concrete one (None -> the single
1464+
# robot), matching run_policy's ergonomics. Previously eval_policy hard-
1465+
# required robot_name even with exactly one robot in the world, an API
1466+
# inconsistency with its sibling run_policy.
14571467
robots = self.list_robots()
14581468
if not robots:
14591469
return {"status": "error", "content": [{"text": "No robots in sim. Add one first."}]}
1470+
try:
1471+
robot_name = self._resolve_single_robot(robot_name)
1472+
except ValueError as exc:
1473+
return {"status": "error", "content": [{"text": str(exc)}]}
14601474
if robot_name not in robots:
14611475
return {
14621476
"status": "error",
@@ -1486,7 +1500,7 @@ def eval_policy(
14861500
# set robot_state_keys; we set defensively so semantics match the
14871501
# provider path.
14881502
policy = policy_object
1489-
policy.set_robot_state_keys(self.robot_joint_names(resolved_robot))
1503+
policy.set_robot_state_keys(self.robot_action_keys(resolved_robot))
14901504
self.bind_policy_sim_context(policy, resolved_robot)
14911505

14921506
return PolicyRunner(self).evaluate(
@@ -1637,7 +1651,7 @@ def evaluate_benchmark(
16371651
}
16381652

16391653
policy = create_policy(policy_provider, **(policy_config or {}))
1640-
policy.set_robot_state_keys(self.robot_joint_names(resolved_robot))
1654+
policy.set_robot_state_keys(self.robot_action_keys(resolved_robot))
16411655
self.bind_policy_sim_context(policy, resolved_robot)
16421656

16431657
return PolicyRunner(self).evaluate(

strands_robots/simulation/benchmark_spec.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,13 @@ def on_episode_start(self, sim: SimEngine, rng: random.Random) -> None:
236236
raise RuntimeError(
237237
f"DeclarativeBenchmark '{self._name}': load_scene({self._scene!r}) failed: {msg}"
238238
)
239+
# Reset any stateful reward terms (e.g. a staged_reward phase machine)
240+
# so per-episode phase state does not leak across episodes. Stateless
241+
# function terms have no reset() and are skipped.
242+
for term in self._reward_terms:
243+
term_reset = getattr(term, "reset", None)
244+
if callable(term_reset):
245+
term_reset()
239246
super().on_episode_start(sim, rng)
240247

241248
def on_step(self, sim: SimEngine, obs: dict[str, Any], action: dict[str, Any]) -> StepInfo:

strands_robots/simulation/mujoco/rendering.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -571,10 +571,21 @@ def _scale_ctrl_for_actuator(model: Any, ai: int, value: float, mj: Any) -> floa
571571
return lo + frac * span
572572

573573
def render(
574-
self, camera_name: str = "default", width: int | None = None, height: int | None = None
574+
self,
575+
camera_name: str = "default",
576+
width: int | None = None,
577+
height: int | None = None,
578+
output_path: str | None = None,
575579
) -> dict[str, Any]:
576580
"""Render a camera view to a PNG image.
577581
582+
When ``output_path`` is given the PNG is ALSO written to that file path
583+
(parent dirs created) and the saved path is reported in the ``json``
584+
block as ``saved_path`` and in the text summary. This lets an agent (or
585+
a human) persist a render for independent verification instead of only
586+
receiving the bytes inline.
587+
588+
578589
Returns an agent-tool dict with ``status`` and a ``content`` list; on
579590
success the content holds an ``image`` block carrying PNG bytes
580591
(``{"image": {"format": "png", "source": {"bytes": ...}}}``) plus a
@@ -662,12 +673,36 @@ def render(
662673
pixel_var = float(_np.var(img))
663674
pixel_mean = float(_np.mean(img))
664675

676+
saved_path: str | None = None
677+
if output_path:
678+
import os as _os
679+
680+
# Validate against shell/path-traversal injection (LLM-supplied).
681+
bad = {";", "|", "$", "`", ">", "<", "\n", "\r", "\x00"}
682+
if any(b in output_path for b in bad) or ".." in output_path.split("/"):
683+
return {
684+
"status": "error",
685+
"content": [{"text": f"render: unsafe output_path {output_path!r}"}],
686+
}
687+
_dir = _os.path.dirname(_os.path.abspath(output_path))
688+
_os.makedirs(_dir, exist_ok=True)
689+
with open(output_path, "wb") as _f:
690+
_f.write(png_bytes)
691+
saved_path = _os.path.abspath(output_path)
692+
693+
summary = f"{w}x{h} from '{label}' at t={self._world.sim_time:.3f}s"
694+
if saved_path:
695+
summary += f" -> saved {saved_path}"
696+
json_block = {"pixel_variance": pixel_var, "pixel_mean": pixel_mean, "camera": label}
697+
if saved_path:
698+
json_block["saved_path"] = saved_path
699+
665700
return {
666701
"status": "success",
667702
"content": [
668-
{"text": f"{w}x{h} from '{label}' at t={self._world.sim_time:.3f}s"},
703+
{"text": summary},
669704
{"image": {"format": "png", "source": {"bytes": png_bytes}}},
670-
{"json": {"pixel_variance": pixel_var, "pixel_mean": pixel_mean, "camera": label}},
705+
{"json": json_block},
671706
],
672707
}
673708
except Exception as e:

strands_robots/simulation/mujoco/simulation.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1096,6 +1096,40 @@ def robot_joint_names(self, robot_name: str) -> list[str]:
10961096
return []
10971097
return list(self._world.robots[robot_name].joint_names)
10981098

1099+
def robot_action_keys(self, robot_name: str) -> list[str]:
1100+
"""Ordered DRIVEABLE action keys for ``robot_name`` (actuator short names).
1101+
1102+
Overrides the ABC default. ``robot_joint_names`` returns ALL joints,
1103+
including passive/mimic followers (e.g. gripper finger joints) that have
1104+
no driving actuator, and it omits tendon-driven actuators (a grasp tendon
1105+
is an actuator, not a joint). A policy must key its actions by what the
1106+
engine can actually drive, which is the set of ACTUATORS scoped to the
1107+
robot's namespace - exactly what :meth:`send_action` resolves against.
1108+
1109+
Returns the short (namespace-stripped) actuator names in model order.
1110+
Falls back to :meth:`robot_joint_names` only if the model is unavailable.
1111+
"""
1112+
if self._world is None or self._world._model is None or robot_name not in self._world.robots:
1113+
return []
1114+
mj = self._mj
1115+
model = self._world._model
1116+
# namespace already carries its trailing "/" (e.g. "xarm7/"); normalize
1117+
# so we don't double it. Empty namespace -> unprefixed single-robot scene.
1118+
raw_ns = self._world.robots[robot_name].namespace or ""
1119+
namespace = raw_ns if raw_ns.endswith("/") or raw_ns == "" else raw_ns + "/"
1120+
keys: list[str] = []
1121+
for ai in range(model.nu):
1122+
aname = mj.mj_id2name(model, mj.mjtObj.mjOBJ_ACTUATOR, ai)
1123+
if not aname:
1124+
continue
1125+
if namespace and aname.startswith(namespace):
1126+
keys.append(aname[len(namespace):])
1127+
elif not namespace and "/" not in aname:
1128+
keys.append(aname)
1129+
# Fall back to joint names only if the robot has no namespaced actuators
1130+
# (degenerate single-robot scenes without a prefix).
1131+
return keys or list(self._world.robots[robot_name].joint_names)
1132+
10991133
def bind_policy_sim_context(self, policy: Any, robot_name: str) -> None:
11001134
"""Hand the compiled MjModel + robot namespace to policies that opt in.
11011135

strands_robots/simulation/mujoco/spec_builder.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,11 +125,20 @@ def _target_quat(position: list[float], target: list[float]) -> list[float] | No
125125
return None
126126
fwd /= flen
127127

128+
# Choose an up vector not parallel to the view direction. World +Z is the
129+
# natural up, but a vertical (top-down / bottom-up) camera has forward
130+
# parallel to +Z, making cross(fwd, up) degenerate. In that case fall back
131+
# to world +Y as up so a straight-down camera still gets a valid, sensible
132+
# orientation (this is the common "overhead view" case, NOT an error).
128133
up = np.array([0.0, 0.0, 1.0])
129134
right = np.cross(fwd, up)
130135
rlen = float(np.linalg.norm(right))
131136
if rlen < 1e-9:
132-
return None
137+
up = np.array([0.0, 1.0, 0.0])
138+
right = np.cross(fwd, up)
139+
rlen = float(np.linalg.norm(right))
140+
if rlen < 1e-9:
141+
return None
133142
right /= rlen
134143
image_up = np.cross(right, fwd)
135144
image_up /= float(np.linalg.norm(image_up))

strands_robots/simulation/predicates.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,162 @@ def term(_sim: SimEngine) -> float:
554554
return term
555555

556556

557+
# Stateful reward terms (declarative phase machine)
558+
#
559+
# A plain RewardTerm is stateless: ``(SimEngine) -> float``. Some rewards need
560+
# memory across steps - a pick-place curriculum advances Reach -> Grasp ->
561+
# Transport -> Place, awards a one-time bonus on each transition, and only ever
562+
# moves forward. Rather than hardcode any specific task, we expose ONE
563+
# generic primitive, ``staged_reward``, that composes EXISTING registry
564+
# predicates into a phase machine. The task itself is then authored as data
565+
# (a spec dict / YAML) by a human or LLM - never as shipped code, and never via
566+
# ``eval`` (sub-predicates are compiled through :func:`make_predicate`, the same
567+
# closed-registry path as every other DSL call).
568+
569+
570+
class StatefulRewardTerm:
571+
"""A reward term that carries per-episode state and must be ``reset()``.
572+
573+
Duck-typed by consumers: anything with ``__call__(sim) -> float`` AND a
574+
zero-arg ``reset()`` is treated as episode-stateful. ``SimEnv.reset`` and
575+
``DeclarativeBenchmark.on_episode_start`` call ``reset()`` on any reward
576+
term that has it, so stateless plain-function terms are unaffected.
577+
"""
578+
579+
def __call__(self, sim: SimEngine) -> float: # pragma: no cover - interface
580+
raise NotImplementedError
581+
582+
def reset(self) -> None: # pragma: no cover - interface
583+
raise NotImplementedError
584+
585+
586+
class _StagedReward(StatefulRewardTerm):
587+
"""Monotonic multi-stage (phase-machine) reward built from sub-predicates.
588+
589+
Each stage declares:
590+
- ``reward``: a float-valued registry predicate giving the dense
591+
shaping signal while the machine is IN that stage.
592+
- ``advance_when``: a bool-valued registry predicate; the FIRST step it
593+
returns True the machine awards ``bonus`` once and advances to the
594+
next stage. Phases only ever move forward (no regression), matching
595+
curriculum semantics and giving a stable, non-oscillating signal.
596+
- ``bonus``: a one-time scalar added on the transition out of the stage
597+
(default 0.0).
598+
599+
The last stage has no ``advance_when`` gate (the task is "done" there for
600+
reward purposes; episode termination is a separate ``success`` predicate).
601+
Per step the emitted reward is ``current_stage.reward(sim) +
602+
(bonus if this step advanced else 0.0)``.
603+
"""
604+
605+
def __init__(
606+
self,
607+
stages: list[tuple[RewardTerm, BoolPredicate | None, float]],
608+
) -> None:
609+
self._stages = stages
610+
self._phase = 0
611+
612+
def reset(self) -> None:
613+
self._phase = 0
614+
615+
@property
616+
def phase(self) -> int:
617+
"""Current stage index (0-based). Exposed for logging / tests."""
618+
return self._phase
619+
620+
def __call__(self, sim: SimEngine) -> float:
621+
if not self._stages:
622+
return 0.0
623+
phase = min(self._phase, len(self._stages) - 1)
624+
reward_fn, advance_fn, bonus = self._stages[phase]
625+
r = float(reward_fn(sim))
626+
# Advance (and award the one-time bonus) only if there IS a next stage
627+
# and this stage declares a gate that now fires.
628+
if self._phase < len(self._stages) - 1 and advance_fn is not None and bool(advance_fn(sim)):
629+
self._phase += 1
630+
return r + float(bonus)
631+
return r
632+
633+
634+
def _staged_reward(stages: list[Any]) -> RewardTerm:
635+
"""Factory: compile a declared stage list into a :class:`_StagedReward`.
636+
637+
This is the single new primitive that turns the stateless DSL into a
638+
declarative phase machine. It recursively compiles each stage's ``reward``
639+
and ``advance_when`` through :func:`make_predicate`, so the whole thing
640+
stays inside the closed-registry / no-``eval`` safety contract: a spec can
641+
only ever reference predicates that already exist in the registry.
642+
643+
Args:
644+
stages: Ordered list of stage dicts. Each stage::
645+
646+
{
647+
"reward": {"predicate": <float-term name>, **kwargs},
648+
"advance_when": {"predicate": <bool-pred name>, **kwargs}, # omit on last stage
649+
"bonus": <float>, # optional, default 0.0
650+
}
651+
652+
Returns:
653+
A callable+resettable :class:`_StagedReward`.
654+
655+
Raises:
656+
ValueError: stages is not a non-empty list, a stage is malformed, a
657+
non-final stage omits ``advance_when``, or ``bonus`` is non-numeric.
658+
TypeError: surfaced from :func:`make_predicate` for bad sub-kwargs.
659+
"""
660+
if not isinstance(stages, list) or not stages:
661+
raise ValueError("staged_reward: 'stages' must be a non-empty list of stage dicts")
662+
663+
compiled: list[tuple[RewardTerm, BoolPredicate | None, float]] = []
664+
n = len(stages)
665+
for i, stage in enumerate(stages):
666+
if not isinstance(stage, dict):
667+
raise ValueError(f"staged_reward: stage[{i}] must be a dict, got {type(stage).__name__}")
668+
unknown = set(stage.keys()) - {"reward", "advance_when", "bonus"}
669+
if unknown:
670+
raise ValueError(
671+
f"staged_reward: stage[{i}] has unknown keys {sorted(unknown)}; "
672+
"allowed: reward, advance_when, bonus"
673+
)
674+
675+
reward_call = stage.get("reward")
676+
if not isinstance(reward_call, dict) or "predicate" not in reward_call:
677+
raise ValueError(
678+
f"staged_reward: stage[{i}].reward must be a predicate-call dict "
679+
"like {predicate: distance_neg, body_a: ..., body_b: ...}"
680+
)
681+
reward_name = reward_call["predicate"]
682+
reward_kwargs = {k: v for k, v in reward_call.items() if k != "predicate"}
683+
reward_fn = make_predicate(reward_name, **reward_kwargs)
684+
685+
advance_call = stage.get("advance_when")
686+
advance_fn: BoolPredicate | None
687+
if advance_call is None:
688+
if i != n - 1:
689+
raise ValueError(
690+
f"staged_reward: stage[{i}] is not the final stage and must declare "
691+
"'advance_when' (a bool predicate gating the transition to the next stage)"
692+
)
693+
advance_fn = None
694+
else:
695+
if not isinstance(advance_call, dict) or "predicate" not in advance_call:
696+
raise ValueError(
697+
f"staged_reward: stage[{i}].advance_when must be a predicate-call dict "
698+
"like {predicate: distance_less_than, body_a: ..., body_b: ..., threshold: ...}"
699+
)
700+
advance_name = advance_call["predicate"]
701+
advance_kwargs = {k: v for k, v in advance_call.items() if k != "predicate"}
702+
advance_fn = make_predicate(advance_name, **advance_kwargs)
703+
704+
bonus_raw = stage.get("bonus", 0.0)
705+
if isinstance(bonus_raw, bool) or not isinstance(bonus_raw, (int, float)):
706+
raise ValueError(f"staged_reward: stage[{i}].bonus must be a number, got {bonus_raw!r}")
707+
708+
compiled.append((reward_fn, advance_fn, float(bonus_raw)))
709+
710+
return _StagedReward(compiled)
711+
712+
557713
# Registry
558714

559715
PREDICATE_REGISTRY: dict[str, PredicateFactory] = {
@@ -574,6 +730,8 @@ def term(_sim: SimEngine) -> float:
574730
"distance_neg": _distance_neg,
575731
"joint_progress": _joint_progress,
576732
"constant": _constant,
733+
# stateful (phase machine)
734+
"staged_reward": _staged_reward,
577735
}
578736

579737

@@ -634,6 +792,7 @@ def make_predicate(name: str, **kwargs: Any) -> Callable[[SimEngine], Any]:
634792
"BoolPredicate",
635793
"PredicateFactory",
636794
"RewardTerm",
795+
"StatefulRewardTerm",
637796
"make_predicate",
638797
"register_predicate",
639798
]

0 commit comments

Comments
 (0)