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,306 @@
1
+ """Versioned physical adapters; all runtime sampling is confined to generation.
2
+
3
+ Initial state uses MuJoCo's version-bound integration vector. Controllers start
4
+ from reset targets at that state; this is an initial-state format, not a generic
5
+ mid-trajectory checkpoint. No simulator state is assigned during env.step().
6
+ """
7
+ from copy import deepcopy
8
+ import hashlib
9
+ import json
10
+ from pathlib import Path
11
+ import sys
12
+ import warnings
13
+
14
+ import numpy as np
15
+
16
+ from .records import VERSION, SUPPORTED_TASKS, digest, seal, validate_pair, validate_record, validate_parameters
17
+
18
+ ROOT = Path(__file__).resolve().parents[2]
19
+ from react_manip.robots.sensors import policy_camera_ids
20
+
21
+ _SOURCE_DRIFT_WARNED: set[str] = set()
22
+
23
+
24
+ def _check_runtime_identity(actual, expected, task_id):
25
+ """Validate compiled runtime fields while tolerating evaluator-only drift.
26
+
27
+ Saved instances contain a broad source digest. Changes to the viewer or
28
+ evaluator control flow must not invalidate already-recorded physical
29
+ scenes, so a source-digest-only difference is reported as a warning. Model,
30
+ controller, camera, and physics identity changes remain hard failures.
31
+ """
32
+
33
+ mismatches = [key for key in expected if actual.get(key) != expected.get(key)]
34
+ mismatches.extend(key for key in actual if key not in expected)
35
+ if not mismatches:
36
+ return
37
+ if set(mismatches) == {'source_digest'}:
38
+ if task_id not in _SOURCE_DRIFT_WARNED:
39
+ warnings.warn(
40
+ f'{task_id}: saved source digest differs from the current evaluator; '
41
+ 'continuing because compiled runtime identity matches',
42
+ RuntimeWarning,
43
+ stacklevel=3,
44
+ )
45
+ _SOURCE_DRIFT_WARNED.add(task_id)
46
+ return
47
+ raise ValueError('compiled model/controller/runtime mismatch')
48
+
49
+
50
+ def source_digest(task_id):
51
+ paths = list((ROOT / 'react_manip').rglob('*.py')) + list((ROOT / 'tasks' / task_id).rglob('*.py'))
52
+ paths += list((ROOT / 'tasks' / task_id).rglob('*.xml'))
53
+ return digest({str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(paths)})
54
+
55
+
56
+ def _json(value):
57
+ if isinstance(value, np.ndarray):
58
+ return value.tolist()
59
+ if isinstance(value, np.generic):
60
+ return value.item()
61
+ if isinstance(value, dict):
62
+ return {str(k): _json(v) for k, v in value.items()}
63
+ if isinstance(value, (list, tuple)):
64
+ return [_json(v) for v in value]
65
+ return value
66
+
67
+
68
+ def _model_digest(env):
69
+ # Hash compiled geometry/dynamics/assets, not XML temporary file names.
70
+ model = env.sim.model._model
71
+ names = ('qpos0 body_pos body_quat body_mass body_inertia body_ipos body_iquat '
72
+ 'jnt_type jnt_axis jnt_pos jnt_range dof_damping dof_frictionloss dof_armature '
73
+ 'geom_type geom_bodyid geom_pos geom_quat geom_size geom_friction geom_solref geom_solimp '
74
+ 'geom_contype geom_conaffinity geom_condim pair_friction pair_solref pair_solimp '
75
+ 'actuator_gainprm actuator_biasprm actuator_dynprm actuator_ctrlrange actuator_forcerange '
76
+ 'eq_data eq_type eq_obj1id eq_obj2id mesh_vert mesh_face mesh_normal '
77
+ 'cam_bodyid cam_pos cam_quat cam_fovy light_pos light_dir light_diffuse light_specular light_ambient '
78
+ 'geom_rgba geom_matid mat_rgba mat_texid mat_texrepeat mat_emission mat_specular mat_shininess mat_reflectance tex_data').split()
79
+ h = hashlib.sha256()
80
+ for name in names:
81
+ value = getattr(model, name, None)
82
+ if value is not None:
83
+ array = np.ascontiguousarray(value)
84
+ h.update(name.encode()); h.update(str((array.shape, str(array.dtype))).encode()); h.update(array.tobytes())
85
+ h.update(bytes(model.names))
86
+ h.update(str((model.nq, model.nv, model.na, model.nu, model.opt.timestep,
87
+ model.opt.integrator, model.opt.solver, model.opt.iterations,
88
+ model.opt.tolerance, tuple(model.opt.gravity))).encode())
89
+ return h.hexdigest()
90
+
91
+
92
+ def spec_for(task_id):
93
+ from react_manip.core.specs import load_task_spec
94
+ if task_id not in SUPPORTED_TASKS:
95
+ raise ValueError('task has not passed the instance adapter gate')
96
+ return load_task_spec(ROOT / 'tasks' / task_id / 'task_spec.json')
97
+
98
+
99
+ def contract_for(task_id):
100
+ return json.loads((ROOT / 'tasks' / task_id / 'instance_contract.json').read_text())
101
+
102
+
103
+ def _construct(task_id, scenario, robot_id=None, episode=None, *, render=False):
104
+ from react_manip.builtin import discover_task_definitions
105
+ definition = next((s, f) for s, f in discover_task_definitions(task_ids=(task_id,)) if s.task_id == task_id)
106
+ if episode is None:
107
+ episode = deepcopy(scenario.metadata.get('initial_episode_scene'))
108
+ extra = {'episode_instance': episode} if task_id in ('phone_lean','leveling_board1','rope_hang','laptop_into_drawer') else {}
109
+ env = definition[1](**extra, scenario=scenario, robot_id=robot_id, seed=scenario.seed,
110
+ initialization_noise=None, has_renderer=render, has_offscreen_renderer=False,
111
+ use_camera_obs=False)
112
+ return env
113
+
114
+
115
+ def runtime_identity(env, task_id):
116
+ import mujoco
117
+ import robosuite
118
+ raw = getattr(env, 'env', env)
119
+ spec = spec_for(task_id)
120
+ controllers = [r.composite_controller.composite_controller_specific_config
121
+ if hasattr(r.composite_controller, 'composite_controller_specific_config') else {}
122
+ for r in raw.robots]
123
+ # Per-arm config is the concrete config supplied to controller construction.
124
+ controllers += [getattr(r, 'controller_config', {}) for r in raw.robots]
125
+ for robot in raw.robots:
126
+ controllers.append({name: {
127
+ 'class': type(part).__module__ + '.' + type(part).__name__,
128
+ **{key: _json(getattr(part, key)) for key in (
129
+ 'input_min', 'input_max', 'output_min', 'output_max', 'kp', 'kd',
130
+ 'damping_ratio', 'control_dim', 'input_ref_frame', 'input_type', 'impedance_mode')
131
+ if hasattr(part, key)}} for name, part in robot.composite_controller.part_controllers.items()})
132
+ return dict(python='.'.join(map(str, sys.version_info[:2])), numpy=np.__version__,
133
+ mujoco=mujoco.__version__, robosuite=robosuite.__version__,
134
+ source_digest=source_digest(task_id), control_hz=spec.control_hz,
135
+ horizon_sec=spec.horizon_sec, physics_timestep=float(raw.sim.model.opt.timestep),
136
+ model_digest=_model_digest(raw),
137
+ action_layout=_json(raw.robots[0].composite_controller._action_split_indexes),
138
+ controller_digest=digest(_json(controllers)), cameras=list(policy_camera_ids(raw.robot_id if hasattr(raw, 'robot_id') else spec.supported_robots[0])))
139
+
140
+
141
+ def capture_state(env):
142
+ import mujoco
143
+ raw = getattr(env, 'env', env)
144
+ model, data = raw.sim.model._model, raw.sim.data._data
145
+ state_spec = int(mujoco.mjtState.mjSTATE_INTEGRATION)
146
+ vector = np.empty(mujoco.mj_stateSize(model, state_spec))
147
+ mujoco.mj_getState(model, data, vector, state_spec)
148
+ return dict(format='mujoco.integration.v1', state_spec=state_spec, integration_state=vector.tolist())
149
+
150
+
151
+
152
+
153
+
154
+
155
+ class ReplayEnv:
156
+ """Bind explicit episodes to an instance; never forward their files to policy."""
157
+ def __init__(self, instance, *, cameras=True, native_actions=False):
158
+ self._native_actions = native_actions
159
+ validate_record(instance)
160
+ spec = spec_for(instance['task_id'])
161
+ if digest(spec.to_dict()) != instance['task_spec_digest'] or digest(contract_for(spec.task_id)) != instance['contract_digest']:
162
+ raise ValueError('TaskSpec/instance contract version mismatch')
163
+ validate_parameters(instance['scenario']['parameters'], contract_for(spec.task_id))
164
+ from react_manip.core.scenarios import Scenario
165
+ data = instance['scenario']
166
+ scenario = Scenario(**{k: deepcopy(v) for k, v in data.items() if k != 'digest'})
167
+ self._instance = deepcopy(instance)
168
+ self._cameras = cameras
169
+ self._episode = None
170
+ self._wrapped = _construct(spec.task_id, scenario, instance['robot_id'])
171
+ self.env = self._wrapped.env # policy geometry filter expects the task here
172
+ try:
173
+ self._wrapped.reset()
174
+ _check_runtime_identity(runtime_identity(self._wrapped, spec.task_id),
175
+ instance['runtime'], spec.task_id)
176
+ if cameras:
177
+ self._wrapped.set_camera_observations(instance['runtime']['cameras'])
178
+ except BaseException:
179
+ self.close()
180
+ raise
181
+
182
+ def __getattr__(self, name):
183
+ return getattr(self._wrapped, name)
184
+
185
+ def bind(self, episode):
186
+ validate_pair(self._instance, episode)
187
+ if 'variation' in episode or (self._episode and 'variation' in self._episode):
188
+ from react_manip.core.scenarios import Scenario
189
+ from .variation import episode_scenario
190
+ data = self._instance['scenario']
191
+ scenario = Scenario(**{k: deepcopy(v) for k,v in data.items() if k != 'digest'})
192
+ if 'variation' in episode:
193
+ scenario = episode_scenario(scenario, episode['variation'])
194
+ candidate = _construct(self._instance['task_id'], scenario,
195
+ self._instance['robot_id'], episode=episode.get('scene'))
196
+ try:
197
+ candidate.reset()
198
+ expected = episode.get('runtime', self._instance['runtime'])
199
+ _check_runtime_identity(runtime_identity(candidate, self._instance['task_id']),
200
+ expected, self._instance['task_id'])
201
+ if self._cameras:
202
+ candidate.set_camera_observations(expected['cameras'])
203
+ except BaseException:
204
+ candidate.close()
205
+ raise
206
+ self._wrapped.close()
207
+ self._wrapped, self.env = candidate, candidate.env
208
+ if self._instance['task_id'] == 'phone_lean':
209
+ # Poses reference the explicit Episode scene.
210
+ self.env.episode_instance = deepcopy(episode['scene'])
211
+ self.env.episode_seed = episode['scene']['episode_seed']
212
+ self.env.layout_seed = self.env.episode_seed
213
+ if self._instance['task_id'] in ('leveling_board1','rope_hang','laptop_into_drawer'):
214
+ self.env.episode_instance = deepcopy(episode['scene'])
215
+ self._episode = deepcopy(episode)
216
+
217
+ def reset(self, *, seed=None, **kwargs):
218
+ if seed is not None or kwargs:
219
+ raise ValueError('explicit episode replay forbids seed/options overrides')
220
+ if self._episode is None:
221
+ raise ValueError('bind an Episode Instance before reset')
222
+ import mujoco
223
+ self._wrapped.reset()
224
+ self._wrapped._episode_index = 0
225
+ raw = self.env
226
+ saved = self._episode['initial_state']
227
+ if self._instance['task_id'] in ('rail_ball_to_cup', 'pitch_pot', 'conveyor_grasp', 'turntable_gamble1', 'ring_toss1', 'box_insertion', 'car_drive2', 'rope_hang', 'pan_flip', 'button_indicator_matching', 'laptop_into_drawer', 'find_object_in_drawer2', 'tilte_maze'):
228
+ # State must match the resolved model's reset, including Episode
229
+ # parameters; arbitrary resealed robot/object pose edits are forbidden.
230
+ if saved != capture_state(self._wrapped):
231
+ raise ValueError(f"{self._instance['task_id']} forbids Episode initial-state variation")
232
+ model, data = raw.sim.model._model, raw.sim.data._data
233
+ if saved['state_spec'] != int(mujoco.mjtState.mjSTATE_INTEGRATION):
234
+ raise ValueError('initial state mask mismatch')
235
+ vector = np.asarray(saved['integration_state'], dtype=float)
236
+ if vector.shape != (mujoco.mj_stateSize(model, saved['state_spec']),) or not np.isfinite(vector).all():
237
+ raise ValueError('initial state shape/value mismatch')
238
+ # This v1 format supports reset states only, not arbitrary task checkpoints.
239
+ if vector[0] != 0:
240
+ raise ValueError('initial state time must be zero')
241
+ mujoco.mj_setState(model, data, vector, saved['state_spec'])
242
+ raw.sim.forward()
243
+ raw.timestep = 0
244
+ raw.cur_time = 0
245
+ raw.done = False
246
+ for robot in raw.robots:
247
+ robot.composite_controller.update_state()
248
+ robot.composite_controller.reset()
249
+ for controller in robot.composite_controller.part_controllers.values():
250
+ if hasattr(controller, 'reset_goal'):
251
+ controller.reset_goal()
252
+ if self._instance['task_id'] == 'car_drive2':
253
+ # Native reset restores Python vehicle state; sync moving controller origins.
254
+ raw._set_car_pose()
255
+ # Resetting targets may touch controls; restore the exact integration input.
256
+ mujoco.mj_setState(model, data, vector, saved['state_spec'])
257
+ raw.sim.forward()
258
+ if self._instance['task_id'] in ('button_indicator_matching', 'find_object_in_drawer2'):
259
+ raw.set_episode_targets(self._episode['round_plan']['targets'])
260
+ raw._episode_index = 0
261
+ else:
262
+ raw._attempt = 1
263
+ raw._update_observables(force=True)
264
+ obs = raw._get_observations(force_update=True)
265
+ if self._instance['task_id'] in ('button_indicator_matching', 'conveyor_grasp'):
266
+ obs = raw._observation(obs)
267
+ elif self._instance['task_id'] == 'box_insertion':
268
+ obs = raw._observation()
269
+ prompt = self._wrapped.get_language_prompt()
270
+ self._wrapped.language_prompt = prompt
271
+ # mj_forward refreshes warm-start acceleration by tiny roundoff amounts.
272
+ # Restore the recorded integration input after all derived observations.
273
+ obs = self._wrapped._with_camera_observations(obs)
274
+ mujoco.mj_setState(model, data, vector, saved['state_spec'])
275
+ return obs, {
276
+ 'success_state': False, 'terminal_success': False, 'events': [],
277
+ 'language_prompt': prompt['text'], 'language_prompt_structured': prompt}
278
+
279
+ def step(self, action):
280
+ action = np.asarray(action, dtype=float)
281
+ low,high=-np.ones(self._wrapped.action_dim),np.ones(self._wrapped.action_dim)
282
+ if self._native_actions:
283
+ robot=self.env.robots[0]
284
+ for arm in robot.arms:
285
+ part=robot.part_controllers[arm]
286
+ if part.name=='JOINT_POSITION' and getattr(part,'input_type',None)=='absolute':
287
+ s,e=robot.composite_controller._action_split_indexes[arm]
288
+ limits=self.env.sim.model.jnt_range[part.joint_indexes['joints']]
289
+ low[s:e],high[s:e]=limits[:,0],limits[:,1]
290
+ if (action.shape != low.shape or not np.isfinite(action).all()
291
+ or np.any(action<low-1e-8) or np.any(action>high+1e-8)):
292
+ raise ValueError('action must be finite and inside the recorded controller bounds')
293
+ result = self._wrapped.step(action)
294
+ if not np.isfinite(self.env.sim.data.qpos).all() or not np.isfinite(self.env.sim.data.qvel).all():
295
+ raise ValueError('non-finite simulation state; evaluation is invalid')
296
+ # Refresh language after dynamic round transitions; do not send the future plan.
297
+ prompt = self._wrapped.get_language_prompt()
298
+ self._wrapped.language_prompt = prompt
299
+ obs, reward, terminated, truncated, info = result
300
+ if self._instance['task_id'] == 'rope_hang' and (info.get('failure') or {}).get('reason') in ('physics_instability', 'non_finite_state'):
301
+ raise ValueError('numerical rope simulation failure; evaluation is invalid')
302
+ info = dict(info, language_prompt=prompt['text'], language_prompt_structured=prompt)
303
+ return obs, reward, terminated, truncated, info
304
+
305
+ def close(self):
306
+ self._wrapped.close()
@@ -0,0 +1,231 @@
1
+ """Manifest execution. Files stay on the evaluator side of the policy boundary."""
2
+ import json
3
+ import math
4
+ import time
5
+ from pathlib import Path
6
+
7
+ from .records import VERSION, SUPPORTED_TASKS, load_suite, save_record, seal
8
+ from react_manip.core.timeouts import (
9
+ DEFAULT_TASK_WALL_TIMEOUT_SEC,
10
+ USE_DEFAULT_TASK_WALL_TIMEOUT,
11
+ resolve_task_wall_timeout_sec,
12
+ validate_timeout,
13
+ )
14
+
15
+ PROTOCOL = dict(name='practice_exam', practice_episodes=10, exam_episodes=5,
16
+ exam_adaptation='online', invalid='abort_group',
17
+ clock='synchronous_simulation', score='mean_exam_task_score', policy_track='public')
18
+
19
+
20
+
21
+
22
+
23
+
24
+ def evaluate(path, policy_factory, *, cameras=True, runner=None, env_factory=None, on_result=None,
25
+ trace_dir=None, visualize=False, video_dir=None, policy_factory_for_robot=None,
26
+ close_policy=False, task_wall_timeout_sec=USE_DEFAULT_TASK_WALL_TIMEOUT,
27
+ episode_wall_timeout_sec=None):
28
+ if (visualize or video_dir is not None) and not cameras:
29
+ raise ValueError('visualization and video require policy images')
30
+ from react_manip.evaluation.viewer import EvaluationViewer
31
+ with EvaluationViewer(enabled=visualize, video_dir=video_dir) as viewer:
32
+ return _evaluate(path, policy_factory, cameras=cameras, runner=runner,
33
+ env_factory=env_factory, on_result=on_result,
34
+ policy_factory_for_robot=policy_factory_for_robot, close_policy=close_policy,
35
+ trace_dir=trace_dir, viewer=viewer if viewer.active else None,
36
+ task_wall_timeout_sec=task_wall_timeout_sec,
37
+ episode_wall_timeout_sec=episode_wall_timeout_sec)
38
+
39
+
40
+ def _evaluate(path, policy_factory, *, cameras=True, runner=None, env_factory=None,
41
+ on_result=None, trace_dir=None, viewer=None, policy_factory_for_robot=None, close_policy=False,
42
+ task_wall_timeout_sec=USE_DEFAULT_TASK_WALL_TIMEOUT, episode_wall_timeout_sec=None):
43
+ """Fresh policy per instance; all fifteen episodes retain learned state."""
44
+ from react_manip.core.runner import BenchmarkRunner
45
+ from react_manip.evaluation.protocols import _EpisodePolicy
46
+ from react_manip.evaluation.viewer import SkipEpisode
47
+ from .runtime import ReplayEnv, spec_for, contract_for
48
+ manifest, groups = load_suite(path)
49
+ if trace_dir is not None:
50
+ trace_dir = Path(trace_dir)
51
+ trace_dir.mkdir(parents=True, exist_ok=False)
52
+ runner = runner or BenchmarkRunner()
53
+ env_factory = env_factory or ReplayEnv
54
+ explicit_episode_timeout = validate_timeout(episode_wall_timeout_sec, 'episode_wall_timeout_sec')
55
+ task_started = {}
56
+ task_timeouts = {}
57
+ timed_out_tasks = set()
58
+ rows, outcomes = [], []
59
+ for instance, episodes in groups:
60
+ task_id = instance['task_id']
61
+ task_started.setdefault(task_id, time.perf_counter())
62
+ task_timeouts.setdefault(task_id, resolve_task_wall_timeout_sec(task_id, task_wall_timeout_sec))
63
+ task_timeout = task_timeouts[task_id]
64
+ task_deadline = None if task_timeout is None else task_started[task_id] + task_timeout
65
+ if task_id in timed_out_tasks or (task_deadline is not None and time.perf_counter() >= task_deadline):
66
+ timed_out_tasks.add(task_id)
67
+ outcomes.append({'task_instance_id': instance['task_instance_id'], 'valid': False,
68
+ 'exam_scores': [], 'error': 'wall_clock_timeout',
69
+ 'completed_episodes': 0, 'skipped_episodes': len(episodes), 'score': None,
70
+ 'timeout_scope': 'task'})
71
+ continue
72
+ group_rows, error, env, policy = [], None, None, None
73
+ try:
74
+ policy = policy_factory_for_robot(instance['robot_id']) if policy_factory_for_robot else policy_factory()
75
+ for name in ('begin_episode', 'end_episode'):
76
+ if not callable(getattr(policy, name, None)):
77
+ raise TypeError(f'policy requires {name}')
78
+ if getattr(policy, 'requires_geometry_truth', False):
79
+ raise ValueError('public track rejects privileged geometry policies')
80
+ env = env_factory(instance, cameras=cameras)
81
+ spec = spec_for(instance['task_id'])
82
+ score_key = contract_for(instance['task_id'])['evaluation']['score_key']
83
+ for index, episode in enumerate(episodes):
84
+ if task_deadline is not None and time.perf_counter() >= task_deadline:
85
+ timed_out_tasks.add(task_id)
86
+ error = 'wall_clock_timeout'
87
+ break
88
+ phase = 'practice' if index < 10 else 'exam'
89
+ env.bind(episode)
90
+ # Policy RNG seed is protocol-owned and is not the private environment seed.
91
+ session = _EpisodePolicy(policy, phase, index if index < 10 else index-10, index)
92
+ writer = None
93
+ if viewer:
94
+ viewer.begin_episode(instance['task_id'], episode['episode_id'], phase,
95
+ index if index < 10 else index-10, spec.control_hz)
96
+ callback = None
97
+ if trace_dir is not None:
98
+ from .trajectory import TrajectoryWriter, RecordingPolicy
99
+ writer = TrajectoryWriter(trace_dir / episode['episode_id'], {
100
+ 'manifest_digest': manifest['content_digest'], 'task_instance_digest': instance['content_digest'],
101
+ 'episode_digest': episode['content_digest'], 'runtime': episode.get('runtime', instance['runtime']),
102
+ 'phase': phase, 'policy_id': session.policy_id,
103
+ 'policy_provenance': getattr(policy, 'provenance', None),
104
+ 'policy_track': 'public', 'cameras_enabled': cameras,
105
+ 'action_application': 'reject invalid; no clipping',
106
+ 'control_hz': spec.control_hz})
107
+ session = RecordingPolicy(session, writer)
108
+ def callback(current_env, step, result):
109
+ from react_manip.core.observation import policy_observation
110
+ writer.write({'type': 'transition', 'tick': step-1,
111
+ 'sim_time_sec': float(current_env.env.sim.data.time),
112
+ 'next_observation': policy_observation(result.observation, spec, current_env),
113
+ 'reward': result.reward, 'terminated': result.terminated,
114
+ 'truncated': result.truncated, 'info': result.info})
115
+ trace_callback = callback
116
+ def callback(current_env, step, result):
117
+ if trace_callback:
118
+ trace_callback(current_env, step, result)
119
+ if viewer and (result.done or step >= round(spec.horizon_sec * spec.control_hz)):
120
+ from react_manip.core.observation import policy_observation
121
+ viewer.observe(current_env, policy_observation(result.observation, spec, current_env), result.info)
122
+ skipped = False
123
+ try:
124
+ remaining = None if task_deadline is None else max(1e-6, task_deadline - time.perf_counter())
125
+ runner_episode_timeout = explicit_episode_timeout
126
+ if runner_episode_timeout is None:
127
+ runner_episode_timeout = getattr(runner, 'episode_wall_timeout_sec', None)
128
+ if remaining is not None:
129
+ runner_episode_timeout = remaining if runner_episode_timeout is None else min(runner_episode_timeout, remaining)
130
+ result = runner.evaluate(spec, lambda **kwargs: env, session, env=env, seed=None,
131
+ robot_id=instance['robot_id'], camera_observations=cameras, stop_on_done=True, step_callback=callback,
132
+ observation_callback=viewer.observe if viewer else None,
133
+ horizon_sec=instance['runtime']['horizon_sec'],
134
+ control_hz=instance['runtime']['control_hz'], score_key=score_key,
135
+ episode_wall_timeout_sec=runner_episode_timeout)
136
+ if writer:
137
+ writer.write({'type': 'episode_result', **result.to_dict()})
138
+ except SkipEpisode:
139
+ # A skipped episode is recorded and the next saved
140
+ # episode is evaluated. It intentionally makes the group
141
+ # invalid for protocol scoring, but must not abort the
142
+ # remaining episodes or groups.
143
+ skipped = True
144
+ if writer:
145
+ writer.write({'type': 'skipped', 'reason': 'viewer_skip'})
146
+ try:
147
+ session.end_episode(skipped=True, passed=False)
148
+ except Exception:
149
+ # The skip action itself is still honored; policy
150
+ # cleanup errors are represented by the invalid row.
151
+ pass
152
+ except Exception as exc:
153
+ if writer:
154
+ writer.write({'type': 'invalid', 'reason': f'{type(exc).__name__}: {exc}'})
155
+ raise
156
+ finally:
157
+ if viewer:
158
+ viewer.end_episode()
159
+ if writer:
160
+ writer.close()
161
+ if skipped:
162
+ row = {'task_id': instance['task_id'], 'task_instance_id': instance['task_instance_id'],
163
+ 'task_instance_digest': instance['content_digest'],
164
+ 'episode_id': episode['episode_id'], 'episode_digest': episode['content_digest'],
165
+ 'phase': phase, 'episode_index': index, 'skipped': True,
166
+ 'error': 'skipped_by_viewer', 'passed': False, 'episode_score': None}
167
+ rows.append(row)
168
+ group_rows.append(row)
169
+ if on_result:
170
+ on_result(row)
171
+ continue
172
+ row = {**result.to_dict(), 'phase': phase, 'episode_index': index,
173
+ 'task_instance_id': instance['task_instance_id'],
174
+ 'task_instance_digest': instance['content_digest'],
175
+ 'episode_id': episode['episode_id'], 'episode_digest': episode['content_digest']}
176
+ if 'error' not in row:
177
+ value = row.get('episode_score')
178
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
179
+ row['error'] = 'missing or non-finite episode_score'
180
+ rows.append(row); group_rows.append(row)
181
+ if on_result:
182
+ on_result(row)
183
+ if 'error' in row:
184
+ if row.get('error') == 'wall_clock_timeout':
185
+ timed_out_tasks.add(task_id)
186
+ raise ValueError(row['error'])
187
+ except Exception as exc:
188
+ error = f'{type(exc).__name__}: {exc}'
189
+ finally:
190
+ try:
191
+ if env is not None:env.close()
192
+ finally:
193
+ if close_policy and policy is not None:
194
+ try:getattr(policy,'shutdown',policy.close)()
195
+ except Exception as exc:error=f'Policy cleanup failed: {type(exc).__name__}: {exc}'
196
+ valid = (error is None and len(group_rows) == 15 and
197
+ not any('error' in row or row.get('skipped') for row in group_rows))
198
+ exam_scores = [r.get('episode_score') for r in group_rows[10:]]
199
+ score = sum(exam_scores)/5 if valid else None
200
+ outcomes.append({'task_instance_id': instance['task_instance_id'], 'valid': valid,
201
+ 'exam_scores': exam_scores, 'error': error, 'completed_episodes': len(group_rows), 'score': score,
202
+ 'skipped_episodes': sum(bool(r.get('skipped')) for r in group_rows),
203
+ 'timeout_scope': 'task' if task_id in timed_out_tasks else None})
204
+ if viewer and viewer.aborted:
205
+ break
206
+ valid = len(outcomes) == len(groups) and all(g['valid'] for g in outcomes)
207
+ skipped_episodes = sum(len(episodes) for instance, episodes in groups
208
+ if instance['task_id'] in timed_out_tasks)
209
+ task_wall_time_sec = {task_id: time.perf_counter() - started
210
+ for task_id, started in task_started.items()}
211
+ return {'schema_version': VERSION, 'suite_id': manifest['suite_id'], 'manifest_digest': manifest['content_digest'],
212
+ 'protocol': manifest['protocol'], 'valid': valid,
213
+ 'mode': 'public_visual' if cameras else 'state_only_smoke',
214
+ 'protocol_score': sum(g['score'] for g in outcomes)/len(outcomes) if valid and cameras else None,
215
+ 'official_score': None, # Pilot contracts have not passed publication gates.
216
+ 'groups': outcomes, 'episodes': rows,
217
+ 'limitations': ['in-process policy state isolation relies on adapter contract',
218
+ 'pilot suite; task solvability and publication gates are separate'],
219
+ 'wall_clock_timeout': bool(timed_out_tasks),
220
+ 'timeout_scope': 'task' if timed_out_tasks else None,
221
+ 'task_wall_timeout_sec': (DEFAULT_TASK_WALL_TIMEOUT_SEC
222
+ if task_wall_timeout_sec is USE_DEFAULT_TASK_WALL_TIMEOUT
223
+ else validate_timeout(task_wall_timeout_sec, 'task_wall_timeout_sec')),
224
+ 'task_wall_timeout_sec_by_task': task_timeouts,
225
+ 'task_wall_time_sec': task_wall_time_sec,
226
+ 'completed_episodes': len(rows),
227
+ 'skipped_episodes': sum(bool(row.get('skipped')) for row in rows) + max(0, skipped_episodes - sum(
228
+ 1 for row in rows if row.get('task_instance_id') in {
229
+ instance['task_instance_id'] for instance, episodes in groups
230
+ if instance['task_id'] in timed_out_tasks
231
+ }))}
@@ -0,0 +1,59 @@
1
+ """Evaluator-owned, optional trajectories with public observations and array files."""
2
+ import json
3
+ from pathlib import Path
4
+
5
+ import numpy as np
6
+
7
+
8
+ class TrajectoryWriter:
9
+ def __init__(self, directory, metadata):
10
+ self.directory = Path(directory)
11
+ self.directory.mkdir(parents=True, exist_ok=False)
12
+ self.stream = (self.directory / 'trajectory.jsonl').open('x', encoding='utf-8')
13
+ self.index = 0
14
+ self.write({'type': 'header', 'schema_version': 'cl-manip.trajectory.v1', **metadata})
15
+
16
+ def write(self, record):
17
+ arrays = {}
18
+ def encode(value):
19
+ if isinstance(value, np.ndarray):
20
+ name = f'a{len(arrays)}'
21
+ if value.dtype.hasobject:
22
+ raise ValueError('trajectory refuses object arrays')
23
+ arrays[name] = value
24
+ return {'array_file': f'{self.index:06d}.npz', 'array_key': name,
25
+ 'dtype': str(value.dtype), 'shape': list(value.shape)}
26
+ if isinstance(value, np.generic):
27
+ return value.item()
28
+ if isinstance(value, dict):
29
+ return {str(k): encode(v) for k, v in value.items()}
30
+ if isinstance(value, (tuple, list)):
31
+ return [encode(v) for v in value]
32
+ return value
33
+ encoded = encode(record)
34
+ line = json.dumps(encoded, ensure_ascii=False, allow_nan=False)
35
+ if arrays:
36
+ np.savez_compressed(self.directory / f'{self.index:06d}.npz', **arrays)
37
+ self.stream.write(line + '\n')
38
+ self.stream.flush()
39
+ self.index += 1
40
+
41
+ def close(self):
42
+ self.stream.close()
43
+
44
+
45
+ class RecordingPolicy:
46
+ """Log policy inputs before act; successful ticks are logged by the evaluator."""
47
+ def __init__(self, policy, writer):
48
+ self.policy, self.writer, self.tick = policy, writer, 0
49
+
50
+ def __getattr__(self, key):
51
+ return getattr(self.policy, key)
52
+
53
+ def act(self, observation, info):
54
+ self.writer.write({'type': 'policy_input', 'tick': self.tick,
55
+ 'observation': observation, 'info': info})
56
+ action = self.policy.act(observation, info)
57
+ self.writer.write({'type': 'requested_action', 'tick': self.tick, 'action': action})
58
+ self.tick += 1
59
+ return action