react-manip 0.1.0__py3-none-any.whl

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 (99) hide show
  1. assets/materials/realistic/common/ceramic.png +0 -0
  2. assets/materials/realistic/common/cream-plaster.png +0 -0
  3. assets/materials/realistic/common/gray-felt.png +0 -0
  4. assets/materials/realistic/common/gray-plaster.png +0 -0
  5. assets/materials/realistic/common/gray-woodgrain.png +0 -0
  6. assets/materials/realistic/common/light-gray-floor-tile.png +0 -0
  7. assets/materials/realistic/common/light-gray-plaster.png +0 -0
  8. assets/materials/realistic/common/metal.png +0 -0
  9. assets/materials/realistic/common/oak-wood.png +0 -0
  10. assets/materials/realistic/common/steel-brushed.png +0 -0
  11. assets/materials/realistic/common/steel-scratched.png +0 -0
  12. assets/materials/realistic/common/varnished-wood-panels.png +0 -0
  13. assets/materials/realistic/common/walnut-wood.png +0 -0
  14. assets/materials/realistic/common/white-bricks.png +0 -0
  15. assets/materials/realistic/common/wood-tiles.png +0 -0
  16. assets/materials/realistic/tiles074/Tiles074_2K_Color.png +0 -0
  17. assets/materials/realistic/woodfloor049/WoodFloor049_2K_Color.png +0 -0
  18. configs/box_insertion/profile_default.json +204 -0
  19. react_manip/__init__.py +12 -0
  20. react_manip/asset_cli.py +18 -0
  21. react_manip/builtin.py +168 -0
  22. react_manip/cameras.py +301 -0
  23. react_manip/category_objects.py +248 -0
  24. react_manip/core/__init__.py +60 -0
  25. react_manip/core/aggregate.py +59 -0
  26. react_manip/core/compatibility.py +64 -0
  27. react_manip/core/contracts.py +42 -0
  28. react_manip/core/geometry.py +269 -0
  29. react_manip/core/language.py +116 -0
  30. react_manip/core/metrics.py +196 -0
  31. react_manip/core/observation.py +59 -0
  32. react_manip/core/protocol.py +91 -0
  33. react_manip/core/registry.py +94 -0
  34. react_manip/core/runner.py +498 -0
  35. react_manip/core/scenarios.py +110 -0
  36. react_manip/core/specs.py +203 -0
  37. react_manip/core/timeouts.py +44 -0
  38. react_manip/core/visual_randomization.py +261 -0
  39. react_manip/evaluation/__init__.py +4 -0
  40. react_manip/evaluation/metrics.py +17 -0
  41. react_manip/evaluation/protocols.py +326 -0
  42. react_manip/evaluation/viewer.py +240 -0
  43. react_manip/instances/__init__.py +6 -0
  44. react_manip/instances/candidate_bank.py +109 -0
  45. react_manip/instances/public.py +95 -0
  46. react_manip/instances/records.py +345 -0
  47. react_manip/instances/runtime.py +306 -0
  48. react_manip/instances/suite.py +231 -0
  49. react_manip/instances/trajectory.py +59 -0
  50. react_manip/instances/variation.py +118 -0
  51. react_manip/legacy.py +318 -0
  52. react_manip/policies/__init__.py +5 -0
  53. react_manip/policies/base.py +79 -0
  54. react_manip/rendering.py +58 -0
  55. react_manip/robot_registry.py +31 -0
  56. react_manip/robots/__init__.py +33 -0
  57. react_manip/robots/adapters.py +135 -0
  58. react_manip/robots/agilex_adapter.py +35 -0
  59. react_manip/robots/agilex_description.py +74 -0
  60. react_manip/robots/agilex_robosuite.py +246 -0
  61. react_manip/robots/arm_actions.py +17 -0
  62. react_manip/robots/base.py +119 -0
  63. react_manip/robots/catalog.py +18 -0
  64. react_manip/robots/legacy_agilex_robosuite.py +169 -0
  65. react_manip/robots/mobile_base.py +33 -0
  66. react_manip/robots/mounts.py +80 -0
  67. react_manip/robots/resolution.py +103 -0
  68. react_manip/robots/sawyer_robosuite.py +147 -0
  69. react_manip/robots/sensors.py +137 -0
  70. react_manip/utils/__init__.py +1 -0
  71. react_manip/visual_task.py +50 -0
  72. react_manip-0.1.0.dist-info/METADATA +446 -0
  73. react_manip-0.1.0.dist-info/RECORD +99 -0
  74. react_manip-0.1.0.dist-info/WHEEL +5 -0
  75. react_manip-0.1.0.dist-info/entry_points.txt +2 -0
  76. react_manip-0.1.0.dist-info/licenses/LICENSE +131 -0
  77. react_manip-0.1.0.dist-info/top_level.txt +5 -0
  78. scripts/add_phone_lean_to_bank.py +242 -0
  79. scripts/agilex_wheeled_builder.py +133 -0
  80. scripts/audit_task_solvability.py +150 -0
  81. scripts/build_agilex_assets.py +317 -0
  82. scripts/build_legacy_agilex_assets.py +244 -0
  83. scripts/evaluate_all_tasks.py +309 -0
  84. scripts/evaluate_policy.py +281 -0
  85. scripts/piperx_assets.lock.json +181 -0
  86. scripts/pygame_demo.py +360 -0
  87. scripts/pygame_task_debug.py +242 -0
  88. scripts/regenerate_instance_bank.py +192 -0
  89. scripts/remove_task_from_bank.py +103 -0
  90. scripts/select_task_instances.py +64 -0
  91. scripts/verify_curling_reachability.py +206 -0
  92. scripts/verify_piperx_tasks.py +81 -0
  93. tasks/box_insertion/__init__.py +3 -0
  94. tasks/box_insertion/assets/scene.xml +50 -0
  95. tasks/box_insertion/env.py +642 -0
  96. tasks/box_insertion/evaluation.json +16 -0
  97. tasks/box_insertion/instance_contract.json +321 -0
  98. tasks/box_insertion/scoring.py +23 -0
  99. tasks/box_insertion/task_spec.json +333 -0
