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.
- assets/materials/realistic/common/ceramic.png +0 -0
- assets/materials/realistic/common/cream-plaster.png +0 -0
- assets/materials/realistic/common/gray-felt.png +0 -0
- assets/materials/realistic/common/gray-plaster.png +0 -0
- assets/materials/realistic/common/gray-woodgrain.png +0 -0
- assets/materials/realistic/common/light-gray-floor-tile.png +0 -0
- assets/materials/realistic/common/light-gray-plaster.png +0 -0
- assets/materials/realistic/common/metal.png +0 -0
- assets/materials/realistic/common/oak-wood.png +0 -0
- assets/materials/realistic/common/steel-brushed.png +0 -0
- assets/materials/realistic/common/steel-scratched.png +0 -0
- assets/materials/realistic/common/varnished-wood-panels.png +0 -0
- assets/materials/realistic/common/walnut-wood.png +0 -0
- assets/materials/realistic/common/white-bricks.png +0 -0
- assets/materials/realistic/common/wood-tiles.png +0 -0
- assets/materials/realistic/tiles074/Tiles074_2K_Color.png +0 -0
- assets/materials/realistic/woodfloor049/WoodFloor049_2K_Color.png +0 -0
- configs/box_insertion/profile_default.json +204 -0
- react_manip/__init__.py +12 -0
- react_manip/asset_cli.py +18 -0
- react_manip/builtin.py +168 -0
- react_manip/cameras.py +301 -0
- react_manip/category_objects.py +248 -0
- react_manip/core/__init__.py +60 -0
- react_manip/core/aggregate.py +59 -0
- react_manip/core/compatibility.py +64 -0
- react_manip/core/contracts.py +42 -0
- react_manip/core/geometry.py +269 -0
- react_manip/core/language.py +116 -0
- react_manip/core/metrics.py +196 -0
- react_manip/core/observation.py +59 -0
- react_manip/core/protocol.py +91 -0
- react_manip/core/registry.py +94 -0
- react_manip/core/runner.py +498 -0
- react_manip/core/scenarios.py +110 -0
- react_manip/core/specs.py +203 -0
- react_manip/core/timeouts.py +44 -0
- react_manip/core/visual_randomization.py +261 -0
- react_manip/evaluation/__init__.py +4 -0
- react_manip/evaluation/metrics.py +17 -0
- react_manip/evaluation/protocols.py +326 -0
- react_manip/evaluation/viewer.py +240 -0
- react_manip/instances/__init__.py +6 -0
- react_manip/instances/candidate_bank.py +109 -0
- react_manip/instances/public.py +95 -0
- react_manip/instances/records.py +345 -0
- react_manip/instances/runtime.py +306 -0
- react_manip/instances/suite.py +231 -0
- react_manip/instances/trajectory.py +59 -0
- react_manip/instances/variation.py +118 -0
- react_manip/legacy.py +318 -0
- react_manip/policies/__init__.py +5 -0
- react_manip/policies/base.py +79 -0
- react_manip/rendering.py +58 -0
- react_manip/robot_registry.py +31 -0
- react_manip/robots/__init__.py +33 -0
- react_manip/robots/adapters.py +135 -0
- react_manip/robots/agilex_adapter.py +35 -0
- react_manip/robots/agilex_description.py +74 -0
- react_manip/robots/agilex_robosuite.py +246 -0
- react_manip/robots/arm_actions.py +17 -0
- react_manip/robots/base.py +119 -0
- react_manip/robots/catalog.py +18 -0
- react_manip/robots/legacy_agilex_robosuite.py +169 -0
- react_manip/robots/mobile_base.py +33 -0
- react_manip/robots/mounts.py +80 -0
- react_manip/robots/resolution.py +103 -0
- react_manip/robots/sawyer_robosuite.py +147 -0
- react_manip/robots/sensors.py +137 -0
- react_manip/utils/__init__.py +1 -0
- react_manip/visual_task.py +50 -0
- react_manip-0.1.0.dist-info/METADATA +446 -0
- react_manip-0.1.0.dist-info/RECORD +99 -0
- react_manip-0.1.0.dist-info/WHEEL +5 -0
- react_manip-0.1.0.dist-info/entry_points.txt +2 -0
- react_manip-0.1.0.dist-info/licenses/LICENSE +131 -0
- react_manip-0.1.0.dist-info/top_level.txt +5 -0
- scripts/add_phone_lean_to_bank.py +242 -0
- scripts/agilex_wheeled_builder.py +133 -0
- scripts/audit_task_solvability.py +150 -0
- scripts/build_agilex_assets.py +317 -0
- scripts/build_legacy_agilex_assets.py +244 -0
- scripts/evaluate_all_tasks.py +309 -0
- scripts/evaluate_policy.py +281 -0
- scripts/piperx_assets.lock.json +181 -0
- scripts/pygame_demo.py +360 -0
- scripts/pygame_task_debug.py +242 -0
- scripts/regenerate_instance_bank.py +192 -0
- scripts/remove_task_from_bank.py +103 -0
- scripts/select_task_instances.py +64 -0
- scripts/verify_curling_reachability.py +206 -0
- scripts/verify_piperx_tasks.py +81 -0
- tasks/box_insertion/__init__.py +3 -0
- tasks/box_insertion/assets/scene.xml +50 -0
- tasks/box_insertion/env.py +642 -0
- tasks/box_insertion/evaluation.json +16 -0
- tasks/box_insertion/instance_contract.json +321 -0
- tasks/box_insertion/scoring.py +23 -0
- tasks/box_insertion/task_spec.json +333 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Read and draw the shipped candidate bank; no offline generation tools."""
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import shutil
|
|
5
|
+
from .records import digest, load_suite, save_record, seal
|
|
6
|
+
from .suite import PROTOCOL
|
|
7
|
+
ALGORITHM = 'sha256-rejection-v1'
|
|
8
|
+
|
|
9
|
+
def write_json(path, value):
|
|
10
|
+
Path(path).write_text(json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False)+'\n')
|
|
11
|
+
|
|
12
|
+
def checked_path(parent, name):
|
|
13
|
+
path = (parent / name).resolve()
|
|
14
|
+
if not path.is_relative_to(parent.resolve()):
|
|
15
|
+
raise ValueError('file reference escapes bank directory')
|
|
16
|
+
return path
|
|
17
|
+
|
|
18
|
+
def choices(groups):
|
|
19
|
+
result = {}
|
|
20
|
+
for instance, episodes in groups:
|
|
21
|
+
result.setdefault(instance['task_id'], []).append((instance, episodes))
|
|
22
|
+
for task, items in result.items():
|
|
23
|
+
if len(items) != 3 or len({digest(i['scenario']['parameters']) for i, _ in items}) != 3:
|
|
24
|
+
raise ValueError(f'{task}: requires three distinct resolved parameter sets')
|
|
25
|
+
items.sort(key=lambda x: x[0]['task_instance_id'])
|
|
26
|
+
return result
|
|
27
|
+
|
|
28
|
+
def load_bank(path):
|
|
29
|
+
path = Path(path).resolve(); bank = json.loads(path.read_text())
|
|
30
|
+
if set(bank) != {'schema_version','manifest','manifest_digest','selection','content_digest'}:
|
|
31
|
+
raise ValueError('invalid bank fields')
|
|
32
|
+
if bank['schema_version'] != 'cl-manip.candidate-bank.v1' or bank['selection'] != {
|
|
33
|
+
'algorithm': ALGORITHM, 'candidates_per_task': 3, 'draws_per_task': 3,
|
|
34
|
+
'policy_state_between_tasks': 'fresh_policy'}:
|
|
35
|
+
raise ValueError('unsupported bank protocol')
|
|
36
|
+
if bank['content_digest'] != digest({k:v for k,v in bank.items() if k!='content_digest'}):
|
|
37
|
+
raise ValueError('bank digest mismatch')
|
|
38
|
+
manifest_path = checked_path(path.parent, bank['manifest'])
|
|
39
|
+
manifest, groups = load_suite(manifest_path)
|
|
40
|
+
if manifest['content_digest'] != bank['manifest_digest'] or manifest['protocol'] != PROTOCOL:
|
|
41
|
+
raise ValueError('bank manifest/protocol mismatch')
|
|
42
|
+
return bank, manifest_path, manifest, choices(groups)
|
|
43
|
+
|
|
44
|
+
def choose_index(seed, task, count=3):
|
|
45
|
+
if type(seed) is not int or seed < 0:
|
|
46
|
+
raise ValueError('draw seed must be a nonnegative integer')
|
|
47
|
+
limit = (1 << 256) - (1 << 256) % count
|
|
48
|
+
counter = 0
|
|
49
|
+
while True:
|
|
50
|
+
value = int(digest([ALGORITHM, seed, task, counter]), 16)
|
|
51
|
+
if value < limit:
|
|
52
|
+
return value % count
|
|
53
|
+
counter += 1
|
|
54
|
+
|
|
55
|
+
def copy_group(group, source, target):
|
|
56
|
+
for ref in [group['task_instance'], *group['episodes']]:
|
|
57
|
+
src = checked_path(source, ref['path']); dst = checked_path(target, ref['path'])
|
|
58
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
if dst.exists():
|
|
60
|
+
raise ValueError('duplicate output reference')
|
|
61
|
+
shutil.copyfile(src, dst)
|
|
62
|
+
|
|
63
|
+
def draw(path, *, seed, out):
|
|
64
|
+
bank, source_path, manifest, by_task = load_bank(path)
|
|
65
|
+
if type(seed) is not int or seed < 0:
|
|
66
|
+
raise ValueError('draw seed must be a nonnegative integer')
|
|
67
|
+
selected=[]; records=[]
|
|
68
|
+
for task in sorted(by_task):
|
|
69
|
+
# The bank owns all three resolved instances for every task. The draw
|
|
70
|
+
# seed remains in provenance for reproducibility/CLI compatibility, but
|
|
71
|
+
# it must not discard two instances: each task contributes 3*15=45
|
|
72
|
+
# episodes to a complete candidate-bank evaluation.
|
|
73
|
+
for instance, episodes in by_task[task]:
|
|
74
|
+
group = next(g for g in manifest['groups'] if g['task_instance']['digest']==instance['content_digest'])
|
|
75
|
+
selected.append(group)
|
|
76
|
+
records.append(dict(task_id=task, task_instance_id=instance['task_instance_id'],
|
|
77
|
+
task_instance_digest=instance['content_digest'],
|
|
78
|
+
episode_digests=[e['content_digest'] for e in episodes]))
|
|
79
|
+
out = Path(out); out.mkdir(parents=True, exist_ok=False)
|
|
80
|
+
for group in selected:
|
|
81
|
+
copy_group(group, source_path.parent, out)
|
|
82
|
+
result=seal('manifest',suite_id=out.name,protocol=manifest['protocol'],groups=selected)
|
|
83
|
+
save_record(result,out/'manifest.json')
|
|
84
|
+
provenance=dict(schema_version='cl-manip.draw.v1', algorithm=ALGORITHM, draw_seed=seed,
|
|
85
|
+
draws_per_task=3,
|
|
86
|
+
bank_digest=bank['content_digest'], candidate_manifest_digest=manifest['content_digest'],
|
|
87
|
+
selected_manifest_digest=result['content_digest'], selected=records)
|
|
88
|
+
provenance['content_digest']=digest(provenance)
|
|
89
|
+
write_json(out/'selection.json',provenance)
|
|
90
|
+
return out/'manifest.json', provenance
|
|
91
|
+
|
|
92
|
+
def selection_for(path):
|
|
93
|
+
"""Validate optional draw provenance when replaying a saved selected manifest."""
|
|
94
|
+
path=Path(path); sidecar=path.with_name('selection.json')
|
|
95
|
+
if not sidecar.exists():
|
|
96
|
+
return None
|
|
97
|
+
record=json.loads(sidecar.read_text())
|
|
98
|
+
if (record.get('schema_version')!='cl-manip.draw.v1' or record.get('algorithm')!=ALGORITHM
|
|
99
|
+
or record.get('draws_per_task') != 3):
|
|
100
|
+
raise ValueError('invalid selection provenance')
|
|
101
|
+
if record.get('content_digest')!=digest({k:v for k,v in record.items() if k!='content_digest'}):
|
|
102
|
+
raise ValueError('selection digest mismatch')
|
|
103
|
+
manifest,groups=load_suite(path)
|
|
104
|
+
expected=[dict(task_id=i['task_id'],task_instance_id=i['task_instance_id'],
|
|
105
|
+
task_instance_digest=i['content_digest'],episode_digests=[e['content_digest'] for e in es])
|
|
106
|
+
for i,es in groups]
|
|
107
|
+
if record.get('selected_manifest_digest')!=manifest['content_digest'] or record.get('selected')!=expected:
|
|
108
|
+
raise ValueError('selection does not match execution manifest')
|
|
109
|
+
return record
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Small, explicit API for one development episode of box_insertion.
|
|
2
|
+
|
|
3
|
+
The returned records are compatible with the existing instance validators and
|
|
4
|
+
replay adapter. Scores obtained here are not official benchmark results.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from react_manip.core.compatibility import resolve_task_robot
|
|
13
|
+
from react_manip.core.scenarios import sample_scenario
|
|
14
|
+
from .records import VERSION, digest, load_record, save_record, seal, validate_pair
|
|
15
|
+
from .runtime import ReplayEnv, _construct, capture_state, contract_for, runtime_identity, spec_for
|
|
16
|
+
from .variation import episode_scenario, sample_variation
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class InstanceBundle:
|
|
21
|
+
task: dict
|
|
22
|
+
episode: dict
|
|
23
|
+
|
|
24
|
+
def save(self, directory):
|
|
25
|
+
directory = Path(directory)
|
|
26
|
+
save_record(self.task, directory / 'task.json')
|
|
27
|
+
save_record(self.episode, directory / 'episode.json')
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def load(cls, directory):
|
|
31
|
+
directory = Path(directory)
|
|
32
|
+
bundle = cls(load_record(directory / 'task.json'), load_record(directory / 'episode.json'))
|
|
33
|
+
validate_pair(bundle.task, bundle.episode)
|
|
34
|
+
return bundle
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_instance(task_id: str = 'box_insertion', *, seed: int = 0,
|
|
38
|
+
episode_index: int = 0) -> InstanceBundle:
|
|
39
|
+
"""Generate one Task/Episode pair; currently supports box_insertion only."""
|
|
40
|
+
if task_id != 'box_insertion':
|
|
41
|
+
raise ValueError('the minimal package currently supports only box_insertion')
|
|
42
|
+
if type(seed) is not int or seed < 0 or type(episode_index) is not int or not 0 <= episode_index < 15:
|
|
43
|
+
raise ValueError('seed must be nonnegative and episode_index must be in [0, 14]')
|
|
44
|
+
spec, contract = spec_for(task_id), contract_for(task_id)
|
|
45
|
+
scenario = sample_scenario(contract['sampling_profile'], seed=seed)
|
|
46
|
+
scenario.metadata['episode_variation_version'] = 1
|
|
47
|
+
robot_id = resolve_task_robot(spec)
|
|
48
|
+
base = _construct(task_id, scenario, robot_id)
|
|
49
|
+
try:
|
|
50
|
+
base.reset()
|
|
51
|
+
task = seal('task_instance', task_id=task_id, task_instance_id=scenario.scenario_id,
|
|
52
|
+
task_spec_digest=digest(spec.to_dict()), contract_digest=digest(contract),
|
|
53
|
+
robot_id=robot_id, scenario=scenario.to_dict(),
|
|
54
|
+
runtime=runtime_identity(base, task_id),
|
|
55
|
+
provenance={'sampler': VERSION, 'seed': seed})
|
|
56
|
+
finally:
|
|
57
|
+
base.close()
|
|
58
|
+
variation = sample_variation(task_id, scenario.parameters, seed, episode_index)
|
|
59
|
+
resolved = episode_scenario(scenario, variation)
|
|
60
|
+
env = _construct(task_id, resolved, robot_id)
|
|
61
|
+
try:
|
|
62
|
+
env.reset()
|
|
63
|
+
episode = seal('episode_instance', task_id=task_id,
|
|
64
|
+
task_instance_id=task['task_instance_id'],
|
|
65
|
+
task_instance_digest=task['content_digest'],
|
|
66
|
+
episode_id=f"{task['task_instance_id']}_e{episode_index:03d}",
|
|
67
|
+
initial_state=capture_state(env), round_plan=None,
|
|
68
|
+
variation=variation, runtime=runtime_identity(env, task_id),
|
|
69
|
+
provenance={'sampler': VERSION, 'seed': episode_index})
|
|
70
|
+
finally:
|
|
71
|
+
env.close()
|
|
72
|
+
validate_pair(task, episode)
|
|
73
|
+
return InstanceBundle(task, episode)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@contextmanager
|
|
77
|
+
def open_instance(bundle: InstanceBundle, *, cameras: bool = False):
|
|
78
|
+
"""Replay one explicit Episode with reset/step, closing it on exit."""
|
|
79
|
+
validate_pair(bundle.task, bundle.episode)
|
|
80
|
+
env = ReplayEnv(bundle.task, cameras=cameras)
|
|
81
|
+
try:
|
|
82
|
+
env.bind(bundle.episode)
|
|
83
|
+
yield env
|
|
84
|
+
finally:
|
|
85
|
+
env.close()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def task_score(info: dict, task_id: str = 'box_insertion') -> float:
|
|
89
|
+
"""Read the task's current score from step info (development use only)."""
|
|
90
|
+
import math
|
|
91
|
+
key = contract_for(task_id)['evaluation']['score_key']
|
|
92
|
+
value = info.get(key)
|
|
93
|
+
if type(value) not in (int, float) or not math.isfinite(value):
|
|
94
|
+
raise ValueError(f'missing or non-finite info.{key}')
|
|
95
|
+
return float(value)
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Strict JSON contracts: no sampling, simulator imports, or implicit overrides."""
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
VERSION = 'cl-manip.instances.v1'
|
|
9
|
+
SUPPORTED_TASKS = ('curling_push', 'button_indicator_matching', 'phone_lean', 'leveling_board1', 'rail_ball_to_cup', 'pitch_pot', 'conveyor_grasp', 'turntable_gamble1', 'ring_toss1', 'box_insertion', 'car_drive2', 'rope_hang', 'pan_flip', 'laptop_into_drawer', 'find_object_in_drawer2', 'tilte_maze')
|
|
10
|
+
ID = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$')
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def digest(value):
|
|
14
|
+
"""SHA256 over UTF-8, sorted keys, compact JSON, finite numbers only."""
|
|
15
|
+
return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False,
|
|
16
|
+
separators=(',', ':'), allow_nan=False).encode()).hexdigest()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _keys(value, names, where):
|
|
20
|
+
if not isinstance(value, dict) or set(value) != set(names.split()):
|
|
21
|
+
raise ValueError(f'{where}: expected exactly {names}')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _id(value, where):
|
|
25
|
+
if not isinstance(value, str) or not ID.fullmatch(value):
|
|
26
|
+
raise ValueError(f'{where}: invalid identifier')
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _hash(value, where):
|
|
30
|
+
if not isinstance(value, str) or not re.fullmatch('[a-f0-9]{64}', value):
|
|
31
|
+
raise ValueError(f'{where}: expected SHA256')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _int(value, where, minimum=0):
|
|
35
|
+
if type(value) is not int or value < minimum:
|
|
36
|
+
raise ValueError(f'{where}: expected integer >= {minimum}')
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_parameters(parameters, contract):
|
|
40
|
+
"""Reject unowned fields, rule overrides, and out-of-domain resolved values."""
|
|
41
|
+
def flatten(value, prefix=''):
|
|
42
|
+
result = {}
|
|
43
|
+
for key, item in value.items():
|
|
44
|
+
name = prefix + key
|
|
45
|
+
if isinstance(item, dict):
|
|
46
|
+
result.update(flatten(item, name + '.'))
|
|
47
|
+
else:
|
|
48
|
+
result[name] = item
|
|
49
|
+
return result
|
|
50
|
+
if contract['task_id'] == 'leveling_board1':
|
|
51
|
+
from tasks.leveling_board1.instances import validate_task_instance
|
|
52
|
+
validate_task_instance(parameters, contract['sampling_profile'])
|
|
53
|
+
return
|
|
54
|
+
if contract['task_id'] == 'phone_lean':
|
|
55
|
+
from tasks.phone_lean.instances import validate_task_instance
|
|
56
|
+
validate_task_instance(parameters, contract['sampling_profile'])
|
|
57
|
+
return
|
|
58
|
+
if contract['task_id'] == 'ring_toss1':
|
|
59
|
+
from tasks.ring_toss1.instances import validate_resolved
|
|
60
|
+
parameters = validate_resolved(parameters, contract['sampling_profile'])
|
|
61
|
+
if contract['task_id'] == 'turntable_gamble1':
|
|
62
|
+
from tasks.turntable_gamble1.instances import validate_resolved
|
|
63
|
+
parameters = validate_resolved(parameters, contract['sampling_profile'])
|
|
64
|
+
if contract['task_id'] == 'conveyor_grasp':
|
|
65
|
+
from tasks.conveyor_grasp.instances import validate_layout
|
|
66
|
+
parameters = deepcopy(parameters)
|
|
67
|
+
layout = parameters.pop('inventory', None)
|
|
68
|
+
validate_layout(layout, parameters.get('task', {}).get('n_objects'), parameters.get('task', {}).get('category_count'))
|
|
69
|
+
if contract['task_id'] == 'find_object_in_drawer2':
|
|
70
|
+
from tasks.find_object_in_drawer2.instances import validate_inventory
|
|
71
|
+
parameters = deepcopy(parameters)
|
|
72
|
+
validate_inventory(parameters.pop('inventory', None))
|
|
73
|
+
profile = contract['sampling_profile']
|
|
74
|
+
fixed = flatten(profile.get('fixed', {}))
|
|
75
|
+
domains = {}
|
|
76
|
+
for group in ('geometry', 'physics', 'appearance', 'runtime'):
|
|
77
|
+
domains.update(profile.get(group, {}))
|
|
78
|
+
actual = flatten(parameters)
|
|
79
|
+
extra = set()
|
|
80
|
+
if contract['task_id'] == 'button_indicator_matching':
|
|
81
|
+
extra = {'button_indicator_assignment', 'layout.seed', 'layout.button_xy', 'layout.indicator_xy'}
|
|
82
|
+
n = parameters.get('button_count')
|
|
83
|
+
_int(n, 'button_count', 3)
|
|
84
|
+
assignment = parameters.get('button_indicator_assignment')
|
|
85
|
+
if n > 6 or not isinstance(assignment, list) or any(type(x) is not int for x in assignment) or sorted(assignment) != list(range(n)):
|
|
86
|
+
raise ValueError('instance assignment must be a permutation of button indices')
|
|
87
|
+
_int(actual.get('layout.seed'), 'layout.seed')
|
|
88
|
+
for name, bounds in (('button_xy', (-.065, .025)), ('indicator_xy', (.34, .46))):
|
|
89
|
+
rows = actual.get('layout.' + name)
|
|
90
|
+
if not isinstance(rows, list) or len(rows) != n:
|
|
91
|
+
raise ValueError('resolved layout must contain N coordinate pairs')
|
|
92
|
+
for i, row in enumerate(rows):
|
|
93
|
+
center = -.48 + i * .96 / (n-1)
|
|
94
|
+
if (not isinstance(row, list) or len(row) != 2 or any(type(v) not in (int, float) for v in row)
|
|
95
|
+
or not bounds[0] <= row[0] <= bounds[1] or abs(row[1]-center) > .045001):
|
|
96
|
+
raise ValueError('resolved layout outside stratified sampling domain')
|
|
97
|
+
if set(actual) != set(fixed) | set(domains) | extra:
|
|
98
|
+
raise ValueError('parameters contain missing or unowned fields')
|
|
99
|
+
for name, value in fixed.items():
|
|
100
|
+
if name not in domains and actual[name] != value:
|
|
101
|
+
raise ValueError(f'{name}: cannot override frozen task parameter')
|
|
102
|
+
for name, domain in domains.items():
|
|
103
|
+
value = actual[name]
|
|
104
|
+
kind = domain['distribution']
|
|
105
|
+
if kind == 'choice':
|
|
106
|
+
valid = value in domain['values']
|
|
107
|
+
elif kind in ('uniform', 'exponential'):
|
|
108
|
+
valid = type(value) in (int, float) and domain['low'] <= value <= domain['high']
|
|
109
|
+
else:
|
|
110
|
+
raise ValueError(f'unsupported pilot distribution: {kind}')
|
|
111
|
+
if not valid:
|
|
112
|
+
raise ValueError(f'{name}: outside frozen sampling domain')
|
|
113
|
+
|
|
114
|
+
for rule in profile.get('constraints', []):
|
|
115
|
+
if rule['type'] != 'not_equal' or len(rule['paths']) != 2:
|
|
116
|
+
raise ValueError('unsupported instance constraint')
|
|
117
|
+
left, right = rule['paths']
|
|
118
|
+
if actual[left] == actual[right]:
|
|
119
|
+
raise ValueError(f'{left} and {right} must differ')
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def validate_record(record):
|
|
123
|
+
if not isinstance(record, dict):
|
|
124
|
+
raise ValueError('instance must be a JSON object')
|
|
125
|
+
try:
|
|
126
|
+
digest(record)
|
|
127
|
+
except (TypeError, ValueError) as exc:
|
|
128
|
+
raise ValueError('instance must contain finite JSON values') from exc
|
|
129
|
+
kind = record.get('kind')
|
|
130
|
+
common = 'schema_version kind content_digest '
|
|
131
|
+
fields = {
|
|
132
|
+
'task_instance': 'task_id task_instance_id task_spec_digest contract_digest robot_id scenario runtime provenance',
|
|
133
|
+
'episode_instance': 'task_id task_instance_id task_instance_digest episode_id initial_state round_plan provenance',
|
|
134
|
+
'manifest': 'suite_id protocol groups',
|
|
135
|
+
}
|
|
136
|
+
if kind not in fields:
|
|
137
|
+
raise ValueError('unknown instance kind')
|
|
138
|
+
if kind == 'episode_instance' and record.get('task_id') in ('phone_lean','leveling_board1','rope_hang','laptop_into_drawer'):
|
|
139
|
+
fields[kind] += ' scene'
|
|
140
|
+
if kind == 'episode_instance' and ('variation' in record or 'runtime' in record):
|
|
141
|
+
from .variation import TASKS
|
|
142
|
+
if record.get('task_id') not in TASKS:
|
|
143
|
+
raise ValueError('this task does not permit Episode model variation')
|
|
144
|
+
fields[kind] += ' variation runtime'
|
|
145
|
+
_keys(record, common + fields[kind], kind)
|
|
146
|
+
if record['schema_version'] != VERSION:
|
|
147
|
+
raise ValueError('unsupported instance schema version')
|
|
148
|
+
_hash(record['content_digest'], 'content_digest')
|
|
149
|
+
if digest({k: v for k, v in record.items() if k != 'content_digest'}) != record['content_digest']:
|
|
150
|
+
raise ValueError('content digest mismatch')
|
|
151
|
+
if kind != 'manifest':
|
|
152
|
+
if record['task_id'] not in SUPPORTED_TASKS:
|
|
153
|
+
raise ValueError('task has not passed the instance adapter gate')
|
|
154
|
+
_id(record['task_instance_id'], 'task_instance_id')
|
|
155
|
+
_keys(record['provenance'], 'sampler seed', 'provenance')
|
|
156
|
+
if record['provenance']['sampler'] != VERSION:
|
|
157
|
+
raise ValueError('unsupported sampler')
|
|
158
|
+
_int(record['provenance']['seed'], 'seed')
|
|
159
|
+
if kind == 'task_instance':
|
|
160
|
+
_hash(record['task_spec_digest'], 'task_spec_digest')
|
|
161
|
+
_hash(record['contract_digest'], 'contract_digest')
|
|
162
|
+
# Validate the record shape before resolving the robot capability.
|
|
163
|
+
# This keeps malformed legacy records diagnosable (and prevents an
|
|
164
|
+
# unsupported robot label from masking a missing runtime contract).
|
|
165
|
+
validate_runtime(record['runtime'])
|
|
166
|
+
from .runtime import spec_for
|
|
167
|
+
from react_manip.core.compatibility import resolve_task_robot
|
|
168
|
+
resolve_task_robot(spec_for(record['task_id']), record['robot_id'])
|
|
169
|
+
from react_manip.robots.sensors import policy_camera_ids
|
|
170
|
+
if record['runtime']['cameras'] != list(policy_camera_ids(record['robot_id'])):
|
|
171
|
+
raise ValueError('camera contract changed; regenerate instances with main and wrist views')
|
|
172
|
+
if ('left' in record['runtime']['action_layout']) != (record['robot_id'] not in ('piperx', 'legacy_piperx', 'panda', 'sawyer', 'panda_libero', 'panda_omron_robocasa')):
|
|
173
|
+
raise ValueError('action layout does not match the instance robot')
|
|
174
|
+
s = record['scenario']
|
|
175
|
+
_keys(s, 'schema_version task_id scenario_id seed parameters assets metadata digest', 'scenario')
|
|
176
|
+
if s['task_id'] != record['task_id'] or not isinstance(s['parameters'], dict):
|
|
177
|
+
raise ValueError('scenario task/parameters mismatch')
|
|
178
|
+
from react_manip.core.scenarios import Scenario
|
|
179
|
+
scenario = Scenario(**{k: v for k, v in s.items() if k != 'digest'})
|
|
180
|
+
if scenario.digest != s['digest']:
|
|
181
|
+
raise ValueError('scenario digest mismatch')
|
|
182
|
+
elif kind == 'episode_instance':
|
|
183
|
+
if 'variation' in record:
|
|
184
|
+
if not isinstance(record['variation'], dict):
|
|
185
|
+
raise ValueError('Episode variation must be explicit')
|
|
186
|
+
validate_runtime(record['runtime'])
|
|
187
|
+
_id(record['episode_id'], 'episode_id')
|
|
188
|
+
_hash(record['task_instance_digest'], 'task_instance_digest')
|
|
189
|
+
state = record['initial_state']
|
|
190
|
+
_keys(state, 'format state_spec integration_state', 'initial_state')
|
|
191
|
+
if state['format'] != 'mujoco.integration.v1':
|
|
192
|
+
raise ValueError('unsupported initial state format')
|
|
193
|
+
_int(state['state_spec'], 'state_spec', 1)
|
|
194
|
+
if not isinstance(state['integration_state'], list) or not state['integration_state'] or any(type(v) not in (int, float) for v in state['integration_state']):
|
|
195
|
+
raise ValueError('integration_state must be a nonempty numeric list')
|
|
196
|
+
plan = record['round_plan']
|
|
197
|
+
if record['task_id'] in ('curling_push', 'phone_lean', 'leveling_board1', 'rail_ball_to_cup', 'pitch_pot', 'conveyor_grasp', 'turntable_gamble1', 'ring_toss1', 'box_insertion', 'car_drive2', 'rope_hang', 'pan_flip', 'laptop_into_drawer', 'tilte_maze'):
|
|
198
|
+
if plan is not None:
|
|
199
|
+
raise ValueError('this task has no rounds')
|
|
200
|
+
if record['task_id'] in ('phone_lean','leveling_board1','rope_hang','laptop_into_drawer'):
|
|
201
|
+
if not isinstance(record['scene'], dict): raise ValueError('task scene must be explicit')
|
|
202
|
+
else:
|
|
203
|
+
_keys(plan, 'advance_on exhaustion targets', 'round_plan')
|
|
204
|
+
if plan['advance_on'] != 'instruction_completed' or plan['exhaustion'] != 'terminate':
|
|
205
|
+
raise ValueError('unsupported round transition')
|
|
206
|
+
if not isinstance(plan['targets'], list) or not plan['targets']:
|
|
207
|
+
raise ValueError('round targets must be nonempty')
|
|
208
|
+
for t in plan['targets']:
|
|
209
|
+
_int(t, 'target')
|
|
210
|
+
if any(a == b for a, b in zip(plan['targets'], plan['targets'][1:])):
|
|
211
|
+
raise ValueError('adjacent targets must differ')
|
|
212
|
+
else:
|
|
213
|
+
_id(record['suite_id'], 'suite_id')
|
|
214
|
+
expected = {'name': 'practice_exam', 'practice_episodes': 10, 'exam_episodes': 5,
|
|
215
|
+
'exam_adaptation': 'online', 'invalid': 'abort_group',
|
|
216
|
+
'clock': 'synchronous_simulation', 'score': 'mean_exam_task_score',
|
|
217
|
+
'policy_track': 'public'}
|
|
218
|
+
if record['protocol'] != expected:
|
|
219
|
+
raise ValueError('unsupported manifest protocol; historical frozen suites require their original source revision')
|
|
220
|
+
if not isinstance(record['groups'], list) or not record['groups']:
|
|
221
|
+
raise ValueError('manifest groups must be nonempty')
|
|
222
|
+
for group in record['groups']:
|
|
223
|
+
_keys(group, 'task_instance episodes', 'manifest group')
|
|
224
|
+
if not isinstance(group['episodes'], list) or len(group['episodes']) != 15:
|
|
225
|
+
raise ValueError('each group requires exactly 15 episodes')
|
|
226
|
+
for ref in [group['task_instance'], *group['episodes']]:
|
|
227
|
+
_keys(ref, 'path digest', 'file reference')
|
|
228
|
+
_hash(ref['digest'], 'file reference digest')
|
|
229
|
+
if not isinstance(ref['path'], str) or not ref['path'] or Path(ref['path']).is_absolute() or '..' in Path(ref['path']).parts:
|
|
230
|
+
raise ValueError('references must be suite-relative paths without ..')
|
|
231
|
+
return record
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def seal(kind, **fields):
|
|
235
|
+
result = dict(schema_version=VERSION, kind=kind, **deepcopy(fields))
|
|
236
|
+
result['content_digest'] = digest(result)
|
|
237
|
+
return validate_record(result)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def save_record(record, path):
|
|
241
|
+
validate_record(record)
|
|
242
|
+
path = Path(path)
|
|
243
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
# Never overwrite published/generated instances silently.
|
|
245
|
+
with path.open('x', encoding='utf-8') as stream:
|
|
246
|
+
json.dump(record, stream, ensure_ascii=False, indent=2, allow_nan=False)
|
|
247
|
+
stream.write('\n')
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def load_record(path):
|
|
251
|
+
def pairs(items):
|
|
252
|
+
result = {}
|
|
253
|
+
for key, value in items:
|
|
254
|
+
if key in result:
|
|
255
|
+
raise ValueError(f'duplicate JSON key: {key}')
|
|
256
|
+
result[key] = value
|
|
257
|
+
return result
|
|
258
|
+
return validate_record(json.loads(Path(path).read_text(encoding='utf-8'), object_pairs_hook=pairs))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def validate_pair(instance, episode):
|
|
262
|
+
validate_record(instance); validate_record(episode)
|
|
263
|
+
if instance['kind'] != 'task_instance' or episode['kind'] != 'episode_instance':
|
|
264
|
+
raise ValueError('expected task instance and episode instance')
|
|
265
|
+
if any(episode[k] != instance[k] for k in ('task_id', 'task_instance_id')) or episode['task_instance_digest'] != instance['content_digest']:
|
|
266
|
+
raise ValueError('episode refers to a different task instance')
|
|
267
|
+
parameters = instance['scenario']['parameters']
|
|
268
|
+
if instance['scenario']['metadata'].get('episode_variation_version') == 1 and 'variation' not in episode:
|
|
269
|
+
raise ValueError('this Task requires explicit Episode variation and runtime')
|
|
270
|
+
if 'variation' in episode:
|
|
271
|
+
from .variation import effective_parameters
|
|
272
|
+
parameters = effective_parameters(instance['task_id'], parameters, episode['variation'])
|
|
273
|
+
# Geometry/physics can change the model, never the robot/controller/runtime.
|
|
274
|
+
for key, value in instance['runtime'].items():
|
|
275
|
+
if key != 'model_digest' and episode['runtime'][key] != value:
|
|
276
|
+
raise ValueError('Episode changed a frozen runtime field: '+key)
|
|
277
|
+
if instance['task_id'] == 'laptop_into_drawer':
|
|
278
|
+
from tasks.laptop_into_drawer.instances import validate_episode_instance
|
|
279
|
+
validate_episode_instance(instance['scenario']['parameters'], episode['scene'])
|
|
280
|
+
if instance['task_id'] == 'find_object_in_drawer2':
|
|
281
|
+
if sorted(episode['round_plan']['targets']) != sorted(instance['scenario']['parameters']['inventory']['target_set']):
|
|
282
|
+
raise ValueError('Episode targets must permute the fixed five-object set')
|
|
283
|
+
if instance['task_id'] == 'rope_hang':
|
|
284
|
+
from tasks.rope_hang.instances import validate_episode_instance
|
|
285
|
+
validate_episode_instance(instance['scenario']['parameters'], episode['scene'])
|
|
286
|
+
if instance['task_id'] == 'leveling_board1':
|
|
287
|
+
from tasks.leveling_board1.instances import validate_episode_instance
|
|
288
|
+
validate_episode_instance(parameters, episode['scene'])
|
|
289
|
+
if instance['task_id'] == 'phone_lean':
|
|
290
|
+
from tasks.phone_lean.instances import validate_episode_instance
|
|
291
|
+
validate_episode_instance(parameters, episode['scene'])
|
|
292
|
+
if instance['task_id'] == 'button_indicator_matching':
|
|
293
|
+
n = instance['scenario']['parameters']['button_count']
|
|
294
|
+
targets = episode['round_plan']['targets']
|
|
295
|
+
# At most N awards per tick; one extra target for each transition.
|
|
296
|
+
minimum = 5
|
|
297
|
+
if len(targets) != minimum or any(t >= n for t in targets):
|
|
298
|
+
raise ValueError('round plan must contain exactly five in-range targets')
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def load_suite(path):
|
|
302
|
+
path = Path(path).resolve()
|
|
303
|
+
manifest = load_record(path)
|
|
304
|
+
if manifest['kind'] != 'manifest':
|
|
305
|
+
raise ValueError('expected manifest')
|
|
306
|
+
def read(ref):
|
|
307
|
+
target = (path.parent / ref['path']).resolve()
|
|
308
|
+
if not target.is_relative_to(path.parent):
|
|
309
|
+
raise ValueError('reference escapes suite directory')
|
|
310
|
+
record = load_record(target)
|
|
311
|
+
if record['content_digest'] != ref['digest']:
|
|
312
|
+
raise ValueError('manifest file digest mismatch')
|
|
313
|
+
return record
|
|
314
|
+
groups, instance_ids, episode_ids = [], set(), set()
|
|
315
|
+
for group in manifest['groups']:
|
|
316
|
+
instance = read(group['task_instance'])
|
|
317
|
+
episodes = [read(ref) for ref in group['episodes']]
|
|
318
|
+
if instance['task_instance_id'] in instance_ids:
|
|
319
|
+
raise ValueError('duplicate task instance ID')
|
|
320
|
+
instance_ids.add(instance['task_instance_id'])
|
|
321
|
+
for episode in episodes:
|
|
322
|
+
validate_pair(instance, episode)
|
|
323
|
+
if episode['episode_id'] in episode_ids:
|
|
324
|
+
raise ValueError('duplicate episode ID')
|
|
325
|
+
episode_ids.add(episode['episode_id'])
|
|
326
|
+
groups.append((instance, episodes))
|
|
327
|
+
return manifest, groups
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def validate_runtime(runtime):
|
|
331
|
+
_keys(runtime, 'python numpy mujoco robosuite source_digest control_hz horizon_sec physics_timestep model_digest action_layout controller_digest cameras', 'runtime')
|
|
332
|
+
for name in ('source_digest', 'model_digest', 'controller_digest'):
|
|
333
|
+
_hash(runtime[name], name)
|
|
334
|
+
for name in ('control_hz', 'horizon_sec', 'physics_timestep'):
|
|
335
|
+
value = runtime[name]
|
|
336
|
+
if type(value) not in (int, float) or value <= 0:
|
|
337
|
+
raise ValueError(f'{name} must be positive')
|
|
338
|
+
layout = runtime['action_layout']
|
|
339
|
+
if layout not in ({'right': [0, 6], 'right_gripper': [6, 7]},
|
|
340
|
+
{'right': [0, 6], 'left': [6, 12], 'right_gripper': [12, 13], 'left_gripper': [13, 14]}):
|
|
341
|
+
raise ValueError('runtime requires a 7D single-arm or 14D dual-arm action layout')
|
|
342
|
+
if (not isinstance(runtime['cameras'], list) or not runtime['cameras']
|
|
343
|
+
or any(not isinstance(n, str) or not n for n in runtime['cameras'])
|
|
344
|
+
or len(set(runtime['cameras'])) != len(runtime['cameras'])):
|
|
345
|
+
raise ValueError('cameras must be a nonempty list of unique camera IDs')
|