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,309 @@
1
+ #!/usr/bin/env python3
2
+ """Run the original-data evaluation protocol for all sixteen release tasks.
3
+
4
+ The script uses the shipped candidate bank, whose manifest owns the protocol,
5
+ episode counts, scenarios, horizons, and control rates. Therefore all 16 tasks
6
+ run the same 10-practice + 5-exam schedule for each of their three candidate
7
+ instances (48 groups and 720 episodes total). ``--state-only-smoke`` is
8
+ available for checking the environment/policy interface, but it is not a
9
+ scored RGB run.
10
+
11
+ Examples::
12
+
13
+ # Formal original-data run with the built-in random policy.
14
+ python scripts/evaluate_all_tasks.py --draw-seed 42
15
+
16
+ # Evaluate an external policy with RGB observations.
17
+ python scripts/evaluate_all_tasks.py \
18
+ --draw-seed 42 --policy your_policy:build \
19
+ --out-dir outputs/my_policy_all_tasks
20
+
21
+ # Open the evaluator viewer for every episode and save per-episode videos.
22
+ python scripts/evaluate_all_tasks.py \
23
+ --draw-seed 42 --visualize \
24
+ --video-dir outputs/all_tasks_videos \
25
+ --out-dir outputs/all_tasks_visual
26
+
27
+ # Fast, unscored interface check (still uses all 16 tasks and 720 episodes).
28
+ python scripts/evaluate_all_tasks.py --draw-seed 0 \
29
+ --state-only-smoke --out-dir outputs/all_tasks_smoke
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import importlib
36
+ import json
37
+ from pathlib import Path
38
+ import subprocess
39
+ import sys
40
+ from typing import Any
41
+
42
+
43
+ ROOT = Path(__file__).resolve().parents[1]
44
+ EVALUATOR = ROOT / "scripts" / "evaluate_policy.py"
45
+
46
+ # Keep the release order stable in summary files. The candidate-bank manifest
47
+ # is validated by evaluate_policy.py against the same sixteen-task release set;
48
+ # this explicit tuple is only used to summarize group outcomes by task.
49
+ TASKS = (
50
+ "curling_push",
51
+ "button_indicator_matching",
52
+ "phone_lean",
53
+ "leveling_board1",
54
+ "rail_ball_to_cup",
55
+ "pitch_pot",
56
+ "conveyor_grasp",
57
+ "turntable_gamble1",
58
+ "ring_toss1",
59
+ "box_insertion",
60
+ "car_drive2",
61
+ "rope_hang",
62
+ "pan_flip",
63
+ "laptop_into_drawer",
64
+ "find_object_in_drawer2",
65
+ "tilte_maze",
66
+ )
67
+ EXPECTED_GROUPS = 3 * len(TASKS)
68
+ EXPECTED_EPISODES = 15 * EXPECTED_GROUPS
69
+
70
+
71
+ def _json_file(path: Path) -> dict[str, Any] | None:
72
+ try:
73
+ value = json.loads(path.read_text(encoding="utf-8"))
74
+ except (OSError, json.JSONDecodeError):
75
+ return None
76
+ return value if isinstance(value, dict) else None
77
+
78
+
79
+ def _build_parser() -> argparse.ArgumentParser:
80
+ parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
81
+ parser.add_argument(
82
+ "--candidate-bank",
83
+ type=Path,
84
+ default=Path("react_manip/suites/piperx_candidates_v1/bank.json"),
85
+ help="candidate bank containing the original-data manifest",
86
+ )
87
+ parser.add_argument(
88
+ "--draw-seed",
89
+ type=int,
90
+ required=True,
91
+ help="nonnegative, reproducible candidate selection seed",
92
+ )
93
+ parser.add_argument(
94
+ "--policy",
95
+ help="importable policy factory/object as module:object; omit to use RandomPolicy",
96
+ )
97
+ parser.add_argument(
98
+ "--state-only-smoke",
99
+ action="store_true",
100
+ help="disable RGB observations; this produces no protocol score",
101
+ )
102
+ parser.add_argument(
103
+ "--visualize",
104
+ action="store_true",
105
+ help="open the policy-camera viewer while evaluating all episodes",
106
+ )
107
+ parser.add_argument(
108
+ "--video-dir",
109
+ type=Path,
110
+ help="new directory for per-episode composite/camera videos",
111
+ )
112
+ parser.add_argument(
113
+ "--episode-wall-timeout-sec",
114
+ type=float,
115
+ help="optional wall-clock timeout forwarded to evaluate_policy.py",
116
+ )
117
+ parser.add_argument(
118
+ "--task-wall-timeout-sec",
119
+ type=float,
120
+ help="optional per-task wall-clock timeout forwarded to evaluate_policy.py",
121
+ )
122
+ parser.add_argument(
123
+ "--out-dir",
124
+ type=Path,
125
+ default=Path("outputs/all_tasks_evaluation"),
126
+ help="new directory for the combined JSON result, logs, and summary",
127
+ )
128
+ return parser
129
+
130
+
131
+ def _validate_args(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
132
+ if args.draw_seed < 0:
133
+ parser.error("--draw-seed must be nonnegative")
134
+ for name in ("episode_wall_timeout_sec", "task_wall_timeout_sec"):
135
+ value = getattr(args, name)
136
+ if value is not None and value <= 0:
137
+ parser.error(f"--{name.replace('_', '-')} must be positive")
138
+ if args.state_only_smoke and (args.visualize or args.video_dir is not None):
139
+ parser.error("--visualize/--video-dir require RGB observations; omit --state-only-smoke")
140
+ if args.video_dir is not None and args.video_dir.exists():
141
+ parser.error(f"video directory already exists: {args.video_dir} (choose a new path)")
142
+
143
+
144
+ def _validate_policy(parser: argparse.ArgumentParser, policy_path: str | None) -> None:
145
+ """Fail before creating a selection or launching the full bank run."""
146
+
147
+ if not policy_path:
148
+ return
149
+ if ":" not in policy_path:
150
+ parser.error("--policy must use module:object syntax (or omit it for RandomPolicy)")
151
+ module_name, object_name = policy_path.split(":", 1)
152
+ if not module_name or not object_name:
153
+ parser.error("--policy must use module:object syntax (or omit it for RandomPolicy)")
154
+ if str(ROOT) not in sys.path:
155
+ sys.path.insert(0, str(ROOT))
156
+ try:
157
+ module = importlib.import_module(module_name)
158
+ getattr(module, object_name)
159
+ except (ImportError, AttributeError) as exc:
160
+ parser.error(
161
+ f"cannot import --policy {policy_path!r}: {exc}. "
162
+ "Put the policy module on PYTHONPATH/in the repository, or omit "
163
+ "--policy to use RandomPolicy."
164
+ )
165
+
166
+
167
+ def _task_for_group(task_instance_id: str) -> str | None:
168
+ for task_id in TASKS:
169
+ if task_instance_id.startswith(task_id + "_"):
170
+ return task_id
171
+ return None
172
+
173
+
174
+ def _run_evaluation(args: argparse.Namespace, out_dir: Path) -> dict[str, Any]:
175
+ result_path = out_dir / "all_tasks.json"
176
+ stdout_path = out_dir / "all_tasks.stdout.log"
177
+ stderr_path = out_dir / "all_tasks.stderr.log"
178
+ bank_path = args.candidate_bank
179
+ if not bank_path.is_absolute():
180
+ bank_path = ROOT / bank_path
181
+ command = [
182
+ sys.executable,
183
+ str(EVALUATOR),
184
+ "--candidate-bank",
185
+ str(bank_path),
186
+ "--draw-seed",
187
+ str(args.draw_seed),
188
+ "--out",
189
+ str(result_path),
190
+ ]
191
+ if args.policy:
192
+ command += ["--policy", args.policy]
193
+ if args.state_only_smoke:
194
+ command.append("--state-only-smoke")
195
+ if args.visualize:
196
+ command.append("--visualize")
197
+ if args.video_dir is not None:
198
+ video_dir = args.video_dir
199
+ if not video_dir.is_absolute():
200
+ video_dir = ROOT / video_dir
201
+ command += ["--video-dir", str(video_dir)]
202
+ for option, value in (
203
+ ("--episode-wall-timeout-sec", args.episode_wall_timeout_sec),
204
+ ("--task-wall-timeout-sec", args.task_wall_timeout_sec),
205
+ ):
206
+ if value is not None:
207
+ command += [option, str(value)]
208
+
209
+ completed = subprocess.run(
210
+ command,
211
+ cwd=ROOT,
212
+ text=True,
213
+ capture_output=True,
214
+ check=False,
215
+ )
216
+ stdout_path.write_text(completed.stdout, encoding="utf-8")
217
+ stderr_path.write_text(completed.stderr, encoding="utf-8")
218
+ payload = _json_file(result_path)
219
+ protocol = payload.get("protocol") if payload else None
220
+ groups = payload.get("groups", []) if payload else []
221
+ completed_episodes = payload.get("completed_episodes") if payload else None
222
+ protocol_shape_ok = (
223
+ isinstance(protocol, dict)
224
+ and protocol.get("name") == "practice_exam"
225
+ and protocol.get("practice_episodes") == 10
226
+ and protocol.get("exam_episodes") == 5
227
+ and len(groups) == EXPECTED_GROUPS
228
+ and completed_episodes == EXPECTED_EPISODES
229
+ )
230
+ valid = bool(
231
+ payload
232
+ and payload.get("valid", False)
233
+ and completed.returncode == 0
234
+ and protocol_shape_ok
235
+ )
236
+ record: dict[str, Any] = {
237
+ "returncode": completed.returncode,
238
+ "valid": valid,
239
+ "result": str(result_path),
240
+ "stdout_log": str(stdout_path),
241
+ "stderr_log": str(stderr_path),
242
+ }
243
+ if payload is not None:
244
+ record["summary"] = payload.get("summary")
245
+ record["completed_episodes"] = completed_episodes
246
+ record["protocol"] = payload.get("protocol")
247
+ record["protocol_shape_ok"] = protocol_shape_ok
248
+ record["manifest_digest"] = payload.get("manifest_digest")
249
+ by_task = {task_id: [] for task_id in TASKS}
250
+ for group in payload.get("groups", []):
251
+ task_id = _task_for_group(str(group.get("task_instance_id", "")))
252
+ if task_id is not None:
253
+ by_task[task_id].append(group)
254
+ record["tasks"] = {
255
+ task_id: {
256
+ "groups": len(groups),
257
+ "valid": bool(groups) and all(item.get("valid", False) for item in groups),
258
+ "completed_episodes": sum(int(item.get("completed_episodes", 0)) for item in groups),
259
+ "score": (
260
+ sum(float(item["score"]) for item in groups if item.get("score") is not None)
261
+ / sum(item.get("score") is not None for item in groups)
262
+ if any(item.get("score") is not None for item in groups) else None
263
+ ),
264
+ }
265
+ for task_id, groups in by_task.items()
266
+ }
267
+ if completed.returncode != 0 and completed.stderr:
268
+ # Keep the machine-readable summary small while retaining a useful
269
+ # first-line diagnostic in it; the full traceback stays in the log.
270
+ lines = [line for line in completed.stderr.splitlines() if line.strip()]
271
+ record["error"] = lines[-1] if lines else "evaluate_policy.py failed"
272
+ return record
273
+
274
+
275
+ def main(argv: list[str] | None = None) -> int:
276
+ parser = _build_parser()
277
+ args = parser.parse_args(argv)
278
+ _validate_args(parser, args)
279
+ _validate_policy(parser, args.policy)
280
+ out_dir = args.out_dir if args.out_dir.is_absolute() else ROOT / args.out_dir
281
+ if out_dir.exists() and any(out_dir.iterdir()):
282
+ parser.error(f"output directory is not empty: {out_dir} (choose a new --out-dir)")
283
+ out_dir.mkdir(parents=True, exist_ok=True)
284
+
285
+ print(f"evaluating all {len(TASKS)} tasks from the original candidate-bank protocol ...", flush=True)
286
+ record = _run_evaluation(args, out_dir)
287
+ print(f"all {len(TASKS)} tasks: " + ("PASS" if record["valid"] else f"FAIL (rc={record['returncode']})"), flush=True)
288
+
289
+ summary = {
290
+ "script": "evaluate_all_tasks.py",
291
+ "tasks": list(TASKS),
292
+ "mode": "state_only_smoke" if args.state_only_smoke else "rgb",
293
+ "visualize": args.visualize,
294
+ "video_dir": str(args.video_dir) if args.video_dir is not None else None,
295
+ "policy": args.policy,
296
+ "draw_seed": args.draw_seed,
297
+ "valid": record["valid"],
298
+ "evaluation": record,
299
+ }
300
+ summary_path = out_dir / "summary.json"
301
+ summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
302
+ passed = sum(item.get("valid", False) for item in record.get("tasks", {}).values())
303
+ print(f"completed: {passed}/{len(TASKS)} tasks passed")
304
+ print(f"summary -> {summary_path}")
305
+ return 0 if summary["valid"] else 1
306
+
307
+
308
+ if __name__ == "__main__":
309
+ raise SystemExit(main())
@@ -0,0 +1,281 @@
1
+ #!/usr/bin/env python3
2
+ """Evaluate an external policy on ReAct-Manip's sixteen release tasks.
3
+
4
+ python scripts/evaluate_policy.py --candidate-bank react_manip/suites/piperx_candidates_v1/bank.json --draw-seed 42 --policy my_policy:build --out outputs/result.json
5
+ python scripts/evaluate_policy.py --task conveyor_grasp --state-only-smoke --horizon-sec 1
6
+
7
+ See README.md for the policy lifecycle and practice/exam protocol.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import importlib
14
+ import inspect
15
+ from copy import deepcopy
16
+ import json
17
+ import math
18
+ from pathlib import Path
19
+ import sys
20
+ from typing import Any
21
+
22
+ # Running ``python scripts/evaluate_policy.py`` places ``scripts/`` (rather
23
+ # than the repository root) on sys.path. Make the CLI work both that way and
24
+ # via ``python -m`` without requiring PYTHONPATH setup.
25
+ ROOT = Path(__file__).resolve().parents[1]
26
+ if str(ROOT) not in sys.path:
27
+ sys.path.insert(0, str(ROOT))
28
+
29
+ from react_manip.builtin import register_builtin_tasks
30
+ from react_manip.core import BenchmarkRunner, get_task_registry
31
+ from react_manip.policies import CallablePolicy, RandomPolicy
32
+ from react_manip.robot_registry import register_default_robots
33
+
34
+
35
+ def _load_object(path: str) -> Any:
36
+ if ":" not in path:
37
+ raise ValueError(f"expected module:object, got {path!r}")
38
+ module, name = path.split(":", 1)
39
+ return getattr(importlib.import_module(module), name)
40
+
41
+
42
+ def main(argv=None) -> int:
43
+ parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
44
+ parser.add_argument("--candidate-bank", type=Path, help="candidate bank (default: shipped piperx_candidates_v1 when neither --manifest nor --task is given)")
45
+ parser.add_argument("--draw-seed", type=int, help="required candidate-bank selection seed, independent of policy seed")
46
+ parser.add_argument("--selection-out", type=Path, help="new draw directory; defaults beside result")
47
+ parser.add_argument("--manifest", type=Path, help="explicit Task/Episode Instance suite manifest")
48
+ parser.add_argument("--state-only-smoke", action="store_true", help="disable policy images for smoke checks; not an RGB evaluation")
49
+ parser.add_argument("--trace-dir", type=Path, help="manifest only: new public trajectory directory")
50
+ parser.add_argument("--visualize", action="store_true", help="display policy main/wrist RGB; Space pauses, Tab toggles debug view, Esc aborts")
51
+ parser.add_argument("--video-dir", type=Path, help="new directory for per-episode composite and per-camera MP4 videos")
52
+ parser.add_argument("--task", help="registered task id")
53
+ parser.add_argument("--scenario", type=Path, help="resolved Scenario JSON to load")
54
+ parser.add_argument("--policy", help="policy factory/object module:object")
55
+ parser.add_argument("--robot", default=None, help="robot embodiment override; defaults to the task's sole supported robot")
56
+ parser.add_argument("--episodes", type=int, help="independent protocol only (default: task config or 3)")
57
+ parser.add_argument("--groups", type=int, default=1, help="number of independent hidden-parameter groups")
58
+ parser.add_argument("--seed", type=int, default=0)
59
+ parser.add_argument("--horizon-sec", type=float)
60
+ parser.add_argument("--control-hz", type=float)
61
+ parser.add_argument("--episode-wall-timeout-sec", type=float,
62
+ help="real-time limit per episode (default: 1800 seconds)")
63
+ parser.add_argument("--task-wall-timeout-sec", type=float,
64
+ help="real-time limit for all episodes of one task (defaults: 2h conveyor_grasp, 4h find_object_in_drawer2, 1h otherwise)")
65
+ parser.add_argument(
66
+ "--render",
67
+ action="store_true",
68
+ help="open and refresh the native simulator viewer (interactive mode)",
69
+ )
70
+ parser.add_argument(
71
+ "--render-realtime",
72
+ action="store_true",
73
+ help="pace rendered steps at control_hz instead of running as fast as possible",
74
+ )
75
+ parser.add_argument(
76
+ "--render-hold-sec",
77
+ type=float,
78
+ default=3.0,
79
+ help="seconds to keep the viewer open after each rendered episode (default: 3)",
80
+ )
81
+ parser.add_argument("--out", type=Path, default=Path("outputs/benchmark_v2_results.json"))
82
+ argv = list(sys.argv[1:] if argv is None else argv)
83
+ args = parser.parse_args(argv)
84
+ for name in ('horizon_sec', 'control_hz', 'render_hold_sec',
85
+ 'episode_wall_timeout_sec', 'task_wall_timeout_sec'):
86
+ value = getattr(args, name)
87
+ if value is not None and (not math.isfinite(value) or
88
+ (value < 0 if name == 'render_hold_sec' else value <= 0)):
89
+ parser.error('--' + name.replace('_', '-') + ' must be finite and ' +
90
+ ('nonnegative' if name == 'render_hold_sec' else 'positive'))
91
+ if args.candidate_bank is None and args.manifest is None and args.task is None:
92
+ args.candidate_bank = ROOT / "react_manip/suites/piperx_candidates_v1/bank.json"
93
+ if args.robot is not None:
94
+ from react_manip.robots.catalog import normalize_robot_id
95
+ args.robot = normalize_robot_id(args.robot)
96
+ supplied = {arg.split("=", 1)[0] for arg in argv if arg.startswith("--")}
97
+ selection = None
98
+ if args.visualize or args.video_dir is not None:
99
+ if not (args.candidate_bank or args.manifest or args.task) or args.state_only_smoke:
100
+ parser.error('--visualize/--video-dir require an RGB task, candidate-bank or manifest evaluation')
101
+ if args.video_dir is not None and args.video_dir.exists():
102
+ parser.error('video directory already exists; choose a new --video-dir path')
103
+ from react_manip.evaluation.viewer import check_dependencies
104
+ try:
105
+ check_dependencies(args.visualize, args.video_dir is not None)
106
+ except ImportError as exc:
107
+ parser.error(str(exc))
108
+ if args.candidate_bank:
109
+ if args.manifest or args.draw_seed is None or args.draw_seed < 0:
110
+ parser.error("--candidate-bank requires nonnegative --draw-seed and excludes --manifest")
111
+ # Apply manifest-mode conflicts before creating any selection files.
112
+ forbidden_bank = {"--task", "--scenario", "--robot",
113
+ "--episodes", "--groups", "--seed", "--horizon-sec", "--control-hz",
114
+ "--render", "--render-realtime", "--render-hold-sec"}
115
+ if supplied & forbidden_bank:
116
+ parser.error("candidate bank owns environment/protocol settings: " + ", ".join(sorted(supplied & forbidden_bank)))
117
+ if args.out.exists() or (args.trace_dir is not None and args.trace_dir.exists()):
118
+ parser.error("result/trace output already exists")
119
+ from react_manip.instances.candidate_bank import draw
120
+ try:
121
+ args.manifest, selection = draw(args.candidate_bank, seed=args.draw_seed,
122
+ out=args.selection_out or args.out.with_name(args.out.stem + "_selection"))
123
+ except (ValueError, TypeError, OSError) as exc:
124
+ parser.error(str(exc))
125
+ elif args.draw_seed is not None or args.selection_out is not None:
126
+ parser.error("--draw-seed and --selection-out require --candidate-bank")
127
+ if args.manifest:
128
+ # Candidate storage must never be accidentally evaluated as 48 groups.
129
+ bank_path = args.manifest.parent / "bank.json"
130
+ if bank_path.exists():
131
+ from react_manip.instances.candidate_bank import load_bank
132
+ _, candidate_path, _, _ = load_bank(bank_path)
133
+ if args.manifest.resolve() == candidate_path:
134
+ parser.error("candidate storage is not an evaluation manifest; use --candidate-bank or draw first")
135
+ forbidden = {"--task", "--scenario", "--robot",
136
+ "--episodes", "--groups", "--seed", "--horizon-sec", "--control-hz",
137
+ "--render", "--render-realtime", "--render-hold-sec"}
138
+ conflicts = sorted(supplied & forbidden)
139
+ if conflicts:
140
+ parser.error("--manifest owns environment and protocol settings; incompatible: " + ", ".join(conflicts))
141
+ if args.out.exists():
142
+ parser.error("manifest result file already exists; choose a new --out path")
143
+ if args.trace_dir is not None and args.trace_dir.exists():
144
+ parser.error("trace directory already exists; choose a new --trace-dir path")
145
+ elif args.trace_dir is not None:
146
+ parser.error("--trace-dir requires --manifest")
147
+ if args.groups < 1 or (args.episodes is not None and args.episodes < 1) or args.seed < 0:
148
+ parser.error("groups/episodes must be positive and seed must be nonnegative")
149
+
150
+ def policy_factory() -> Any:
151
+ if not args.policy:
152
+ return RandomPolicy(seed=args.seed)
153
+ obj = _load_object(args.policy)
154
+ if isinstance(obj, type):
155
+ value = obj()
156
+ elif callable(obj) and not hasattr(obj, "act"):
157
+ try:
158
+ inspect.signature(obj).bind()
159
+ except TypeError:
160
+ value = obj
161
+ else:
162
+ value = obj()
163
+ else:
164
+ value = deepcopy(obj)
165
+ if callable(value) and not hasattr(value, "act"):
166
+ value = CallablePolicy(value)
167
+ return value
168
+
169
+ def report(row):
170
+ if "error" in row:
171
+ print(f"[benchmark error] {row['error']}", file=sys.stderr)
172
+ print(json.dumps(row, ensure_ascii=False, sort_keys=True))
173
+
174
+ if args.manifest:
175
+ from react_manip.instances.suite import evaluate as evaluate_manifest
176
+ from react_manip.instances.candidate_bank import selection_for
177
+ try:
178
+ selection = selection_for(args.manifest)
179
+ except (ValueError, TypeError, OSError) as exc:
180
+ parser.error(str(exc))
181
+ try:
182
+ timeout_options = ({'task_wall_timeout_sec': args.task_wall_timeout_sec}
183
+ if args.task_wall_timeout_sec is not None else {})
184
+ evaluation = evaluate_manifest(args.manifest, policy_factory,
185
+ cameras=not args.state_only_smoke,
186
+ trace_dir=args.trace_dir, on_result=report,
187
+ visualize=args.visualize, video_dir=args.video_dir,
188
+ episode_wall_timeout_sec=args.episode_wall_timeout_sec,
189
+ **timeout_options)
190
+ except (ValueError, TypeError, OSError) as exc:
191
+ parser.error(str(exc))
192
+ rows = evaluation.get("episodes", [])
193
+ for group in evaluation["groups"]:
194
+ if group.get("error"):
195
+ print(f"[benchmark error] {group['task_instance_id']}: {group['error']}", file=sys.stderr)
196
+ payload = {
197
+ "benchmark_api_version": "2.0", "metrics_schema_version": "2.0",
198
+ "manifest_path": str(args.manifest.resolve()),
199
+ "policy_factory": args.policy,
200
+ "policy_id": rows[0]["policy_id"] if rows else None,
201
+ "summary": {"groups": len(evaluation["groups"]), "completed_episodes": len(rows),
202
+ "invalid_groups": sum(not g["valid"] for g in evaluation["groups"]),
203
+ "score": evaluation["protocol_score"]},
204
+ **evaluation,
205
+ **({"candidate_selection": selection} if selection is not None else {}),
206
+ }
207
+ args.out.parent.mkdir(parents=True, exist_ok=True)
208
+ with args.out.open("x", encoding="utf-8") as stream:
209
+ json.dump(payload, stream, ensure_ascii=False, indent=2, allow_nan=False)
210
+ print(f"results -> {args.out}")
211
+ return 0 if evaluation["valid"] else 1
212
+
213
+ register_default_robots()
214
+ register_builtin_tasks()
215
+
216
+ if not args.task:
217
+ parser.error('provide --candidate-bank, --manifest or --task')
218
+ try:
219
+ definition = get_task_registry().get(args.task)
220
+ except KeyError:
221
+ parser.error(f'unknown release task: {args.task}')
222
+ spec, factory = definition.spec, definition.factory
223
+ if args.control_hz is not None and args.control_hz != spec.control_hz:
224
+ parser.error(f'--control-hz must match the task physics rate ({spec.control_hz:g} Hz); '
225
+ 'changing evaluator tick counts does not change robot control timing')
226
+ from react_manip.core.compatibility import resolve_task_robot
227
+ try:
228
+ args.robot = resolve_task_robot(spec, args.robot)
229
+ except ValueError as exc:
230
+ parser.error(str(exc))
231
+
232
+ runner = BenchmarkRunner()
233
+ scenario = None
234
+ if args.scenario:
235
+ from react_manip.core.scenarios import Scenario
236
+ scenario = Scenario.load(args.scenario)
237
+ if scenario.task_id != spec.task_id:
238
+ parser.error(f"scenario task {scenario.task_id!r} does not match {spec.task_id!r}")
239
+ from react_manip.evaluation import EvaluationContext, load_protocol
240
+
241
+ try:
242
+ protocol = load_protocol(spec)
243
+ from react_manip.evaluation.viewer import EvaluationViewer
244
+ task_timeout_options = ({'task_wall_timeout_sec': args.task_wall_timeout_sec}
245
+ if args.task_wall_timeout_sec is not None else {})
246
+ with EvaluationViewer(enabled=args.visualize, video_dir=args.video_dir) as viewer:
247
+ evaluation = protocol.run(EvaluationContext(
248
+ spec=spec, factory=factory, policy_factory=policy_factory,
249
+ viewer=viewer if viewer.active else None,
250
+ robot_id=args.robot, seed=args.seed, scenario=scenario,
251
+ groups=args.groups, episodes=args.episodes, runner=runner,
252
+ runner_options=dict(camera_observations=not args.state_only_smoke,
253
+ horizon_sec=args.horizon_sec, control_hz=args.control_hz,
254
+ episode_wall_timeout_sec=args.episode_wall_timeout_sec,
255
+ render=args.render, render_realtime=args.render_realtime,
256
+ render_hold_sec=args.render_hold_sec,
257
+ **task_timeout_options), on_result=report))
258
+ except (ValueError, TypeError) as exc:
259
+ parser.error(str(exc))
260
+ results = evaluation.get("episodes", [])
261
+
262
+ args.out.parent.mkdir(parents=True, exist_ok=True)
263
+ payload = {
264
+ "benchmark_api_version": "2.0",
265
+ "metrics_schema_version": "2.0",
266
+ "task_id": spec.task_id,
267
+ "task_version": spec.task_version,
268
+ "robot_id": args.robot,
269
+ "observation_mode": "state_only_smoke" if args.state_only_smoke else "rgb",
270
+ "scenario_id": scenario.scenario_id if scenario else None,
271
+ "scenario_digest": scenario.digest if scenario else None,
272
+ "policy_id": results[0]["policy_id"] if results else None,
273
+ **evaluation,
274
+ }
275
+ args.out.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
276
+ print(f"results -> {args.out}")
277
+ return 0 if evaluation.get("valid", True) else 1
278
+
279
+
280
+ if __name__ == "__main__":
281
+ raise SystemExit(main())