@@ -0,0 +1,498 @@
1
+ """Fixed-horizon policy evaluation runner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import inspect
7
+ import math
8
+
9
+ import time
10
+ from typing import Any, Callable, Mapping
11
+
12
+ from .metrics import SustainedSuccessTracker
13
+ from .compatibility import validate_task_robot, resolve_task_robot
14
+ from .protocol import normalize_reset, normalize_step
15
+ from .observation import policy_info, policy_observation
16
+ from .registry import TaskDefinition
17
+ from .specs import TaskSpec
18
+ from .timeouts import DEFAULT_EPISODE_WALL_TIMEOUT_SEC, validate_timeout
19
+
20
+
21
+ def _call_with_supported_kwargs(fn: Callable[..., Any], kwargs: dict[str, Any]) -> Any:
22
+ """Call a factory without hiding errors raised inside its implementation."""
23
+
24
+ try:
25
+ signature = inspect.signature(fn)
26
+ except (TypeError, ValueError):
27
+ return fn(**kwargs)
28
+ if any(param.kind == inspect.Parameter.VAR_KEYWORD for param in signature.parameters.values()):
29
+ return fn(**kwargs)
30
+ # Accommodate common adapter spellings without forcing every external
31
+ # policy/factory to use our parameter names. Build this mapping before
32
+ # the direct-name fast path so ``act(obs, info)`` receives both values
33
+ # (rather than only the directly named ``info`` argument).
34
+ aliases = {
35
+ "observation": ("obs", "observation", "state"),
36
+ "info": ("info", "metadata"),
37
+ "robot_id": ("robot_id", "robot", "embodiment"),
38
+ "task_spec": ("task_spec", "spec", "task"),
39
+ "seed": ("seed", "rng_seed"),
40
+ }
41
+ mapped: dict[str, Any] = {}
42
+ for source, value in kwargs.items():
43
+ for name in aliases.get(source, (source,)):
44
+ if name in signature.parameters:
45
+ mapped[name] = value
46
+ break
47
+ if mapped:
48
+ return fn(**mapped)
49
+ # Last resort for simple positional callables (e.g. lambda obs: action).
50
+ positional = [
51
+ param
52
+ for param in signature.parameters.values()
53
+ if param.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
54
+ ]
55
+ values = [kwargs[key] for key in ("observation", "info", "seed", "robot_id", "task_spec") if key in kwargs]
56
+ if positional and values:
57
+ return fn(*values[: len(positional)])
58
+ return fn()
59
+
60
+
61
+ @dataclass
62
+ class EvaluationResult:
63
+ task_id: str
64
+ task_version: str
65
+ robot_id: str | None
66
+ policy_id: str
67
+ seed: int | None
68
+ metrics: dict[str, Any]
69
+ wall_time_sec: float
70
+ ended_early: bool
71
+
72
+ def to_dict(self) -> dict[str, Any]:
73
+ return {
74
+ "benchmark_api_version": "2.0",
75
+ "metrics_schema_version": "2.0",
76
+ "task_id": self.task_id,
77
+ "task_version": self.task_version,
78
+ "robot_id": self.robot_id,
79
+ "policy_id": self.policy_id,
80
+ "seed": self.seed,
81
+ "wall_time_sec": round(self.wall_time_sec, 6),
82
+ "ended_early": self.ended_early,
83
+ **self.metrics,
84
+ }
85
+
86
+
87
+ class BenchmarkRunner:
88
+ """Run a policy for a fixed simulation horizon and wall-clock budget.
89
+
90
+ The runner accepts either a :class:`TaskDefinition`, a ``TaskSpec`` plus a
91
+ factory, or a raw factory. This makes it possible to migrate existing
92
+ robosuite environments gradually while new tasks use the registry.
93
+ """
94
+
95
+ def __init__(self, *, default_horizon_sec: float | None = None, default_control_hz: float | None = None,
96
+ episode_wall_timeout_sec: float | None = DEFAULT_EPISODE_WALL_TIMEOUT_SEC):
97
+ self.default_horizon_sec = default_horizon_sec
98
+ self.default_control_hz = default_control_hz
99
+ self.episode_wall_timeout_sec = validate_timeout(episode_wall_timeout_sec, "episode_wall_timeout_sec")
100
+
101
+ def evaluate(
102
+ self,
103
+ task: TaskDefinition | TaskSpec,
104
+ factory: Callable[..., Any] | None = None,
105
+ policy: Any | None = None,
106
+ *,
107
+ robot_id: str | None = None,
108
+ seed: int | None = 0,
109
+ horizon_sec: float | None = None,
110
+ control_hz: float | None = None,
111
+ policy_id: str | None = None,
112
+ env_kwargs: Mapping[str, Any] | None = None,
113
+ render: bool = False,
114
+ render_realtime: bool = False,
115
+ render_hold_sec: float = 3.0,
116
+ step_callback: Callable[..., Any] | None = None,
117
+ observation_callback: Callable[..., Any] | None = None,
118
+ env: Any | None = None,
119
+ stop_on_done: bool = False,
120
+ score_key: str | None = None,
121
+ camera_observations: bool = True,
122
+ episode_wall_timeout_sec: float | None = None,
123
+ ) -> EvaluationResult:
124
+ # A supplied environment is borrowed: reset/step it, but leave closing
125
+ # to its owner. stop_on_done prevents a multi-episode protocol from
126
+ # consuming uncounted retries through a task's reset-on-done behavior.
127
+ if isinstance(task, TaskDefinition):
128
+ spec, task_factory = task.spec, task.factory
129
+ elif isinstance(task, TaskSpec):
130
+ if factory is None:
131
+ raise TypeError("factory is required when task is a TaskSpec")
132
+ spec, task_factory = task, factory
133
+ else: # pragma: no cover - defensive for callers using duck typing
134
+ raise TypeError("task must be TaskDefinition or TaskSpec")
135
+
136
+ hz = float(control_hz or self.default_control_hz or spec.control_hz)
137
+ horizon = float(horizon_sec or self.default_horizon_sec or spec.horizon_sec)
138
+ if hz <= 0 or horizon <= 0:
139
+ raise ValueError("horizon_sec and control_hz must be positive")
140
+ steps_limit = max(1, int(round(horizon * hz)))
141
+ if render_hold_sec < 0:
142
+ raise ValueError("render_hold_sec must be non-negative")
143
+ episode_timeout = validate_timeout(
144
+ self.episode_wall_timeout_sec if episode_wall_timeout_sec is None else episode_wall_timeout_sec,
145
+ "episode_wall_timeout_sec",
146
+ )
147
+
148
+ robot_id = resolve_task_robot(spec, robot_id)
149
+ robot_adapter = None
150
+ if robot_id is not None:
151
+ try:
152
+ from .registry import get_robot_registry
153
+
154
+ robot_adapter = get_robot_registry().get(robot_id.lower())
155
+ except KeyError:
156
+ # A custom application may provide a robot factory outside
157
+ # the registry; task-level support checks still apply below.
158
+ robot_adapter = None
159
+ validate_task_robot(spec, robot_id, robot_adapter)
160
+ if robot_adapter is not None:
161
+ robot_spec = getattr(robot_adapter, "spec", None)
162
+ capabilities = getattr(robot_spec, "capabilities", ())
163
+ if "custom_mjcf" in capabilities and hasattr(robot_adapter, "validate_assets"):
164
+ robot_adapter.validate_assets()
165
+
166
+ factory_kwargs: dict[str, Any] = {"seed": seed, "robot_id": robot_id}
167
+ if env_kwargs:
168
+ factory_kwargs.update(dict(env_kwargs))
169
+ if render:
170
+ # robosuite environments use these constructor flags to create a
171
+ # native viewer. ``_call_with_supported_kwargs`` drops them for
172
+ # custom factories that do not expose rendering options.
173
+ factory_kwargs.setdefault("has_renderer", True)
174
+ # Native mjviewer does not need an offscreen context. On macOS an
175
+ # offscreen GLFW / CGL context can create an NSWindow from the
176
+ # mjpython worker thread and abort the process.
177
+ factory_kwargs.setdefault("has_offscreen_renderer", False)
178
+ factory_kwargs.setdefault("use_camera_obs", False)
179
+ owns_env = env is None
180
+ if owns_env:
181
+ env = _call_with_supported_kwargs(task_factory, factory_kwargs)
182
+ tracker = SustainedSuccessTracker(control_hz=hz)
183
+ started = time.perf_counter()
184
+ ended_early = False
185
+ terminal_success = False
186
+ error_message: str | None = None
187
+ reset_count = 0
188
+ wall_clock_timeout = False
189
+ try:
190
+ from ..robots.sensors import policy_camera_ids, CAMERA_CONTRACT_VERSION
191
+ from ..robots.catalog import ALL_ROBOT_IDS
192
+ from integrations.rpent_embodiments.catalog import EMBODIMENTS
193
+ if robot_id in ALL_ROBOT_IDS or robot_id in EMBODIMENTS:
194
+ env.set_camera_observations(policy_camera_ids(robot_id) if camera_observations else ())
195
+ reset_kwargs = {"seed": seed} if seed is not None else {}
196
+ observation, reset_info = _reset_env(env, reset_kwargs)
197
+ observation = policy_observation(observation, spec, env)
198
+ # Every episode exposes the language instruction through reset
199
+ # info, including legacy robosuite environments whose reset only
200
+ # returns an observation.
201
+ prompt_fn = getattr(env, "get_language_prompt", None)
202
+ if callable(prompt_fn):
203
+ prompt = prompt_fn()
204
+ reset_info = dict(reset_info)
205
+ if isinstance(prompt, Mapping):
206
+ reset_info.setdefault("language_prompt", prompt.get("text", ""))
207
+ reset_info.setdefault("language_prompt_structured", prompt)
208
+ if render:
209
+ _render_env(env)
210
+ if policy is None:
211
+ from react_manip.policies.base import RandomPolicy
212
+
213
+ policy = RandomPolicy()
214
+ if hasattr(policy, "set_action_dim"):
215
+ policy.set_action_dim(_action_dim(env))
216
+ if hasattr(policy, "reset"):
217
+ _call_with_supported_kwargs(
218
+ policy.reset,
219
+ {"seed": seed, "task_spec": spec, "episode_index": reset_count},
220
+ )
221
+
222
+ last_info: Mapping[str, Any] = reset_info
223
+ last_reward, terminated, truncated = 0.0, False, False
224
+ try:
225
+ for _ in range(steps_limit):
226
+ if episode_timeout is not None and time.perf_counter() - started >= episode_timeout:
227
+ wall_clock_timeout = True
228
+ ended_early = True
229
+ error_message = "wall_clock_timeout"
230
+ tracker.record_failure(at_sec=tracker.steps / hz)
231
+ break
232
+ # Evaluator display/recording is outside policy latency.
233
+ if observation_callback is not None:
234
+ observation_callback(env, observation, last_info)
235
+ tick_started = time.perf_counter()
236
+ policy_started = time.perf_counter()
237
+ action = _call_with_supported_kwargs(
238
+ policy.act,
239
+ {
240
+ "observation": observation,
241
+ "info": policy_info(last_info, policy, env),
242
+ },
243
+ )
244
+ inference_latency_ms = (time.perf_counter() - policy_started) * 1000.0
245
+ if episode_timeout is not None and time.perf_counter() - started >= episode_timeout:
246
+ wall_clock_timeout = True
247
+ ended_early = True
248
+ error_message = "wall_clock_timeout"
249
+ tracker.record_failure(at_sec=tracker.steps / hz)
250
+ break
251
+ result = normalize_step(env.step(action))
252
+ observation = policy_observation(result.observation, spec, env)
253
+ last_info = result.info
254
+ last_reward, terminated, truncated = result.reward, result.terminated, result.truncated
255
+ if step_callback is not None:
256
+ step_callback(env, _ + 1, result)
257
+ if render:
258
+ _render_env(env)
259
+ if render_realtime:
260
+ time.sleep(max(0.0, (1.0 / hz) - (time.perf_counter() - tick_started)))
261
+ # Preserve task-reported latency when present, otherwise
262
+ # make model inference cost measurable for every policy.
263
+ if "inference_latency_ms" not in last_info:
264
+ last_info = dict(last_info)
265
+ last_info["inference_latency_ms"] = inference_latency_ms
266
+ success_state = _success_state(last_info, result.reward)
267
+ events = _events(last_info)
268
+ failure = last_info.get("failure")
269
+ # A shaped reward may represent partial task progress
270
+ # (for example, a scored prerequisite). When a task
271
+ # explicitly reports terminal success, that ground-truth
272
+ # value must take precedence over the legacy reward
273
+ # fallback.
274
+ if "terminal_success" in last_info:
275
+ terminal_success = bool(last_info["terminal_success"])
276
+ elif "success" in last_info:
277
+ terminal_success = bool(last_info["success"])
278
+ else:
279
+ terminal_success = result.reward > 0.0
280
+ behavior = spec.done_behavior
281
+ if result.done and not terminal_success and failure is None:
282
+ failure = {
283
+ "recoverable": behavior in {"reset", "fail_and_reset"},
284
+ }
285
+ tracker.update(
286
+ success_state,
287
+ events=events,
288
+ failure=failure,
289
+ dt=1.0 / hz,
290
+ info=last_info,
291
+ )
292
+ # A policy/environment call can itself consume the budget.
293
+ # Check after recording the completed tick so the result is
294
+ # still traceable without running another control step.
295
+ if (not result.done and episode_timeout is not None and
296
+ time.perf_counter() - started >= episode_timeout):
297
+ wall_clock_timeout = True
298
+ ended_early = True
299
+ error_message = "wall_clock_timeout"
300
+ tracker.record_failure(at_sec=tracker.steps / hz)
301
+ break
302
+ if result.done:
303
+ if not stop_on_done and behavior in {"reset", "fail_and_reset"} and _can_reset_for_horizon(steps_limit, _):
304
+ reset_count += 1
305
+ observation, last_info = _reset_env(
306
+ env,
307
+ {"seed": None if seed is None else seed + reset_count},
308
+ )
309
+ observation = policy_observation(observation, spec, env)
310
+ if hasattr(policy, "reset"):
311
+ _call_with_supported_kwargs(
312
+ policy.reset,
313
+ {
314
+ "seed": None if seed is None else seed + reset_count,
315
+ "task_spec": spec,
316
+ "episode_index": reset_count,
317
+ },
318
+ )
319
+ continue
320
+ ended_early = _ + 1 < steps_limit
321
+ break
322
+ except Exception as exc:
323
+ # The interactive evaluator uses a dedicated exception for
324
+ # "skip current episode". Let the manifest runner handle it
325
+ # between episodes instead of turning it into a normal failed
326
+ # EvaluationResult.
327
+ try:
328
+ from react_manip.evaluation.viewer import SkipEpisode
329
+ except ImportError: # pragma: no cover - optional UI module
330
+ SkipEpisode = ()
331
+ if isinstance(exc, SkipEpisode):
332
+ raise
333
+ error_message = f"{type(exc).__name__}: {str(exc)[:240]}"
334
+ ended_early = True
335
+ # The exception occurs between control ticks. Record it as a
336
+ # failure without inventing an extra simulated tick.
337
+ tracker.record_failure(at_sec=tracker.steps / hz)
338
+ finally:
339
+ if render and render_hold_sec > 0:
340
+ time.sleep(render_hold_sec)
341
+ if owns_env and hasattr(env, "close"):
342
+ env.close()
343
+
344
+ metrics = tracker.summarize(spec.success, terminal_success=terminal_success)
345
+ if score_key is not None and error_message is None:
346
+ value = last_info.get(score_key)
347
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
348
+ error_message = f"missing or non-finite final task score: info.{score_key}"
349
+ else:
350
+ metrics["episode_score"] = float(value)
351
+ metrics["score_source"] = f"info.{score_key}"
352
+ sim_time = float(metrics.get("sim_time_sec", 0.0))
353
+ metrics.update({
354
+ "horizon_sec": horizon,
355
+ "control_hz": hz,
356
+ "horizon_steps": steps_limit,
357
+ "real_time_factor": round(sim_time / max(time.perf_counter() - started, 1e-12), 6),
358
+ "episode_wall_timeout_sec": episode_timeout,
359
+ "wall_clock_timeout": wall_clock_timeout,
360
+ "timeout_scope": "episode" if wall_clock_timeout else None,
361
+ })
362
+ from integrations.rpent_embodiments.catalog import EMBODIMENTS
363
+ if robot_id in ALL_ROBOT_IDS or robot_id in EMBODIMENTS:
364
+ metrics['camera_contract_version'] = CAMERA_CONTRACT_VERSION
365
+ metrics['camera_ids'] = list(policy_camera_ids(robot_id)) if camera_observations else []
366
+ metrics['observation_mode'] = 'rgb' if camera_observations else 'state_only_smoke'
367
+ scenario = getattr(env, "scenario", None)
368
+ if scenario is not None:
369
+ metrics.update({"scenario_id": scenario.scenario_id, "scenario_digest": scenario.digest})
370
+ if error_message is not None:
371
+ metrics["error"] = error_message
372
+ metrics["passed"] = False
373
+ if hasattr(policy, "end_episode"):
374
+ _call_with_supported_kwargs(policy.end_episode, {
375
+ "observation": observation, "info": policy_info(last_info, policy, env),
376
+ "reward": last_reward, "terminated": terminated,
377
+ "truncated": truncated or wall_clock_timeout or (not terminated and tracker.steps >= steps_limit),
378
+ "passed": metrics["passed"], "episode_score": metrics.get("episode_score"),
379
+ })
380
+ return EvaluationResult(
381
+ task_id=spec.task_id,
382
+ task_version=spec.task_version,
383
+ robot_id=robot_id,
384
+ policy_id=policy_id or getattr(policy, "policy_id", policy.__class__.__name__),
385
+ seed=seed,
386
+ metrics=metrics,
387
+ wall_time_sec=time.perf_counter() - started,
388
+ ended_early=ended_early,
389
+ )
390
+
391
+
392
+ def _reset_env(env: Any, kwargs: dict[str, Any]) -> tuple[Any, Mapping[str, Any]]:
393
+ """Reset old robosuite and Gymnasium environments without masking errors."""
394
+
395
+ try:
396
+ return normalize_reset(env.reset(**kwargs))
397
+ except TypeError as exc:
398
+ # Legacy robosuite reset() has no seed parameter. Only retry for a
399
+ # signature mismatch; errors raised inside reset must propagate.
400
+ try:
401
+ signature = inspect.signature(env.reset)
402
+ except (TypeError, ValueError):
403
+ raise
404
+ if kwargs and "seed" not in signature.parameters:
405
+ return normalize_reset(env.reset())
406
+ raise exc
407
+
408
+
409
+ def _can_reset_for_horizon(steps_limit: int, step_index: int) -> bool:
410
+ # A reset at the final tick would not contribute any simulation time and
411
+ # can make task traces look like an extra episode. Keep the horizon exact.
412
+ return step_index + 1 < steps_limit
413
+
414
+ def evaluate_many(
415
+ self,
416
+ task: TaskDefinition | TaskSpec,
417
+ policy_factory: Callable[[], Any],
418
+ *,
419
+ factory: Callable[..., Any] | None = None,
420
+ episodes: int = 3,
421
+ **kwargs: Any,
422
+ ) -> list[EvaluationResult]:
423
+ if episodes < 1:
424
+ raise ValueError("episodes must be >= 1")
425
+ base_seed = kwargs.pop("seed", 0)
426
+ return [
427
+ self.evaluate(
428
+ task,
429
+ factory=factory,
430
+ policy=policy_factory(),
431
+ seed=None if base_seed is None else base_seed + index,
432
+ **kwargs,
433
+ )
434
+ for index in range(episodes)
435
+ ]
436
+
437
+
438
+ def _success_state(info: Mapping[str, Any], reward: float) -> bool | None:
439
+ if "success_state" in info:
440
+ return bool(info["success_state"])
441
+ if "success" in info:
442
+ return bool(info["success"])
443
+ # Legacy robosuite tasks generally expose success through positive reward.
444
+ return bool(reward > 0.0)
445
+
446
+
447
+ def _events(info: Mapping[str, Any]) -> list[Any]:
448
+ events = info.get("events", info.get("event", ()))
449
+ if events is None:
450
+ return []
451
+ if isinstance(events, (str, bytes)):
452
+ return [events]
453
+ if isinstance(events, int):
454
+ return [None] * max(0, events)
455
+ try:
456
+ return list(events)
457
+ except TypeError:
458
+ return [events]
459
+
460
+
461
+ def _action_dim(env: Any) -> int:
462
+ """Read action dimension from robosuite or Gymnasium-style environments."""
463
+
464
+ value = getattr(env, "action_dim", None)
465
+ if value is not None:
466
+ return int(value)
467
+ space = getattr(env, "action_space", None)
468
+ shape = getattr(space, "shape", None)
469
+ if shape:
470
+ size = 1
471
+ for dimension in shape:
472
+ size *= int(dimension)
473
+ return size
474
+ if hasattr(space, "n"):
475
+ return int(space.n)
476
+ raise AttributeError("environment must expose action_dim or action_space.shape/n")
477
+
478
+
479
+ def _render_env(env: Any) -> None:
480
+ """Refresh a native viewer when interactive rendering is requested."""
481
+
482
+ # robosuite's ``mjviewer`` renderer implements ``render()`` as a no-op;
483
+ # the actual window creation and frame presentation happen in
484
+ # ``MjviewerRenderer.update()``. Prefer that method when available.
485
+ viewer = getattr(env, "viewer", None)
486
+ update_fn = getattr(viewer, "update", None)
487
+ if callable(update_fn):
488
+ update_fn()
489
+ return
490
+ render_fn = getattr(env, "render", None)
491
+ if not callable(render_fn):
492
+ return
493
+ try:
494
+ render_fn()
495
+ except TypeError:
496
+ # Gymnasium environments may require an explicit mode when their
497
+ # render method was not configured at construction time.
498
+ render_fn(mode="human")
@@ -0,0 +1,110 @@
1
+ """Deterministic environment profiles and resolved Scenario instances."""
2
+ from pathlib import Path
3
+ from typing import Any, Mapping
4
+ import hashlib, json
5
+ import numpy as np
6
+
7
+ def _set(dst, path, value):
8
+ cur=dst; parts=path.split('.')
9
+ for part in parts[:-1]: cur=cur.setdefault(part,{})
10
+ cur[parts[-1]]=value
11
+
12
+ def _get(src, path):
13
+ cur = src
14
+ for part in path.split('.'):
15
+ if not isinstance(cur, Mapping) or part not in cur:
16
+ raise ValueError(f"scenario constraint references missing path: {path}")
17
+ cur = cur[part]
18
+ return cur
19
+
20
+ def _profile_rule(profile, path):
21
+ """Find a sampling rule by its resolved (group-relative) path."""
22
+ for group in ("fixed", "geometry", "sampled", "physics", "appearance", "runtime"):
23
+ cur = profile.get(group, {}) or {}
24
+ if path in cur:
25
+ return cur[path]
26
+ try:
27
+ for part in path.split('.'):
28
+ cur = cur[part]
29
+ except (KeyError, TypeError):
30
+ continue
31
+ return cur
32
+ return None
33
+
34
+ def _flatten(value, prefix=''):
35
+ for key,item in value.items():
36
+ path=f'{prefix}.{key}' if prefix else str(key)
37
+ if isinstance(item,Mapping) and 'distribution' not in item: yield from _flatten(item,path)
38
+ else: yield path,item
39
+
40
+ def _sample(raw,rng):
41
+ if not isinstance(raw,Mapping) or raw.get('distribution','fixed')=='fixed': return raw.get('value') if isinstance(raw,Mapping) and 'value' in raw else raw
42
+ if raw['distribution']=='uniform': return np.asarray(rng.uniform(raw['low'],raw['high'])).item()
43
+ if raw['distribution']=='exponential':
44
+ low=float(raw.get('low',0.0)); scale=float(raw.get('scale',1.0)); high=raw.get('high')
45
+ if not np.isfinite(low) or not np.isfinite(scale) or scale<=0: raise ValueError('exponential distribution requires finite low and scale > 0')
46
+ if high is None: return float(low+rng.exponential(scale))
47
+ high=float(high)
48
+ if not np.isfinite(high) or high<=low: raise ValueError('bounded exponential distribution requires finite high > low')
49
+ # Inverse CDF of an exponential conditioned on [low, high]. Unlike
50
+ # clipping an unbounded sample, this creates no probability spike at
51
+ # the upper limit and remains exactly reproducible from the seed.
52
+ span=high-low; normalizer=-np.expm1(-span/scale); u=float(rng.random())
53
+ return float(low-scale*np.log1p(-u*normalizer))
54
+ if raw['distribution']=='choice':
55
+ values = list(raw['values'])
56
+ if not values: raise ValueError('choice distribution requires at least one value')
57
+ return values[int(rng.integers(len(values)))]
58
+ raise ValueError(f"unsupported distribution: {raw['distribution']}")
59
+
60
+ def _apply_constraints(profile, parameters, seed):
61
+ """Apply declarative cross-field constraints after independent sampling."""
62
+ constraints = profile.get("constraints", ()) or ()
63
+ rng = np.random.default_rng(int(seed) + 4000012)
64
+ for rule in constraints:
65
+ if not isinstance(rule, Mapping) or rule.get("type") != "not_equal":
66
+ raise ValueError(f"unsupported scenario constraint: {rule!r}")
67
+ paths = list(rule.get("paths", ()))
68
+ if len(paths) != 2:
69
+ raise ValueError("not_equal constraint requires exactly two paths")
70
+ left_path, right_path = map(str, paths)
71
+ left = _get(parameters, left_path)
72
+ if _get(parameters, right_path) != left:
73
+ continue
74
+ raw = _profile_rule(profile, right_path)
75
+ values = list(raw.get("values", ())) if (
76
+ isinstance(raw, Mapping) and raw.get("distribution") == "choice"
77
+ ) else []
78
+ candidates = [value for value in values if value != left]
79
+ if not candidates:
80
+ raise ValueError(
81
+ f"cannot satisfy not_equal constraint for {left_path} and {right_path}"
82
+ )
83
+ _set(parameters, right_path, candidates[int(rng.integers(len(candidates)))])
84
+
85
+ class Scenario:
86
+ def __init__(self,schema_version,task_id,scenario_id,seed,parameters,assets=None,metadata=None): self.schema_version,self.task_id,self.scenario_id,self.seed=schema_version,task_id,scenario_id,int(seed); self.parameters,self.assets,self.metadata=parameters,assets or {},metadata or {}
87
+ def to_dict(self, digest=True):
88
+ out={'schema_version':self.schema_version,'task_id':self.task_id,'scenario_id':self.scenario_id,'seed':self.seed,'parameters':self.parameters,'assets':self.assets,'metadata':self.metadata}
89
+ if digest: out['digest']=self.digest
90
+ return out
91
+ @property
92
+ def digest(self): return hashlib.sha256(json.dumps(self.to_dict(False),sort_keys=True,separators=(',',':')).encode()).hexdigest()[:16]
93
+ def save(self,path):
94
+ path = Path(path)
95
+ path.parent.mkdir(parents=True, exist_ok=True)
96
+ path.write_text(json.dumps(self.to_dict(),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
97
+ @classmethod
98
+ def load(cls,path):
99
+ r=json.loads(Path(path).read_text(encoding='utf-8')); s=cls(r['schema_version'],r['task_id'],r['scenario_id'],r['seed'],r['parameters'],r.get('assets'),r.get('metadata'))
100
+ if r.get('digest') and r['digest']!=s.digest: raise ValueError('scenario digest mismatch')
101
+ return s
102
+
103
+ def sample_scenario(profile:Mapping[str,Any], *, seed=0, scenario_id=None):
104
+ out={}
105
+ for path,value in _flatten(profile.get('fixed',{})): _set(out,path,value)
106
+ for i,(group,values) in enumerate((('geometry',profile.get('geometry',profile.get('sampled',{}))),('physics',profile.get('physics',{})),('appearance',profile.get('appearance',{})),('runtime',profile.get('runtime',{})))):
107
+ rng=np.random.default_rng(int(seed)+i*1000003)
108
+ for path,raw in _flatten(values): _set(out,path,_sample(raw,rng))
109
+ _apply_constraints(profile, out, seed)
110
+ return Scenario(str(profile.get('schema_version','1.0')),str(profile['task_id']),scenario_id or f"{profile['task_id']}_{int(seed):06d}",seed,out,profile.get('assets',{}),{'profile':profile.get('profile_id','default')})