From 7c065ed1d556a59c83f463b74ff055fac92a6a41 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Tue, 11 Aug 2026 17:02:14 +0800 Subject: [PATCH 1/4] feat(expert-program): add task-owned pre-sim catalogs --- .../lab/gym/envs/expert_program/__init__.py | 14 +- .../lab/gym/envs/expert_program/catalog.py | 1047 +++++++++++++++++ .../gym/envs/expert_program/environment.py | 34 +- .../lab/gym/envs/expert_program/simulation.py | 240 ++++ .../expert_program/simulation_environment.py | 98 +- .../expert_program/simulation_policies.py | 8 +- embodichain/lab/gym/utils/gym_utils.py | 48 +- embodichain/lab/gym/utils/registration.py | 69 +- embodichain/lab/scripts/run_env.py | 6 - .../gym/multi_segments/cube_pick_place.json | 5 +- .../multi_segments/cube_pick_place.py | 39 +- .../tableware/open_drawer.py | 17 +- tests/gym/envs/expert_program/test_catalog.py | 596 ++++++++++ .../test_simulation_environment.py | 85 +- .../test_multi_segments_cube_pick_place.py | 28 +- tests/gym/envs/tasks/test_open_drawer.py | 9 +- tests/gym/utils/test_gym_utils.py | 91 +- tests/lab/scripts/test_run_env.py | 23 +- 18 files changed, 2281 insertions(+), 176 deletions(-) create mode 100644 embodichain/lab/gym/envs/expert_program/catalog.py create mode 100644 tests/gym/envs/expert_program/test_catalog.py diff --git a/embodichain/lab/gym/envs/expert_program/__init__.py b/embodichain/lab/gym/envs/expert_program/__init__.py index ca245f98..6a39b35e 100644 --- a/embodichain/lab/gym/envs/expert_program/__init__.py +++ b/embodichain/lab/gym/envs/expert_program/__init__.py @@ -130,6 +130,11 @@ SimulationRobotSkillProfileBinding, SimulationSceneBinding, ) +from .catalog import ( + ExpertProgramIntegrationCatalog, + IntegrationFingerprintMismatch, + SimulationExpertProgramRegistration, +) from .simulation_environment import ( ControlCommandStateEvidenceTracker, MotionGeneratorFactory, @@ -139,7 +144,10 @@ SimulationPlanningObservationProvider, create_simulation_expert_program_adapter, ) -from .simulation_policies import SimulationSegmentPolicyPort +from .simulation_policies import ( + SimulationSegmentPolicyPort, + default_simulation_settle_presets, +) __all__ = [ "AcceptedRuntimeCommandObserver", @@ -183,6 +191,7 @@ "ExpertProgramEnvironmentFactory", "ExpertProgramEnvironmentMixin", "ExpertProgramIntegrationCfg", + "ExpertProgramIntegrationCatalog", "ExpertProgramRuntimeAssembly", "ExpertProgramSceneResolver", "ExpertProgramValidationContext", @@ -190,6 +199,7 @@ "HandOverCfg", "GymPlanningObservationProvider", "InvokeCfg", + "IntegrationFingerprintMismatch", "MAX_DECLARATIVE_DEPTH", "MAX_DECLARATIVE_NODES", "MAX_EXPANDED_CALLS", @@ -228,6 +238,7 @@ "SimulationArticulationLinkBinding", "SimulationExpertProgramEnvironment", "SimulationExpertProgramFactory", + "SimulationExpertProgramRegistration", "SimulationPlanningObservationProvider", "SimulationRigidObjectBinding", "SimulationResourceEndpointBinding", @@ -242,6 +253,7 @@ "ValidatorCfg", "WaitStablePostCfg", "create_simulation_expert_program_adapter", + "default_simulation_settle_presets", "decode_expert_program", "load_expert_program", "loads_expert_program_json", diff --git a/embodichain/lab/gym/envs/expert_program/catalog.py b/embodichain/lab/gym/envs/expert_program/catalog.py new file mode 100644 index 00000000..a683caa1 --- /dev/null +++ b/embodichain/lab/gym/envs/expert_program/catalog.py @@ -0,0 +1,1047 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Immutable task-registration catalog for declarative Expert Programs.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field, fields, is_dataclass, replace +from enum import Enum +import hashlib +import json +import math +from types import MappingProxyType +import torch + +from embodichain.lab.gym.envs.settling import DynamicSettleMonitorCfg +from embodichain.lab.sim.atomic_actions import ( + Affordance, + ArticulationOperationAffordance, + AtomicActionEngine, + SkillDescriptor, +) +from embodichain.lab.sim.atomic_actions.primitives import BUILTIN_ACTION_TYPES +from embodichain.lab.sim.skills import ( + ARTICULATION_OPERATION_AFFORDANCE_CAPABILITY, + PLACE_IN_AFFORDANCE_CAPABILITY, + PLACE_ON_AFFORDANCE_CAPABILITY, + HandOverPoseProvider, + OperateArticulation, + Place, + RelationTargetGrounder, + RobotSkillProfile, + SceneAffordanceRef, + SceneArticulationRef, + SceneEntityRef, + SceneManifest, + SceneObjectRef, + SceneRegistry, + SemanticCallCatalog, + SemanticIntegrationManifest, + SemanticValidationError, + SkillPolicyPreset, + builtin_semantic_call_catalog, +) + +from .cfg import ( + ExpertProgramCfg, + ExpertProgramIntegrationCfg, + OperateArticulationCfg, + PostPolicyCfg, + RegisteredSemanticCallCfg, + SemanticCallCfg, + ValidatorCfg, +) +from .compiler import ( + CompiledProgram, + ExpertProgramCompileError, + ExpertProgramCompiler, + ExpertProgramSceneResolver, +) +from .decoder import ( + ConfigPath, + ExpertProgramValidationError, + SceneReferenceRole, +) +from .simulation import SimulationRobotSkillProfileBinding, SimulationSceneBinding +from .simulation_policies import default_simulation_settle_presets + +_CATALOG_FINGERPRINT_SCHEMA_VERSION = 1 +_POST_POLICY_KINDS = frozenset({"wait_stable"}) +_VALIDATOR_KINDS = frozenset({"object_near_target"}) + + +class IntegrationFingerprintMismatch(RuntimeError): + """Raised when a live integration no longer matches its registration.""" + + +def _qualified_name(value: type[object] | object) -> str: + """Return a stable fully-qualified type name.""" + value_type = value if isinstance(value, type) else type(value) + return f"{value_type.__module__}.{value_type.__qualname__}" + + +def _canonical_value(value: object) -> object: + """Convert provider-free declarations to deterministic JSON values.""" + if value is None or type(value) in (bool, int, str): + return value + if type(value) is float: + if not math.isfinite(value): + raise ValueError("Fingerprint metadata cannot contain non-finite floats.") + return value + if isinstance(value, Enum): + return { + "type": _qualified_name(value), + "value": _canonical_value(value.value), + } + if isinstance(value, type): + return {"type": _qualified_name(value)} + if isinstance(value, torch.Tensor): + tensor = value.detach().cpu() + return { + "tensor_dtype": str(tensor.dtype), + "tensor_shape": list(tensor.shape), + "tensor_value": tensor.tolist(), + } + if isinstance(value, Mapping): + normalized: dict[str, object] = {} + for key, nested in value.items(): + if type(key) is not str: + raise TypeError("Fingerprint mapping keys must be exact strings.") + normalized[key] = _canonical_value(nested) + return {key: normalized[key] for key in sorted(normalized)} + if isinstance(value, (tuple, list)): + return [_canonical_value(nested) for nested in value] + if isinstance(value, (set, frozenset)): + normalized = [_canonical_value(nested) for nested in value] + return sorted( + normalized, + key=lambda item: json.dumps( + item, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ), + ) + if is_dataclass(value): + metadata = { + data_field.name: _canonical_value(getattr(value, data_field.name)) + for data_field in fields(value) + } + return {"type": _qualified_name(value), "fields": metadata} + # Provider objects are not executable catalog data. Their declared type is + # still part of the integration surface, while live identity is excluded. + return {"provider_type": _qualified_name(value)} + + +def _canonical_json(value: object) -> str: + """Encode one declaration using the versioned canonical JSON form.""" + return json.dumps( + _canonical_value(value), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ) + + +def _digest(payload: object) -> str: + """Return the SHA-256 digest for one canonical declaration payload.""" + return hashlib.sha256(_canonical_json(payload).encode("utf-8")).hexdigest() + + +def _snapshot_settle_presets( + values: Mapping[str, DynamicSettleMonitorCfg], +) -> Mapping[str, DynamicSettleMonitorCfg]: + """Own one strict named settle-preset table.""" + if not isinstance(values, Mapping) or not values: + raise ValueError("settle_presets must be a non-empty mapping.") + normalized: dict[str, DynamicSettleMonitorCfg] = {} + for preset_id, preset in values.items(): + if ( + type(preset_id) is not str + or not preset_id + or preset_id != preset_id.strip() + ): + raise ValueError( + "Settle preset IDs must be non-empty strings without outer " + "whitespace." + ) + if not isinstance(preset, DynamicSettleMonitorCfg): + raise TypeError( + "settle_presets values must be DynamicSettleMonitorCfg values." + ) + normalized[preset_id] = preset.snapshot() + return MappingProxyType(normalized) + + +def _exact_identifier(value: object, *, field_name: str) -> str: + """Validate one exact catalog identifier.""" + if type(value) is not str or not value or value != value.strip(): + raise ValueError( + f"{field_name} must be a non-empty string without outer whitespace." + ) + return value + + +def _relation_grounder_key( + grounder: RelationTargetGrounder, +) -> tuple[str, type[Affordance], str]: + """Return the compiler-compatible exact key for one relation grounder.""" + grounder_type = type(grounder) + capability = _exact_identifier( + getattr(grounder_type, "capability", None), + field_name="RelationTargetGrounder.capability", + ) + affordance_type = getattr(grounder_type, "affordance_type", None) + if not isinstance(affordance_type, type) or not issubclass( + affordance_type, + Affordance, + ): + raise TypeError( + "RelationTargetGrounder.affordance_type must be an Affordance subclass." + ) + revision = _exact_identifier( + getattr(grounder_type, "affordance_revision", None), + field_name="RelationTargetGrounder.affordance_revision", + ) + return capability, affordance_type, revision + + +def _relation_grounder_order_key( + grounder: RelationTargetGrounder, +) -> tuple[str, str, str]: + """Return one totally ordered rendering of a relation-grounder key.""" + capability, affordance_type, revision = _relation_grounder_key(grounder) + return capability, _qualified_name(affordance_type), revision + + +def _validate_provider_declaration(provider: object, *, field_name: str) -> None: + """Accept only frozen dataclass declarations or stateless providers.""" + dataclass_declaration = is_dataclass(provider) + dataclass_field_names: set[str] = set() + if dataclass_declaration: + params = getattr(type(provider), "__dataclass_params__", None) + if params is None or not params.frozen: + raise TypeError( + f"{field_name} stateful declarations must be frozen dataclasses " + "so every configuration field enters the registration fingerprint." + ) + dataclass_field_names.update( + declaration_field.name for declaration_field in fields(provider) + ) + + state_names: set[str] = set() + instance_state = getattr(provider, "__dict__", None) + if isinstance(instance_state, Mapping): + state_names.update(instance_state) + for owner in type(provider).__mro__: + declared_slots = getattr(owner, "__slots__", ()) + slots = (declared_slots,) if isinstance(declared_slots, str) else declared_slots + for slot_name in slots: + if slot_name in {"__dict__", "__weakref__"}: + continue + storage_name = ( + f"_{owner.__name__.lstrip('_')}{slot_name}" + if slot_name.startswith("__") and not slot_name.endswith("__") + else slot_name + ) + if hasattr(provider, storage_name): + state_names.add(storage_name) + undeclared_state = ( + state_names.difference(dataclass_field_names) + if dataclass_declaration + else state_names + ) + if undeclared_state: + raise TypeError( + f"{field_name} providers contain unfingerprinted state " + f"{sorted(undeclared_state)}. Use a frozen dataclass declaration with " + "every state field declared; non-dataclass providers must be stateless." + ) + + +def _snapshot_relation_grounders( + values: tuple[RelationTargetGrounder, ...], +) -> tuple[RelationTargetGrounder, ...]: + """Validate and own one immutable relation-grounder tuple.""" + if type(values) is not tuple: + raise TypeError("relation_grounders must be an exact tuple.") + seen: set[tuple[str, type[Affordance], str]] = set() + for grounder in values: + if not isinstance(grounder, RelationTargetGrounder): + raise TypeError( + "relation_grounders must contain RelationTargetGrounder instances." + ) + _validate_provider_declaration( + grounder, + field_name="relation_grounders", + ) + key = _relation_grounder_key(grounder) + if key in seen: + raise ValueError(f"Duplicate relation grounder key {key!r}.") + seen.add(key) + return tuple(values) + + +def _snapshot_relation_grounder_keys( + values: frozenset[tuple[str, type[Affordance], str]], +) -> frozenset[tuple[str, type[Affordance], str]]: + """Validate immutable provider-free relation-grounder lookup keys.""" + if type(values) is not frozenset: + raise TypeError("relation_grounder_keys must be an exact frozenset.") + normalized: set[tuple[str, type[Affordance], str]] = set() + for key in values: + if type(key) is not tuple or len(key) != 3: + raise TypeError("relation_grounder_keys must contain exact 3-tuple values.") + capability, affordance_type, revision = key + _exact_identifier(capability, field_name="relation grounder capability") + if not isinstance(affordance_type, type) or not issubclass( + affordance_type, + Affordance, + ): + raise TypeError( + "relation grounder affordance types must be Affordance subclasses." + ) + _exact_identifier(revision, field_name="relation grounder revision") + normalized.add((capability, affordance_type, revision)) + return frozenset(normalized) + + +def _handover_pose_provider_id(provider: HandOverPoseProvider) -> str: + """Return the compiler-compatible class ID for one hand-over provider.""" + return _exact_identifier( + getattr(type(provider), "provider_id", None), + field_name="HandOverPoseProvider.provider_id", + ) + + +def _snapshot_handover_pose_providers( + values: tuple[HandOverPoseProvider, ...], +) -> tuple[HandOverPoseProvider, ...]: + """Validate and own one immutable hand-over-provider tuple.""" + if type(values) is not tuple: + raise TypeError("handover_pose_providers must be an exact tuple.") + seen: set[str] = set() + for provider in values: + if not isinstance(provider, HandOverPoseProvider): + raise TypeError( + "handover_pose_providers must contain HandOverPoseProvider instances." + ) + _validate_provider_declaration( + provider, + field_name="handover_pose_providers", + ) + provider_id = _handover_pose_provider_id(provider) + if provider_id in seen: + raise ValueError(f"Duplicate handover pose provider {provider_id!r}.") + seen.add(provider_id) + return tuple(values) + + +def _declared_articulation_operation_targets( + scene_binding: SimulationSceneBinding, +) -> dict[str, frozenset[str]]: + """Derive named operation-target IDs from the task-owned scene binding.""" + return { + binding.entity_id: frozenset(binding.semantic_targets) + for binding in scene_binding.articulation_operations + } + + +def _snapshot_articulation_operation_targets( + values: Mapping[str, frozenset[str]], + *, + scene: SceneManifest, +) -> Mapping[str, frozenset[str]]: + """Own and cross-check provider-free named articulation targets.""" + if not isinstance(values, Mapping): + raise TypeError("articulation_operation_targets must be a mapping.") + normalized: dict[str, frozenset[str]] = {} + for affordance_id, target_ids in values.items(): + _exact_identifier( + affordance_id, + field_name="articulation operation affordance IDs", + ) + if type(target_ids) is not frozenset: + raise TypeError( + "articulation_operation_targets values must be exact frozensets." + ) + for target_id in target_ids: + _exact_identifier( + target_id, + field_name="articulation operation target IDs", + ) + entry = scene.lookup( + affordance_id, + expected_type=SceneAffordanceRef, + path=("articulation_operation_targets", affordance_id), + ) + if entry.ref.entity_id != affordance_id: + raise ValueError( + "articulation_operation_targets keys must use canonical " + "affordance IDs." + ) + if ( + ARTICULATION_OPERATION_AFFORDANCE_CAPABILITY + not in entry.affordance_capabilities + or entry.affordance_payload_type is not ArticulationOperationAffordance + ): + raise TypeError( + f"Scene affordance {affordance_id!r} is not an articulation " + "operation affordance." + ) + normalized[affordance_id] = frozenset(target_ids) + + declared_affordance_ids = { + entry.ref.entity_id + for entry in scene.entries + if type(entry.ref) is SceneAffordanceRef + and ARTICULATION_OPERATION_AFFORDANCE_CAPABILITY + in entry.affordance_capabilities + } + if set(normalized) != declared_affordance_ids: + raise ValueError( + "articulation_operation_targets must cover every declared operation " + f"affordance exactly; expected {sorted(declared_affordance_ids)}, got " + f"{sorted(normalized)}." + ) + return MappingProxyType(normalized) + + +class _SceneManifestProgramResolver: + """Resolve compiler references from an immutable :class:`SceneManifest`.""" + + def __init__(self, scene: SceneManifest) -> None: + if type(scene) is not SceneManifest: + raise TypeError("scene must be exactly SceneManifest.") + self._scene = scene + + def resolve( + self, + reference: str, + *, + expected_types: tuple[type[SceneEntityRef], ...], + path: ConfigPath, + ) -> SceneEntityRef: + """Resolve one reference without retaining a live registry.""" + if ( + type(expected_types) is not tuple + or not expected_types + or not all( + isinstance(expected_type, type) + and issubclass(expected_type, SceneEntityRef) + for expected_type in expected_types + ) + ): + raise TypeError( + "expected_types must be a non-empty tuple of scene-ref types." + ) + try: + resolved = self._scene.resolve(reference, path=path) + except (KeyError, TypeError, ValueError) as exc: + raise ExpertProgramCompileError( + "unknown_scene_reference", + path, + str(exc), + ) from exc + if type(resolved) not in expected_types: + raise ExpertProgramCompileError( + "scene_reference_type_mismatch", + path, + f"Scene reference {reference!r} resolves to " + f"{type(resolved).__name__}, expected one of " + f"{tuple(value.__name__ for value in expected_types)}.", + ) + return type(resolved)(resolved.entity_id) + + +@dataclass(frozen=True, slots=True) +class ExpertProgramIntegrationCatalog: + """Provider-free integration directory owned by one task registration.""" + + scene_registry_id: str + robot_profile_id: str + scene: SceneManifest + robot_profile: RobotSkillProfile + call_catalog: SemanticCallCatalog + relation_grounder_keys: frozenset[tuple[str, type[Affordance], str]] + articulation_operation_targets: Mapping[str, frozenset[str]] + settle_preset_ids: frozenset[str] + fingerprint: str + _required_skills: Mapping[str, SkillDescriptor] = field( + repr=False, + compare=False, + ) + + def __post_init__(self) -> None: + for field_name in ("scene_registry_id", "robot_profile_id"): + value = getattr(self, field_name) + if type(value) is not str or not value or value != value.strip(): + raise ValueError(f"{field_name} must be an exact identifier.") + if type(self.scene) is not SceneManifest: + raise TypeError("scene must be exactly SceneManifest.") + if type(self.robot_profile) is not RobotSkillProfile: + raise TypeError("robot_profile must be exactly RobotSkillProfile.") + if type(self.call_catalog) is not SemanticCallCatalog: + raise TypeError("call_catalog must be exactly SemanticCallCatalog.") + object.__setattr__( + self, + "relation_grounder_keys", + _snapshot_relation_grounder_keys(self.relation_grounder_keys), + ) + object.__setattr__( + self, + "articulation_operation_targets", + _snapshot_articulation_operation_targets( + self.articulation_operation_targets, + scene=self.scene, + ), + ) + if self.robot_profile.profile_id != self.robot_profile_id: + raise ValueError("robot_profile_id must match robot_profile.profile_id.") + preset_ids = frozenset(self.settle_preset_ids) + if not preset_ids: + raise ValueError("settle_preset_ids must not be empty.") + object.__setattr__(self, "settle_preset_ids", preset_ids) + if ( + type(self.fingerprint) is not str + or len(self.fingerprint) != 64 + or any( + character not in "0123456789abcdef" for character in self.fingerprint + ) + ): + raise ValueError("fingerprint must be a lowercase SHA-256 digest.") + object.__setattr__( + self, + "_required_skills", + MappingProxyType(dict(self._required_skills)), + ) + + def validate_integration( + self, + integration: ExpertProgramIntegrationCfg, + *, + path: ConfigPath, + ) -> None: + """Validate exact scene, profile, and runtime-preset selection.""" + del path + if integration.scene_registry != self.scene_registry_id: + raise ValueError( + f"Expected scene_registry {self.scene_registry_id!r}, got " + f"{integration.scene_registry!r}." + ) + if integration.robot_profile != self.robot_profile_id: + raise ValueError( + f"Expected robot_profile {self.robot_profile_id!r}, got " + f"{integration.robot_profile!r}." + ) + if integration.runtime_preset not in self.robot_profile.presets: + raise KeyError( + f"Unknown runtime preset {integration.runtime_preset!r}; available " + f"presets are {sorted(self.robot_profile.presets)}." + ) + + def validate_semantic_call( + self, + call: SemanticCallCfg, + *, + path: ConfigPath, + ) -> None: + """Validate semantic-call catalog and payload revision references.""" + call_id = call.call_id if type(call) is RegisteredSemanticCallCfg else call.kind + descriptor = self.call_catalog.discover(call_id) + if type(call) is RegisteredSemanticCallCfg and ( + call.schema_version != descriptor.schema_version + ): + raise ValueError( + f"Semantic call {call_id!r} requires schema_version " + f"{descriptor.schema_version}, got {call.schema_version}." + ) + if type(call) is OperateArticulationCfg and call.target is not None: + self._validate_articulation_operation_target( + articulation=call.articulation, + handle=call.handle, + target=call.target, + path=path, + ) + + def _validate_articulation_operation_target( + self, + *, + articulation: str | SceneArticulationRef, + handle: str | SceneAffordanceRef | None, + target: str, + path: ConfigPath, + ) -> None: + """Resolve one operation affordance and validate its named target.""" + try: + articulation_ref = self.scene.resolve( + articulation, + expected_type=SceneArticulationRef, + path=(*path, "articulation"), + ) + except SemanticValidationError as exc: + raise ExpertProgramValidationError( + exc.diagnostic.code, + exc.diagnostic.path, + exc.diagnostic.message, + ) from exc + try: + affordance = self.scene.resolve_affordance( + articulation_ref, + capability=ARTICULATION_OPERATION_AFFORDANCE_CAPABILITY, + explicit=handle, + path=(*path, "handle"), + ) + except SemanticValidationError as exc: + raise ExpertProgramValidationError( + exc.diagnostic.code, + exc.diagnostic.path, + exc.diagnostic.message, + ) from exc + target_ids = self.articulation_operation_targets.get(affordance.entity_id) + if target_ids is None: + raise ExpertProgramValidationError( + "missing_articulation_operation_targets", + (*path, "handle"), + f"Operation affordance {affordance.entity_id!r} has no static " + "named-target declaration.", + ) + if target not in target_ids: + raise ExpertProgramValidationError( + "unknown_articulation_operation_target", + (*path, "target"), + f"Unknown target {target!r} for operation affordance " + f"{affordance.entity_id!r}; available targets are " + f"{sorted(target_ids)}.", + ) + + def _validate_place_relation_grounder( + self, + call: Place, + *, + affordance: SceneAffordanceRef, + path: ConfigPath, + ) -> None: + """Require the exact linked relation-affordance grounder pre-sim.""" + if call.on is not None: + capability = PLACE_ON_AFFORDANCE_CAPABILITY + relation_field = "on" + elif call.inside is not None: + capability = PLACE_IN_AFFORDANCE_CAPABILITY + relation_field = "inside" + else: + return + entry = self.scene.lookup( + affordance, + expected_type=SceneAffordanceRef, + path=(*path, relation_field), + ) + payload_type = entry.affordance_payload_type + revision = entry.affordance_revision + if payload_type is None or revision is None: + raise ExpertProgramValidationError( + "incomplete_relation_affordance_declaration", + (*path, relation_field), + f"Relation affordance {affordance.entity_id!r} must declare an " + "exact payload type and revision.", + ) + key = (capability, payload_type, revision) + if key not in self.relation_grounder_keys: + rendered_key = ( + capability, + _qualified_name(payload_type), + revision, + ) + raise ExpertProgramValidationError( + "relation_grounder_not_registered", + (*path, relation_field), + f"No task-registration relation grounder matches linked " + f"affordance {affordance.entity_id!r} with key {rendered_key!r}.", + ) + + def validate_scene_reference( + self, + reference: str, + *, + role: SceneReferenceRole, + path: ConfigPath, + ) -> None: + """Validate one typed scene reference against its declared role.""" + expected: dict[str, tuple[type[SceneEntityRef], ...]] = { + "entity": (SceneEntityRef,), + "object": (SceneObjectRef,), + "articulation": (SceneArticulationRef,), + "affordance": (SceneAffordanceRef,), + "object_or_affordance": (SceneObjectRef, SceneAffordanceRef), + } + expected_types = expected.get(role) + if expected_types is None: + raise ValueError(f"Unsupported scene reference role {role!r}.") + resolved = self.scene.resolve(reference, path=path) + if not isinstance(resolved, expected_types): + raise TypeError( + f"Scene reference {reference!r} is {type(resolved).__name__}, " + f"not one of {tuple(value.__name__ for value in expected_types)}." + ) + + def validate_post_policy( + self, + policy: PostPolicyCfg, + *, + path: ConfigPath, + ) -> None: + """Validate one registered post-policy kind and named preset.""" + del path + if policy.kind not in _POST_POLICY_KINDS: + raise KeyError( + f"Unknown post-policy kind {policy.kind!r}; available kinds are " + f"{sorted(_POST_POLICY_KINDS)}." + ) + if policy.preset not in self.settle_preset_ids: + raise KeyError( + f"Unknown settle preset {policy.preset!r}; available presets are " + f"{sorted(self.settle_preset_ids)}." + ) + + def validate_validator( + self, + validator: ValidatorCfg, + *, + path: ConfigPath, + ) -> None: + """Validate one registered segment-validator kind.""" + del path + if validator.kind not in _VALIDATOR_KINDS: + raise KeyError( + f"Unknown validator kind {validator.kind!r}; available kinds are " + f"{sorted(_VALIDATOR_KINDS)}." + ) + + def preflight(self, program: ExpertProgramCfg) -> CompiledProgram: + """Compile and statically link every expanded semantic call.""" + self.validate_integration(program.integration, path=("integration",)) + resolver: ExpertProgramSceneResolver = _SceneManifestProgramResolver(self.scene) + compiled = ExpertProgramCompiler(resolver).compile(program) + manifest = SemanticIntegrationManifest( + scene=self.scene, + robot_profile=self.robot_profile, + call_catalog=self.call_catalog, + runtime_preset=program.integration.runtime_preset, + ) + for segment in compiled.iter_segments(): + for call in segment.calls: + if ( + type(call.call) is OperateArticulation + and call.call.target is not None + ): + self._validate_articulation_operation_target( + articulation=call.call.articulation, + handle=call.call.handle, + target=call.call.target, + path=call.source_path, + ) + linked = manifest.link_call(call.call, path=call.source_path) + if type(linked.call) is Place and linked.call.at is None: + destination = linked.affordances.get("destination") + if destination is None: + raise AssertionError( + "Linked relation Place call lacks a destination " + "affordance." + ) + self._validate_place_relation_grounder( + linked.call, + affordance=destination, + path=call.source_path, + ) + return compiled + + def validate_engine(self, engine: AtomicActionEngine) -> None: + """Require the live engine to expose every statically selected skill.""" + if not isinstance(engine, AtomicActionEngine): + raise TypeError("engine must be an AtomicActionEngine.") + for skill_id, expected in self._required_skills.items(): + actual = engine.skills.get(skill_id) + if actual != expected: + raise IntegrationFingerprintMismatch( + f"Live skill {skill_id!r} differs from the registered " + "semantic target descriptor." + ) + + +def _profile_with_control_dt( + profile: RobotSkillProfile, + *, + control_dt: float, +) -> RobotSkillProfile: + """Return the registration profile aligned to one Gym control cadence.""" + return replace( + profile, + presets={ + preset_id: SkillPolicyPreset( + preset_id=preset.preset_id, + schema_version=preset.schema_version, + motion_policy=replace(preset.motion_policy, control_dt=control_dt), + tracking_policy=preset.tracking_policy, + recovery_policy=preset.recovery_policy, + runner_cfg=preset.runner_cfg, + effect_monitors=preset.effect_monitors, + ) + for preset_id, preset in profile.presets.items() + }, + ) + + +def _registration_payload( + *, + scene_binding: SimulationSceneBinding, + scene: SceneManifest, + articulation_operation_targets: Mapping[str, frozenset[str]], + robot_profile_binding: SimulationRobotSkillProfileBinding, + robot_profile: RobotSkillProfile, + call_catalog: SemanticCallCatalog, + settle_presets: Mapping[str, DynamicSettleMonitorCfg], + relation_grounder_keys: frozenset[tuple[str, type[Affordance], str]], + relation_grounders: tuple[RelationTargetGrounder, ...], + handover_pose_providers: tuple[HandOverPoseProvider, ...], +) -> dict[str, object]: + """Build the versioned canonical fingerprint payload.""" + return { + "schema_version": _CATALOG_FINGERPRINT_SCHEMA_VERSION, + "scene_binding": scene_binding, + "scene_manifest": scene.entries, + "articulation_operation_targets": articulation_operation_targets, + "robot_profile_binding": robot_profile_binding, + "robot_profile": robot_profile, + "call_descriptors": tuple( + sorted( + call_catalog.descriptors.values(), + key=lambda descriptor: descriptor.call_id, + ) + ), + "relation_grounder_keys": relation_grounder_keys, + "relation_grounders": tuple( + { + "key": _relation_grounder_key(grounder), + "provider": grounder, + } + for grounder in sorted( + relation_grounders, + key=_relation_grounder_order_key, + ) + ), + "handover_pose_providers": tuple( + { + "provider_id": _handover_pose_provider_id(provider), + "provider": provider, + } + for provider in sorted( + handover_pose_providers, + key=_handover_pose_provider_id, + ) + ), + "post_policy_kinds": _POST_POLICY_KINDS, + "settle_presets": settle_presets, + "validator_kinds": _VALIDATOR_KINDS, + } + + +@dataclass(frozen=True, slots=True) +class SimulationExpertProgramRegistration: + """Exact immutable task-owned simulation integration registration.""" + + scene_binding: SimulationSceneBinding + robot_profile_binding: SimulationRobotSkillProfileBinding + call_catalog: SemanticCallCatalog = field( + default_factory=builtin_semantic_call_catalog + ) + settle_presets: Mapping[str, DynamicSettleMonitorCfg] = field( + default_factory=default_simulation_settle_presets + ) + relation_grounders: tuple[RelationTargetGrounder, ...] = () + handover_pose_providers: tuple[HandOverPoseProvider, ...] = () + catalog: ExpertProgramIntegrationCatalog = field(init=False) + + def __post_init__(self) -> None: + if type(self.scene_binding) is not SimulationSceneBinding: + raise TypeError("scene_binding must be exactly SimulationSceneBinding.") + if type(self.robot_profile_binding) is not SimulationRobotSkillProfileBinding: + raise TypeError( + "robot_profile_binding must be exactly " + "SimulationRobotSkillProfileBinding." + ) + if type(self.call_catalog) is not SemanticCallCatalog: + raise TypeError("call_catalog must be exactly SemanticCallCatalog.") + settle_presets = _snapshot_settle_presets(self.settle_presets) + object.__setattr__(self, "settle_presets", settle_presets) + relation_grounders = _snapshot_relation_grounders(self.relation_grounders) + object.__setattr__(self, "relation_grounders", relation_grounders) + relation_grounder_keys = frozenset( + _relation_grounder_key(grounder) for grounder in relation_grounders + ) + handover_pose_providers = _snapshot_handover_pose_providers( + self.handover_pose_providers + ) + object.__setattr__( + self, + "handover_pose_providers", + handover_pose_providers, + ) + + scene = self.scene_binding.declare() + articulation_operation_targets = _declared_articulation_operation_targets( + self.scene_binding + ) + profile = self.robot_profile_binding.declare() + selected_handover_provider = profile.grounding_providers.get("hand_over") + registered_handover_provider_ids = { + _handover_pose_provider_id(provider) for provider in handover_pose_providers + } + if ( + selected_handover_provider is not None + and selected_handover_provider not in registered_handover_provider_ids + ): + raise ValueError( + "Robot profile selects handover pose provider " + f"{selected_handover_provider!r}, but the task registration did " + "not install it." + ) + builtin_skills = { + descriptor.skill_id: descriptor + for action_type in BUILTIN_ACTION_TYPES + if (descriptor := action_type.descriptor()).agent_visible + and descriptor.binding_contract is not None + } + required_skills: dict[str, SkillDescriptor] = {} + for descriptor in self.call_catalog.descriptors.values(): + target = descriptor.target_descriptor + installed = builtin_skills.get(descriptor.skill_id) + if target is None or installed != target: + raise ValueError( + f"Semantic call {descriptor.call_id!r} targets skill " + f"{descriptor.skill_id!r}, which is not installed by the " + "standard simulation factory." + ) + required_skills[descriptor.skill_id] = target + + fingerprint = _digest( + _registration_payload( + scene_binding=self.scene_binding, + scene=scene, + articulation_operation_targets=articulation_operation_targets, + robot_profile_binding=self.robot_profile_binding, + robot_profile=profile, + call_catalog=self.call_catalog, + settle_presets=settle_presets, + relation_grounder_keys=relation_grounder_keys, + relation_grounders=relation_grounders, + handover_pose_providers=handover_pose_providers, + ) + ) + object.__setattr__( + self, + "catalog", + ExpertProgramIntegrationCatalog( + scene_registry_id=self.scene_binding.registry_id, + robot_profile_id=self.robot_profile_binding.profile_id, + scene=scene, + robot_profile=profile, + call_catalog=self.call_catalog, + relation_grounder_keys=relation_grounder_keys, + articulation_operation_targets=articulation_operation_targets, + settle_preset_ids=frozenset(settle_presets), + fingerprint=fingerprint, + _required_skills=required_skills, + ), + ) + + @property + def fingerprint(self) -> str: + """Return the canonical registration fingerprint.""" + return self.catalog.fingerprint + + def assert_unchanged(self) -> None: + """Reject nested declaration drift before live component creation.""" + scene = self.scene_binding.declare() + articulation_operation_targets = _declared_articulation_operation_targets( + self.scene_binding + ) + profile = self.robot_profile_binding.declare() + try: + relation_grounders = _snapshot_relation_grounders(self.relation_grounders) + relation_grounder_keys = frozenset( + _relation_grounder_key(grounder) for grounder in relation_grounders + ) + handover_pose_providers = _snapshot_handover_pose_providers( + self.handover_pose_providers + ) + current = _digest( + _registration_payload( + scene_binding=self.scene_binding, + scene=scene, + articulation_operation_targets=(articulation_operation_targets), + robot_profile_binding=self.robot_profile_binding, + robot_profile=profile, + call_catalog=self.call_catalog, + settle_presets=self.settle_presets, + relation_grounder_keys=relation_grounder_keys, + relation_grounders=relation_grounders, + handover_pose_providers=handover_pose_providers, + ) + ) + except (TypeError, ValueError) as exc: + raise IntegrationFingerprintMismatch( + "Expert Program integration provider declaration changed after " + "task registration." + ) from exc + if current != self.fingerprint: + raise IntegrationFingerprintMismatch( + "Expert Program integration declaration changed after task " + "registration." + ) + + def validate_scene_registry(self, registry: SceneRegistry) -> None: + """Validate a live registry against the registered scene declaration.""" + self.assert_unchanged() + self.catalog.scene.validate_registry(registry) + + def validate_robot_profile( + self, + profile: RobotSkillProfile, + *, + step_dt: float, + ) -> None: + """Validate a cadence-aligned live profile against its declaration.""" + self.assert_unchanged() + if type(profile) is not RobotSkillProfile: + raise TypeError("profile must be exactly RobotSkillProfile.") + expected = _profile_with_control_dt( + self.catalog.robot_profile, + control_dt=step_dt, + ) + if _canonical_json(profile) != _canonical_json(expected): + raise IntegrationFingerprintMismatch( + "Live robot skill profile differs from the registered declaration." + ) + + +__all__ = [ + "ExpertProgramIntegrationCatalog", + "IntegrationFingerprintMismatch", + "SimulationExpertProgramRegistration", +] diff --git a/embodichain/lab/gym/envs/expert_program/environment.py b/embodichain/lab/gym/envs/expert_program/environment.py index 95b2294f..2e75c934 100644 --- a/embodichain/lab/gym/envs/expert_program/environment.py +++ b/embodichain/lab/gym/envs/expert_program/environment.py @@ -80,6 +80,7 @@ SegmentPostPolicyPort, SegmentValidatorPort, ) +from .catalog import ExpertProgramIntegrationCatalog from .cfg import ExpertProgramCfg, ExpertProgramIntegrationCfg from .compiler import ( CompiledProgram, @@ -268,6 +269,8 @@ class ExpertProgramEnvironmentAdapter: Args: factory: Environment-owned live-provider and engine factory. step_dt: Authoritative Gym control cadence in seconds. + integration_catalog: Optional immutable task-registration catalog used + for provider-free compilation. call_catalog: Optional immutable semantic call catalog. The built-in catalog is used when omitted. endpoint_adapters: Optional custom robot endpoint adapters. @@ -291,6 +294,7 @@ def __init__( factory: ExpertProgramEnvironmentFactory, *, step_dt: float, + integration_catalog: ExpertProgramIntegrationCatalog | None = None, call_catalog: SemanticCallCatalog | None = None, endpoint_adapters: ( Mapping[type[ResourceEndpoint], ResourceEndpointAdapter] | None @@ -319,7 +323,32 @@ def __init__( factory.robot_profile_id, field_name="factory.robot_profile_id", ) - selected_catalog = call_catalog or builtin_semantic_call_catalog() + if ( + integration_catalog is not None + and type(integration_catalog) is not ExpertProgramIntegrationCatalog + ): + raise TypeError( + "integration_catalog must be exactly " + "ExpertProgramIntegrationCatalog or None." + ) + if integration_catalog is not None: + if integration_catalog.scene_registry_id != scene_registry_id: + raise ValueError( + "integration_catalog scene_registry_id does not match factory." + ) + if integration_catalog.robot_profile_id != robot_profile_id: + raise ValueError( + "integration_catalog robot_profile_id does not match factory." + ) + if call_catalog is not None and ( + call_catalog is not integration_catalog.call_catalog + ): + raise ValueError( + "call_catalog cannot override the task registration catalog." + ) + selected_catalog = integration_catalog.call_catalog + else: + selected_catalog = call_catalog or builtin_semantic_call_catalog() if type(selected_catalog) is not SemanticCallCatalog: raise TypeError("call_catalog must be exactly SemanticCallCatalog or None.") if endpoint_adapters is not None and not isinstance(endpoint_adapters, Mapping): @@ -353,6 +382,7 @@ def __init__( self._scene_registry_id = scene_registry_id self._robot_profile_id = robot_profile_id self._step_dt = float(step_dt) + self._integration_catalog = integration_catalog self._call_catalog = selected_catalog self._endpoint_adapters = ( None if endpoint_adapters is None else dict(endpoint_adapters) @@ -406,6 +436,8 @@ def compile(self, program: ExpertProgramCfg) -> CompiledProgram: if type(program) is not ExpertProgramCfg: raise TypeError("program must be exactly ExpertProgramCfg.") self._validate_selection(program.integration) + if self._integration_catalog is not None: + return self._integration_catalog.preflight(program) registry = self._create_scene_registry() return ExpertProgramCompiler.from_scene_registry(registry).compile(program) diff --git a/embodichain/lab/gym/envs/expert_program/simulation.py b/embodichain/lab/gym/envs/expert_program/simulation.py index 5317dc09..b08235c3 100644 --- a/embodichain/lab/gym/envs/expert_program/simulation.py +++ b/embodichain/lab/gym/envs/expert_program/simulation.py @@ -50,6 +50,7 @@ RobotSkillProfile, SkillPolicyPreset, ) +from embodichain.lab.sim.skills.integration import SceneEntityManifest, SceneManifest from embodichain.lab.sim.skills.scene import ( ARTICULATION_OPERATION_AFFORDANCE_CAPABILITY, GRASP_AFFORDANCE_CAPABILITY, @@ -596,6 +597,139 @@ def __post_init__(self) -> None: "collision_world_mode must be SceneCollisionWorldMode or None." ) + def declare(self) -> SceneManifest: + """Project the complete provider-free scene declaration. + + Canonical topology errors are rejected here, before a simulation is + constructed. Native simulation UIDs, mesh data, link names, and joint + names remain live validation owned by :meth:`build`. + """ + objects = {item.entity_id: item for item in self.rigid_objects} + articulations = {item.entity_id: item for item in self.articulations} + links = {item.entity_id: item for item in self.links} + entries: list[SceneEntityManifest] = [] + + for binding in self.rigid_objects: + native_aliases = ( + () + if binding.simulation_uid == binding.entity_id + else (binding.simulation_uid,) + ) + defaults = ( + {} + if binding.default_grasp_affordance is None + else { + GRASP_AFFORDANCE_CAPABILITY: SceneAffordanceRef( + binding.default_grasp_affordance + ) + } + ) + entries.append( + SceneEntityManifest( + ref=SceneObjectRef(binding.entity_id), + aliases=(*native_aliases, *binding.aliases), + dynamics=binding.dynamics, + collision_role=binding.collision_role, + semantic_type=binding.semantic_type, + default_affordances=defaults, + ) + ) + + for binding in self.articulations: + native_aliases = ( + () + if binding.simulation_uid == binding.entity_id + else (binding.simulation_uid,) + ) + defaults = ( + {} + if binding.default_operation_affordance is None + else { + ARTICULATION_OPERATION_AFFORDANCE_CAPABILITY: ( + SceneAffordanceRef(binding.default_operation_affordance) + ) + } + ) + entries.append( + SceneEntityManifest( + ref=SceneArticulationRef(binding.entity_id), + aliases=(*native_aliases, *binding.aliases), + dynamics=binding.dynamics, + collision_role=binding.collision_role, + semantic_type=binding.semantic_type, + default_affordances=defaults, + ) + ) + + for binding in self.links: + if binding.articulation_id not in articulations: + raise KeyError( + f"Link {binding.entity_id!r} references unbound articulation " + f"{binding.articulation_id!r}." + ) + entries.append( + SceneEntityManifest( + ref=SceneLinkRef(binding.entity_id), + aliases=binding.aliases, + parent=SceneArticulationRef(binding.articulation_id), + native_name=binding.native_link_name, + dynamics=binding.dynamics, + semantic_type=binding.semantic_type, + ) + ) + + for binding in self.antipodal_grasps: + if binding.object_id not in objects: + raise KeyError( + f"Grasp affordance {binding.entity_id!r} references unbound " + f"object {binding.object_id!r}." + ) + entries.append( + SceneEntityManifest( + ref=SceneAffordanceRef(binding.entity_id), + aliases=binding.aliases, + parent=SceneObjectRef(binding.object_id), + native_name=binding.native_name, + affordance_capabilities=frozenset({GRASP_AFFORDANCE_CAPABILITY}), + affordance_payload_type=AntipodalAffordance, + affordance_revision=binding.revision, + relative_pose=binding.relative_pose, + ) + ) + + for binding in self.articulation_operations: + if binding.articulation_id not in articulations: + raise KeyError( + f"Operation affordance {binding.entity_id!r} references " + f"unbound articulation {binding.articulation_id!r}." + ) + link = links.get(binding.link_id) + if link is None: + raise KeyError( + f"Operation affordance {binding.entity_id!r} references " + f"unbound link {binding.link_id!r}." + ) + if link.articulation_id != binding.articulation_id: + raise ValueError( + f"Operation affordance {binding.entity_id!r} and link " + f"{binding.link_id!r} select different articulations." + ) + entries.append( + SceneEntityManifest( + ref=SceneAffordanceRef(binding.entity_id), + aliases=binding.aliases, + parent=SceneArticulationRef(binding.articulation_id), + native_name=link.native_link_name, + affordance_capabilities=frozenset( + {ARTICULATION_OPERATION_AFFORDANCE_CAPABILITY} + ), + affordance_payload_type=ArticulationOperationAffordance, + affordance_revision=binding.revision, + ) + ) + + return SceneManifest(entries) + def build(self, simulation: SimulationManager) -> SceneRegistry: """Build the existing authoritative scene registry. @@ -854,6 +988,21 @@ def build(self, *, control_dof: int) -> ControlPartCommandProfile: } ) + def declare(self) -> ControlPartCommandProfile: + """Build a provider-free command profile from declared tuple widths.""" + widths = {len(positions) for positions in self.commands.values()} + if len(widths) > 1: + raise ValueError( + f"Command preset {self.preset_id!r} declares inconsistent command " + f"widths {sorted(widths)}." + ) + return ControlPartCommandProfile.joint_positions( + **{ + command_id: torch.tensor(positions, dtype=torch.float32) + for command_id, positions in self.commands.items() + } + ) + def _require_control_part_dof(robot: Robot, control_part: str) -> int: """Validate one native joint-backed control part and return its width.""" @@ -902,6 +1051,9 @@ def endpoint_id(self) -> str: def build(self, robot: Robot) -> ResourceEndpoint: """Build and validate one endpoint declaration for ``robot``.""" + def declare(self) -> ResourceEndpoint: + """Return the provider-free endpoint declaration.""" + @runtime_checkable class SimulationRobotResourceBinding(Protocol): @@ -918,6 +1070,9 @@ def members(self) -> tuple[str, ...]: def build(self, robot: Robot) -> RobotResource: """Build and validate one owned robot resource declaration.""" + def declare(self) -> RobotResource: + """Return the provider-free resource declaration.""" + @dataclass(frozen=True, slots=True) class RobotResourceBinding: @@ -951,6 +1106,14 @@ def build(self, robot: Robot) -> RobotResource: members=self.members, ) + def declare(self) -> RobotResource: + """Return an independently owned provider-free resource.""" + return RobotResource( + resource_id=self.resource_id, + endpoints=self.endpoints, + members=self.members, + ) + @dataclass(frozen=True, slots=True) class ControlPartEndpointBinding: @@ -981,6 +1144,14 @@ def build(self, robot: Robot) -> ResourceEndpoint: capabilities=self.capabilities, ) + def declare(self) -> ResourceEndpoint: + """Return the endpoint contract without reading a robot.""" + return ControlPartEndpoint( + control_part=self.control_part, + command_profile=self.command_preset, + capabilities=self.capabilities, + ) + @dataclass(frozen=True, slots=True) class ControlPartResourceBinding: @@ -1026,6 +1197,16 @@ def build(self, robot: Robot) -> RobotResource: members=self.members, ) + def declare(self) -> RobotResource: + """Return the resource graph without reading native control parts.""" + return RobotResource( + resource_id=self.resource_id, + endpoints={ + binding.endpoint_id: binding.declare() for binding in self.endpoints + }, + members=self.members, + ) + def _owned_nested_identifier_mapping( values: Mapping[str, Mapping[str, str]], @@ -1221,6 +1402,65 @@ def require_control_part(control_part: str) -> int: grounding_providers=self.grounding_providers, ) + def declare(self) -> RobotSkillProfile: + """Project the complete provider-free robot skill profile.""" + resources: dict[str, RobotResource] = {} + for binding in self.resources: + resource = binding.declare() + if type(resource) is not RobotResource: + raise TypeError( + f"Resource binding {binding.resource_id!r} must declare " + "exactly RobotResource." + ) + if resource.resource_id != binding.resource_id: + raise ValueError( + f"Resource binding {binding.resource_id!r} declared " + f"resource ID {resource.resource_id!r}." + ) + if resource.members != tuple(binding.members): + raise ValueError( + f"Resource binding {binding.resource_id!r} changed its " + "declared resource members." + ) + resources[resource.resource_id] = resource + + command_presets = {preset.preset_id: preset for preset in self.command_presets} + for resource in resources.values(): + for endpoint_id, endpoint in resource.endpoints.items(): + if not isinstance(endpoint, ControlPartEndpoint): + continue + preset_id = endpoint.command_profile + if preset_id is None: + continue + preset = command_presets.get(preset_id) + if preset is None: + raise KeyError( + f"Endpoint {resource.resource_id!r}.{endpoint_id!r} " + f"references unknown command preset {preset_id!r}." + ) + if preset.control_part != endpoint.control_part: + raise ValueError( + f"Endpoint {resource.resource_id!r}.{endpoint_id!r} uses " + f"control part {endpoint.control_part!r}, but command " + f"preset {preset_id!r} targets {preset.control_part!r}." + ) + + return RobotSkillProfile( + profile_id=self.profile_id, + resources=resources, + command_profiles={ + preset.preset_id: preset.declare() for preset in self.command_presets + }, + defaults={ + skill_id: ResourceBinding(resources=bindings) + for skill_id, bindings in self.defaults.items() + }, + presets={preset.preset_id: preset for preset in self.presets}, + default_preset=self.default_preset, + skill_presets=self.skill_presets, + grounding_providers=self.grounding_providers, + ) + __all__ = [ "AntipodalGraspAffordanceBinding", diff --git a/embodichain/lab/gym/envs/expert_program/simulation_environment.py b/embodichain/lab/gym/envs/expert_program/simulation_environment.py index 557b8f70..eee4660e 100644 --- a/embodichain/lab/gym/envs/expert_program/simulation_environment.py +++ b/embodichain/lab/gym/envs/expert_program/simulation_environment.py @@ -38,7 +38,6 @@ import torch -from embodichain.lab.gym.envs.settling import DynamicSettleMonitorCfg from embodichain.lab.sim.atomic_actions import ( AtomicActionEngine, EntityState, @@ -70,11 +69,8 @@ MotionGenerator, ToppraPlannerCfg, ) -from embodichain.lab.sim.skills.calls import SemanticCallCatalog from embodichain.lab.sim.skills.compiler import ( - HandOverPoseProvider, RegisteredSemanticLowerer, - RelationTargetGrounder, ) from embodichain.lab.sim.skills.effects import ( ControlPartEvidenceAddress, @@ -108,15 +104,12 @@ GymPlanningObservationProvider, RuntimeTransportActionEncoder, ) +from .catalog import SimulationExpertProgramRegistration from .environment import ( ExpertProgramEnvironmentAdapter, ExpertProgramEnvironmentFactory, PlanningObservationPort, ) -from .simulation import ( - SimulationRobotSkillProfileBinding, - SimulationSceneBinding, -) from .simulation_policies import SimulationSegmentPolicyPort if TYPE_CHECKING: @@ -725,8 +718,7 @@ class SimulationExpertProgramFactory(ExpertProgramEnvironmentFactory): Args: simulation: Exact live simulation that owns ``robot`` and scene UIDs. robot: Exact robot selected for planning and evidence acquisition. - scene_binding: Canonical-to-native scene declaration. - robot_profile_binding: Typed robot resource and policy declaration. + registration: Exact task-owned static and live integration declaration. step_dt: Authoritative Gym control cadence. planner_cfg: Explicit planner configuration. ``None`` selects TOPPRA for ``robot.uid``. @@ -735,7 +727,6 @@ class SimulationExpertProgramFactory(ExpertProgramEnvironmentFactory): planners and isolated tests. endpoint_adapters: Explicit adapters for non-built-in resource endpoint types. - settle_presets: Optional named segment settling policies. translation_threshold: Material scene translation threshold. rotation_threshold: Material scene rotation threshold. contact_observer: Optional raw contact evidence callback. @@ -753,8 +744,7 @@ def __init__( self, simulation: SimulationManager, robot: Robot, - scene_binding: SimulationSceneBinding, - robot_profile_binding: SimulationRobotSkillProfileBinding, + registration: SimulationExpertProgramRegistration, *, step_dt: float, planner_cfg: BasePlannerCfg | None = None, @@ -762,7 +752,6 @@ def __init__( endpoint_adapters: ( Mapping[type[ResourceEndpoint], ResourceEndpointAdapter] | None ) = None, - settle_presets: Mapping[str, DynamicSettleMonitorCfg] | None = None, translation_threshold: float = 1.0e-4, rotation_threshold: float = 1.0e-3, contact_observer: BinaryObservationCallback | None = None, @@ -770,13 +759,11 @@ def __init__( force_observer: ScalarObservationCallback | None = None, wrench_observer: ScalarObservationCallback | None = None, ) -> None: - if type(scene_binding) is not SimulationSceneBinding: - raise TypeError("scene_binding must be exactly SimulationSceneBinding.") - if type(robot_profile_binding) is not SimulationRobotSkillProfileBinding: + if type(registration) is not SimulationExpertProgramRegistration: raise TypeError( - "robot_profile_binding must be exactly " - "SimulationRobotSkillProfileBinding." + "registration must be exactly SimulationExpertProgramRegistration." ) + registration.assert_unchanged() if planner_cfg is not None and motion_generator_factory is not None: raise ValueError( "planner_cfg and motion_generator_factory are mutually exclusive." @@ -818,8 +805,9 @@ def __init__( self._simulation = simulation self._robot = robot - self._scene_binding = scene_binding - self._robot_profile_binding = robot_profile_binding + self._registration = registration + self._scene_binding = registration.scene_binding + self._robot_profile_binding = registration.robot_profile_binding self._step_dt = _positive_finite(step_dt, field_name="step_dt") self._planner_cfg = selected_planner_cfg self._motion_generator_factory = motion_generator_factory @@ -850,8 +838,8 @@ def __init__( self._segment_policy_port = SimulationSegmentPolicyPort( simulation, robot, - scene_binding, - settle_presets=settle_presets, + registration.scene_binding, + settle_presets=registration.settle_presets, env_ids=self._env_ids, ) @@ -860,14 +848,12 @@ def from_environment( cls, environment: SimulationExpertProgramEnvironment, *, - scene_binding: SimulationSceneBinding, - robot_profile_binding: SimulationRobotSkillProfileBinding, + registration: SimulationExpertProgramRegistration, planner_cfg: BasePlannerCfg | None = None, motion_generator_factory: MotionGeneratorFactory | None = None, endpoint_adapters: ( Mapping[type[ResourceEndpoint], ResourceEndpointAdapter] | None ) = None, - settle_presets: Mapping[str, DynamicSettleMonitorCfg] | None = None, translation_threshold: float = 1.0e-4, rotation_threshold: float = 1.0e-3, contact_observer: BinaryObservationCallback | None = None, @@ -887,13 +873,11 @@ def from_environment( return cls( simulation, robot, - scene_binding, - robot_profile_binding, + registration, step_dt=step_dt, planner_cfg=planner_cfg, motion_generator_factory=motion_generator_factory, endpoint_adapters=endpoint_adapters, - settle_presets=settle_presets, translation_threshold=translation_threshold, rotation_threshold=rotation_threshold, contact_observer=contact_observer, @@ -933,7 +917,9 @@ def endpoint_adapters( def create_scene_registry(self) -> SceneRegistry: """Build one fresh authoritative registry from explicit bindings.""" - return self._scene_binding.build(self._simulation) + registry = self._scene_binding.build(self._simulation) + self._registration.validate_scene_registry(registry) + return registry def create_robot_skill_profile(self) -> RobotSkillProfile: """Build a profile whose every motion policy uses the Gym cadence.""" @@ -946,6 +932,7 @@ def create_robot_skill_profile(self) -> RobotSkillProfile: preset.motion_policy, control_dt=self._step_dt, ), + tracking_policy=preset.tracking_policy, recovery_policy=preset.recovery_policy, runner_cfg=preset.runner_cfg, effect_monitors=preset.effect_monitors, @@ -958,6 +945,10 @@ def create_robot_skill_profile(self) -> RobotSkillProfile: for preset in aligned.presets.values() ): raise AssertionError("Profile motion policies were not cadence-aligned.") + self._registration.validate_robot_profile( + aligned, + step_dt=self._step_dt, + ) return aligned def create_atomic_action_engine( @@ -977,11 +968,13 @@ def create_atomic_action_engine( raise ValueError( "Motion generator must own the exact robot selected by the factory." ) - return AtomicActionEngine( + engine = AtomicActionEngine( motion_generator, skill_profile=profile, endpoint_adapters=self._endpoint_adapters, ) + self._registration.catalog.validate_engine(engine) + return engine def create_planning_observation_provider( self, @@ -1089,24 +1082,22 @@ def create_accepted_runtime_command_observer( def create_adapter( self, *, - call_catalog: SemanticCallCatalog | None = None, registered_lowerers: Iterable[RegisteredSemanticLowerer] = (), - relation_grounders: Iterable[RelationTargetGrounder] = (), - handover_pose_providers: Iterable[HandOverPoseProvider] = (), effect_monitor_registry: EffectMonitorRegistry | None = None, runtime_transports: Iterable[RuntimeTransportActionEncoder] = (), runner_cfg: ExecutionRunnerCfg | None = None, parallel_safety_validator: ParallelCommandSafetyValidator | None = None, ) -> ExpertProgramEnvironmentAdapter: """Create the exact Gym adapter with shared simulation policy ports.""" + self._registration.assert_unchanged() return ExpertProgramEnvironmentAdapter( self, step_dt=self._step_dt, - call_catalog=call_catalog, + integration_catalog=self._registration.catalog, endpoint_adapters=self._endpoint_adapters, registered_lowerers=registered_lowerers, - relation_grounders=relation_grounders, - handover_pose_providers=handover_pose_providers, + relation_grounders=self._registration.relation_grounders, + handover_pose_providers=self._registration.handover_pose_providers, effect_monitor_registry=effect_monitor_registry, runtime_transports=runtime_transports, runner_cfg=runner_cfg, @@ -1136,17 +1127,13 @@ def _create_motion_generator(self) -> MotionGenerator: def create_simulation_expert_program_adapter( environment: SimulationExpertProgramEnvironment, *, - scene_binding: SimulationSceneBinding, - robot_profile_binding: SimulationRobotSkillProfileBinding, + registration: SimulationExpertProgramRegistration, planner_cfg: BasePlannerCfg | None = None, motion_generator_factory: MotionGeneratorFactory | None = None, endpoint_adapters: ( Mapping[type[ResourceEndpoint], ResourceEndpointAdapter] | None ) = None, - relation_grounders: Iterable[RelationTargetGrounder] = (), - handover_pose_providers: Iterable[HandOverPoseProvider] = (), runtime_transports: Iterable[RuntimeTransportActionEncoder] = (), - settle_presets: Mapping[str, DynamicSettleMonitorCfg] | None = None, translation_threshold: float = 1.0e-4, rotation_threshold: float = 1.0e-3, contact_observer: BinaryObservationCallback | None = None, @@ -1158,26 +1145,23 @@ def create_simulation_expert_program_adapter( """Create a complete production adapter from one standard Gym environment. This is the intended task-side one-line integration. Relation-target - grounders and embodiment-owned handover pose providers are explicit and - default to empty collections, so calls that require an uninstalled provider - remain fail-closed during program preflight. Advanced callers can retain - :class:`SimulationExpertProgramFactory` and call ``create_adapter`` directly - to install registered semantic lowerers or custom monitors. Custom endpoint - adapters and their matching Gym runtime transports are accepted here so a - non-joint endpoint remains executable through the one-line path. + grounders and embodiment-owned handover pose providers come exclusively + from ``registration``, so the statically fingerprinted objects are the exact + objects consumed by the runtime compiler. Calls that require an unregistered + provider remain fail-closed during program preflight. Advanced callers can + retain :class:`SimulationExpertProgramFactory` and call ``create_adapter`` + directly to install registered semantic lowerers or custom monitors. Custom + endpoint adapters and their matching Gym runtime transports are accepted + here so a non-joint endpoint remains executable through the one-line path. Args: environment: Standard Gym simulation environment exposing ``sim``, ``robot``, and ``step_dt``. - scene_binding: Authoritative typed scene declaration. - robot_profile_binding: Typed robot resource and policy declaration. + registration: Exact task registration used during static config loading. planner_cfg: Optional planner configuration owned by the factory. motion_generator_factory: Optional factory for one fresh motion generator. endpoint_adapters: Optional exact-type custom endpoint adapters. - relation_grounders: Explicit typed relation-target grounders. - handover_pose_providers: Explicit embodiment-owned handover pose providers. runtime_transports: Additional runtime-command-to-Gym encoders. - settle_presets: Optional named dynamic-settling policies. translation_threshold: Scene translation revision threshold. rotation_threshold: Scene rotation revision threshold. contact_observer: Optional raw contact evidence callback. @@ -1191,12 +1175,10 @@ def create_simulation_expert_program_adapter( """ factory = SimulationExpertProgramFactory.from_environment( environment, - scene_binding=scene_binding, - robot_profile_binding=robot_profile_binding, + registration=registration, planner_cfg=planner_cfg, motion_generator_factory=motion_generator_factory, endpoint_adapters=endpoint_adapters, - settle_presets=settle_presets, translation_threshold=translation_threshold, rotation_threshold=rotation_threshold, contact_observer=contact_observer, @@ -1205,8 +1187,6 @@ def create_simulation_expert_program_adapter( wrench_observer=wrench_observer, ) return factory.create_adapter( - relation_grounders=relation_grounders, - handover_pose_providers=handover_pose_providers, runtime_transports=runtime_transports, parallel_safety_validator=parallel_safety_validator, ) diff --git a/embodichain/lab/gym/envs/expert_program/simulation_policies.py b/embodichain/lab/gym/envs/expert_program/simulation_policies.py index c18dace2..408e7cac 100644 --- a/embodichain/lab/gym/envs/expert_program/simulation_policies.py +++ b/embodichain/lab/gym/envs/expert_program/simulation_policies.py @@ -62,7 +62,7 @@ class _SimulationSettleTarget: native_entity: Any -def _default_settle_presets() -> Mapping[str, DynamicSettleMonitorCfg]: +def default_simulation_settle_presets() -> Mapping[str, DynamicSettleMonitorCfg]: """Return independently owned built-in post-policy presets.""" return MappingProxyType( { @@ -133,7 +133,9 @@ def __init__( raise ValueError("env_ids must contain unique values.") selected_presets = ( - _default_settle_presets() if settle_presets is None else settle_presets + default_simulation_settle_presets() + if settle_presets is None + else settle_presets ) if not isinstance(selected_presets, Mapping) or not selected_presets: raise ValueError("settle_presets must be a non-empty mapping.") @@ -712,4 +714,4 @@ def _read_pose(self, entity: Any, *, entity_id: str) -> torch.Tensor: return pose.clone() -__all__ = ["SimulationSegmentPolicyPort"] +__all__ = ["SimulationSegmentPolicyPort", "default_simulation_settle_presets"] diff --git a/embodichain/lab/gym/utils/gym_utils.py b/embodichain/lab/gym/utils/gym_utils.py index e524b676..c3df4bba 100644 --- a/embodichain/lab/gym/utils/gym_utils.py +++ b/embodichain/lab/gym/utils/gym_utils.py @@ -399,6 +399,7 @@ def config_to_cfg( manager_modules: list | None = None, *, source_path: str | os.PathLike[str] | None = None, + expert_program_path_override: str | os.PathLike[str] | None = None, ) -> "EmbodiedEnvCfg": """Parser configuration file into cfgs for env initialization. @@ -410,6 +411,9 @@ def config_to_cfg( relative top-level ``expert_program_path`` is resolved from this file's directory. Without it, relative paths use the current working directory. + expert_program_path_override: Optional explicit program path. This is + selected instead of the Gym-config path and resolves from the + process working directory. Returns: EmbodiedEnvCfg: A configuration object for initializing the environment. @@ -456,13 +460,23 @@ class ComponentCfg: if key not in config: log_error(f"Missing required config key: {key}") - if "expert_program_path" in config: - expert_program_path = config["expert_program_path"] - if type(expert_program_path) is not str: - raise TypeError("expert_program_path must be an exact string.") - if ( - not expert_program_path - or expert_program_path != expert_program_path.strip() + configured_expert_program_path = config.get("expert_program_path") + if expert_program_path_override is not None or "expert_program_path" in config: + if expert_program_path_override is not None: + expert_program_path = expert_program_path_override + expert_program_base_dir = None + if not isinstance(expert_program_path, (str, os.PathLike)): + raise TypeError("expert_program_path must be a string or path.") + else: + expert_program_path = configured_expert_program_path + expert_program_base_dir = ( + None if source_path is None else Path(source_path).expanduser().parent + ) + if type(expert_program_path) is not str: + raise TypeError("expert_program_path must be an exact string.") + expert_program_path_text = os.fspath(expert_program_path) + if not expert_program_path_text or ( + expert_program_path_text != expert_program_path_text.strip() ): raise ValueError( "expert_program_path must be a non-empty string without outer " @@ -471,14 +485,23 @@ class ComponentCfg: from embodichain.lab.gym.envs.expert_program.loader import ( load_expert_program, ) + from embodichain.lab.gym.utils.registration import get_env_spec - expert_program_base_dir = ( - None if source_path is None else Path(source_path).expanduser().parent - ) - env_cfg.expert_program = load_expert_program( - expert_program_path, + env_spec = get_env_spec(config["id"]) + registration = env_spec.expert_program_registration + if registration is None: + raise ValueError( + f"Environment {config['id']!r} does not register an Expert " + "Program integration catalog." + ) + registration.assert_unchanged() + expert_program = load_expert_program( + expert_program_path_text, base_dir=expert_program_base_dir, + validation_context=registration.catalog, ) + registration.catalog.preflight(expert_program) + env_cfg.expert_program = expert_program env_cfg.max_episode_steps = config.get("max_episode_steps", 300) env_cfg.num_envs = config.get("num_envs", 1) @@ -1069,6 +1092,7 @@ def build_env_cfg_from_args( gym_config, manager_modules=get_manager_modules(), source_path=gym_config_source_path, + expert_program_path_override=getattr(args, "expert_program", None), ) cfg.filter_visual_rand = args.filter_visual_rand cfg.filter_dataset_saving = args.filter_dataset_saving diff --git a/embodichain/lab/gym/utils/registration.py b/embodichain/lab/gym/utils/registration.py index 2fce236a..d571317a 100644 --- a/embodichain/lab/gym/utils/registration.py +++ b/embodichain/lab/gym/utils/registration.py @@ -37,6 +37,9 @@ if TYPE_CHECKING: from embodichain.lab.gym.envs import BaseEnv, EmbodiedEnvCfg + from embodichain.lab.gym.envs.expert_program import ( + SimulationExpertProgramRegistration, + ) _logger = logging.getLogger(__name__) @@ -48,12 +51,27 @@ def __init__( cls: Type[BaseEnv], max_episode_steps=None, default_kwargs: dict = None, + expert_program_registration: SimulationExpertProgramRegistration | None = None, ): """A specification for a Embodied environment.""" + if expert_program_registration is not None: + from embodichain.lab.gym.envs.expert_program import ( + SimulationExpertProgramRegistration, + ) + + if ( + type(expert_program_registration) + is not SimulationExpertProgramRegistration + ): + raise TypeError( + "expert_program_registration must be exactly " + "SimulationExpertProgramRegistration or None." + ) self.uid = uid self.cls = cls self.max_episode_steps = max_episode_steps self.default_kwargs = {} if default_kwargs is None else default_kwargs + self.expert_program_registration = expert_program_registration def make(self, **kwargs): _kwargs = self.default_kwargs.copy() @@ -76,7 +94,11 @@ def gym_spec(self): def register( - name: str, cls: Type[BaseEnv], max_episode_steps=None, default_kwargs: dict = None + name: str, + cls: Type[BaseEnv], + max_episode_steps=None, + default_kwargs: dict = None, + expert_program_registration: SimulationExpertProgramRegistration | None = None, ): """Register a Embodied environment.""" @@ -88,7 +110,11 @@ def register( if not (issubclass(cls, BaseEnv) or issubclass(cls, BaseEnv)): raise TypeError(f"Env {name} must inherit from BaseEnv or BaseEnv") REGISTERED_ENVS[name] = EnvSpec( - name, cls, max_episode_steps=max_episode_steps, default_kwargs=default_kwargs + name, + cls, + max_episode_steps=max_episode_steps, + default_kwargs=default_kwargs, + expert_program_registration=expert_program_registration, ) @@ -146,6 +172,16 @@ def make(env_id, **kwargs): return env +def get_env_spec(env_id: str) -> EnvSpec: + """Return one registered environment specification or fail closed.""" + if type(env_id) is not str or not env_id or env_id != env_id.strip(): + raise ValueError("env_id must be a non-empty string without outer whitespace.") + try: + return REGISTERED_ENVS[env_id] + except KeyError as exc: + raise KeyError(f"Env {env_id!r} not found in registry.") from exc + + def build_env(env_id: str, base_env_cfg: EmbodiedEnvCfg): """Create an environment from a registered env id. @@ -172,7 +208,14 @@ def make_vec(env_id, **kwargs): return env -def register_env(uid: str, max_episode_steps=None, override=False, **kwargs): +def register_env( + uid: str, + max_episode_steps=None, + override=False, + *, + expert_program_registration: SimulationExpertProgramRegistration | None = None, + **kwargs, +): """A decorator to register Embodied environments. Args: @@ -193,13 +236,28 @@ def register_env(uid: str, max_episode_steps=None, override=False, **kwargs): ) def _register_env(cls): - cls = register_env_function(cls, uid, override, max_episode_steps, **kwargs) + cls = register_env_function( + cls, + uid, + override, + max_episode_steps, + expert_program_registration=expert_program_registration, + **kwargs, + ) return cls return _register_env -def register_env_function(cls, uid, override=False, max_episode_steps=None, **kwargs): +def register_env_function( + cls, + uid, + override=False, + max_episode_steps=None, + *, + expert_program_registration: SimulationExpertProgramRegistration | None = None, + **kwargs, +): if uid in REGISTERED_ENVS: if override: from gymnasium.envs.registration import registry @@ -216,6 +274,7 @@ def register_env_function(cls, uid, override=False, max_episode_steps=None, **kw cls, max_episode_steps=max_episode_steps, default_kwargs=deepcopy(kwargs), + expert_program_registration=expert_program_registration, ) # Register for gym diff --git a/embodichain/lab/scripts/run_env.py b/embodichain/lab/scripts/run_env.py index 8c99f098..78cce472 100644 --- a/embodichain/lab/scripts/run_env.py +++ b/embodichain/lab/scripts/run_env.py @@ -32,9 +32,6 @@ import tqdm from embodichain.lab.gym.envs.demo import DemoEpisodeResult, execute_demo_episode -from embodichain.lab.gym.envs.expert_program.loader import ( - load_expert_program as _load_expert_program, -) from embodichain.lab.gym.envs.wrapper import ReplayWrapper from embodichain.lab.gym.utils.gym_utils import ( add_env_launcher_args_to_parser, @@ -858,9 +855,6 @@ def cli(argv: Sequence[str] | None = None) -> None: execute_init_hooks() env_cfg, gym_config, action_config = build_env_cfg_from_args(args) - expert_program_path = getattr(args, "expert_program", None) - if expert_program_path is not None: - env_cfg.expert_program = _load_expert_program(expert_program_path) if args.replay and args.replay_mode == "control": log_info("Dataset saving disabled for control replay mode.", color="green") diff --git a/embodichain_tasks/configs/gym/multi_segments/cube_pick_place.json b/embodichain_tasks/configs/gym/multi_segments/cube_pick_place.json index 32cf1551..cbb4ba14 100644 --- a/embodichain_tasks/configs/gym/multi_segments/cube_pick_place.json +++ b/embodichain_tasks/configs/gym/multi_segments/cube_pick_place.json @@ -50,10 +50,7 @@ } } }, - "extensions": { - "grasp_samples": 10000, - "force_reannotate": false - } + "extensions": {} }, "robot": { "class_type": "URRobot", diff --git a/embodichain_tasks/embodichain_tasks/multi_segments/cube_pick_place.py b/embodichain_tasks/embodichain_tasks/multi_segments/cube_pick_place.py index 6965c6f9..1a048fd4 100644 --- a/embodichain_tasks/embodichain_tasks/multi_segments/cube_pick_place.py +++ b/embodichain_tasks/embodichain_tasks/multi_segments/cube_pick_place.py @@ -41,6 +41,7 @@ ExpertProgramEnvironmentAdapter, ExpertProgramEnvironmentMixin, SimulationRigidObjectBinding, + SimulationExpertProgramRegistration, SimulationRobotSkillProfileBinding, SimulationSceneBinding, create_simulation_expert_program_adapter, @@ -53,6 +54,7 @@ FORWARD_KINEMATICS_CAPABILITY, GRASP_CAPABILITY, RecoveryPolicy, + TrackingPolicy, ) from embodichain.lab.sim.cfg import ( LightCfg, @@ -72,6 +74,7 @@ __all__ = [ "MultiSegmentsCubePickPlaceEnv", + "CUBE_EXPERT_PROGRAM_REGISTRATION", "create_cube_robot_profile_binding", "create_cube_scene_binding", ] @@ -133,7 +136,12 @@ def _create_default_robot_cfg() -> URRobotCfg: def _load_default_expert_program() -> ExpertProgramCfg: """Decode the packaged semantic program for direct instantiation.""" - return load_expert_program(get_config_path(CUBE_EXPERT_PROGRAM_PATH)) + program = load_expert_program( + get_config_path(CUBE_EXPERT_PROGRAM_PATH), + validation_context=CUBE_EXPERT_PROGRAM_REGISTRATION.catalog, + ) + CUBE_EXPERT_PROGRAM_REGISTRATION.catalog.preflight(program) + return program def _create_default_env_cfg() -> EmbodiedEnvCfg: @@ -167,10 +175,7 @@ def _create_default_env_cfg() -> EmbodiedEnvCfg: init_pos=(-0.42, -0.08, 0.5 * CUBE_SIZE), ) ] - cfg.extensions = { - "grasp_samples": 10000, - "force_reannotate": False, - } + cfg.extensions = {} cfg.events = { "settle_cube_on_reset": EventCfg( func=wait_for_dynamic_objects_to_settle, @@ -289,14 +294,28 @@ def create_cube_robot_profile_binding() -> SimulationRobotSkillProfileBinding: presets=( SkillPolicyPreset( "safe", - recovery_policy=RecoveryPolicy(tracking_error_threshold=0.08), + recovery_policy=RecoveryPolicy(), + tracking_policy=TrackingPolicy.joint_position( + in_flight_max_abs_error=0.08, + terminal_max_abs_error=0.08, + ), ), ), default_preset="safe", ) -@register_env("MultiSegmentsCubePickPlace-v1", max_episode_steps=1200) +CUBE_EXPERT_PROGRAM_REGISTRATION = SimulationExpertProgramRegistration( + scene_binding=create_cube_scene_binding(), + robot_profile_binding=create_cube_robot_profile_binding(), +) + + +@register_env( + "MultiSegmentsCubePickPlace-v1", + max_episode_steps=1200, + expert_program_registration=CUBE_EXPERT_PROGRAM_REGISTRATION, +) class MultiSegmentsCubePickPlaceEnv(ExpertProgramEnvironmentMixin, EmbodiedEnv): """Repeatedly pick and place a cube from a semantic config program.""" @@ -307,11 +326,7 @@ def __init__(self, cfg: EmbodiedEnvCfg | None = None, **kwargs: Any) -> None: super().__init__(cfg, **kwargs) self._expert_program_adapter = create_simulation_expert_program_adapter( self, - scene_binding=create_cube_scene_binding( - grasp_samples=getattr(self, "grasp_samples", 10000), - force_reannotate=getattr(self, "force_reannotate", False), - ), - robot_profile_binding=create_cube_robot_profile_binding(), + registration=CUBE_EXPERT_PROGRAM_REGISTRATION, ) @property diff --git a/embodichain_tasks/embodichain_tasks/tableware/open_drawer.py b/embodichain_tasks/embodichain_tasks/tableware/open_drawer.py index ff1166c6..2661ac88 100644 --- a/embodichain_tasks/embodichain_tasks/tableware/open_drawer.py +++ b/embodichain_tasks/embodichain_tasks/tableware/open_drawer.py @@ -36,6 +36,7 @@ ExpertProgramEnvironmentMixin, SimulationArticulationBinding, SimulationArticulationLinkBinding, + SimulationExpertProgramRegistration, SimulationRobotSkillProfileBinding, SimulationSceneBinding, create_simulation_expert_program_adapter, @@ -51,6 +52,7 @@ __all__ = [ "OpenDrawerEnv", + "OPEN_DRAWER_EXPERT_PROGRAM_REGISTRATION", "create_open_drawer_robot_profile_binding", "create_open_drawer_scene_binding", ] @@ -204,7 +206,17 @@ def create_open_drawer_robot_profile_binding() -> SimulationRobotSkillProfileBin ) -@register_env("OpenDrawer-v1", max_episode_steps=300) +OPEN_DRAWER_EXPERT_PROGRAM_REGISTRATION = SimulationExpertProgramRegistration( + scene_binding=create_open_drawer_scene_binding(), + robot_profile_binding=create_open_drawer_robot_profile_binding(), +) + + +@register_env( + "OpenDrawer-v1", + max_episode_steps=300, + expert_program_registration=OPEN_DRAWER_EXPERT_PROGRAM_REGISTRATION, +) class OpenDrawerEnv(ExpertProgramEnvironmentMixin, EmbodiedEnv): """Open a drawer through a configured semantic Expert Program.""" @@ -213,8 +225,7 @@ def __init__(self, cfg: EmbodiedEnvCfg, **kwargs: Any) -> None: super().__init__(cfg, **kwargs) self._expert_program_adapter = create_simulation_expert_program_adapter( self, - scene_binding=create_open_drawer_scene_binding(), - robot_profile_binding=create_open_drawer_robot_profile_binding(), + registration=OPEN_DRAWER_EXPERT_PROGRAM_REGISTRATION, ) @property diff --git a/tests/gym/envs/expert_program/test_catalog.py b/tests/gym/envs/expert_program/test_catalog.py new file mode 100644 index 00000000..a63cc15c --- /dev/null +++ b/tests/gym/envs/expert_program/test_catalog.py @@ -0,0 +1,596 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Tests for task-registration-owned Expert Program integration catalogs.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import ClassVar + +import pytest + +from embodichain.lab.gym.envs.expert_program import ( + ExpertProgramIntegrationCatalog, + ExpertProgramValidationError, + IntegrationFingerprintMismatch, + SimulationArticulationLinkBinding, + SimulationExpertProgramRegistration, + SimulationSceneBinding, + decode_expert_program, +) +from embodichain.lab.gym.utils.registration import EnvSpec +from embodichain.lab.sim.atomic_actions import Affordance, PlanningContext +from embodichain.lab.sim.skills import ( + PLACE_ON_AFFORDANCE_CAPABILITY, + BoundSemanticCall, + HandOver, + HandOverPoseProvider, + HandOverPoseTargets, + OperateArticulation, + RelationTargetGrounder, + SemanticCallCatalog, + SceneAffordanceRef, + SceneEntityManifest, + SceneManifest, + SceneObjectRef, + SemanticRelationTarget, + builtin_semantic_call_catalog, +) +from embodichain_tasks.multi_segments.cube_pick_place import ( + CUBE_ROBOT_PROFILE_ID, + CUBE_SCENE_REGISTRY_ID, + create_cube_robot_profile_binding, + create_cube_scene_binding, +) +from embodichain_tasks.tableware.open_drawer import ( + DRAWER_HANDLE_AFFORDANCE_ID, + DRAWER_ROBOT_PROFILE_ID, + DRAWER_SCENE_REGISTRY_ID, + DRAWER_UID, + OPEN_DRAWER_EXPERT_PROGRAM_REGISTRATION, +) + + +class _CatalogRelationGrounder(RelationTargetGrounder): + """Typed relation-grounder sentinel for registration validation.""" + + capability: ClassVar[str] = "test.catalog_relation" + affordance_type: ClassVar[type[Affordance]] = Affordance + affordance_revision: ClassVar[str] = "test-v1" + + def ground( + self, + relation: SemanticRelationTarget, + *, + affordance: Affordance, + context: PlanningContext, + ) -> object: + """Remain unreachable in provider-free catalog tests.""" + del relation, affordance, context + raise AssertionError("Catalog tests must not execute live providers.") + + +class _CatalogPlaceAffordance(Affordance): + """Typed provider-free payload marker for relation-linking tests.""" + + +@dataclass(frozen=True, slots=True) +class _CatalogHandOverPoseProvider(HandOverPoseProvider): + """Frozen declaration used to prove malicious drift detection.""" + + provider_id: ClassVar[str] = "test.catalog_handover" + transfer_height: float + + def resolve( + self, + call: HandOver, + *, + context: PlanningContext, + bound: BoundSemanticCall, + ) -> HandOverPoseTargets: + """Remain unreachable in provider-free catalog tests.""" + del call, context, bound + raise AssertionError("Catalog tests must not execute live providers.") + + +class _SecondCatalogRelationGrounder(_CatalogRelationGrounder): + """Second stateless grounder used for ordering regressions.""" + + capability: ClassVar[str] = "test.catalog_relation.second" + affordance_revision: ClassVar[str] = "test-v2" + + +class _SecondCatalogHandOverPoseProvider(HandOverPoseProvider): + """Second stateless hand-over provider used for ordering regressions.""" + + provider_id: ClassVar[str] = "test.catalog_handover.second" + + def resolve( + self, + call: HandOver, + *, + context: PlanningContext, + bound: BoundSemanticCall, + ) -> HandOverPoseTargets: + """Remain unreachable in provider-free catalog tests.""" + del call, context, bound + raise AssertionError("Catalog tests must not execute live providers.") + + +class _StatefulCatalogRelationGrounder(_CatalogRelationGrounder): + """Invalid non-dataclass provider with public instance state.""" + + capability: ClassVar[str] = "test.catalog_relation.stateful" + + def __init__(self) -> None: + self.height = 0.5 + + +class _PrivateSlotHandOverPoseProvider(HandOverPoseProvider): + """Invalid provider whose state is hidden behind a mangled slot name.""" + + __slots__ = ("__height",) + + provider_id: ClassVar[str] = "test.catalog_handover.private_slot" + + def __init__(self) -> None: + self.__height = 0.5 + + def resolve( + self, + call: HandOver, + *, + context: PlanningContext, + bound: BoundSemanticCall, + ) -> HandOverPoseTargets: + """Remain unreachable because registration rejects this provider.""" + del call, context, bound + raise AssertionError("Rejected providers must never execute.") + + +class _InheritedCachedHandOverPoseProvider(_CatalogHandOverPoseProvider): + """Invalid non-dataclass subclass adding state to a frozen declaration.""" + + __slots__ = ("cache",) + + provider_id: ClassVar[str] = "test.catalog_handover.inherited_cache" + + def __init__(self) -> None: + super().__init__(transfer_height=0.5) + object.__setattr__(self, "cache", {}) + + +def _program_payload( + *, + scene_registry: str = CUBE_SCENE_REGISTRY_ID, + runtime_preset: str = "safe", + object_id: str = "cube", +) -> dict[str, object]: + """Return one minimal catalog-linked program payload.""" + return { + "schema_version": 1, + "program_id": "catalog_pick", + "integration": { + "robot_profile": CUBE_ROBOT_PROFILE_ID, + "scene_registry": scene_registry, + "runtime_preset": runtime_preset, + }, + "targets": {}, + "program": { + "kind": "invoke", + "call": {"kind": "pick", "object": object_id}, + }, + } + + +def _registration() -> SimulationExpertProgramRegistration: + """Build one isolated provider-free task registration.""" + return SimulationExpertProgramRegistration( + scene_binding=create_cube_scene_binding(grasp_samples=32), + robot_profile_binding=create_cube_robot_profile_binding(), + ) + + +def _operate_articulation_payload( + *, + target: str, + handle: str | None = None, +) -> dict[str, object]: + """Return one named drawer-operation program with an optional handle.""" + call: dict[str, object] = { + "kind": "operate_articulation", + "articulation": DRAWER_UID, + "target": target, + } + if handle is not None: + call["handle"] = handle + return { + "schema_version": 1, + "program_id": "catalog_open_drawer", + "integration": { + "robot_profile": DRAWER_ROBOT_PROFILE_ID, + "scene_registry": DRAWER_SCENE_REGISTRY_ID, + "runtime_preset": "safe", + }, + "targets": {}, + "program": {"kind": "invoke", "call": call}, + } + + +def _place_relation_catalog( + *, + install_grounder_key: bool, +) -> ExpertProgramIntegrationCatalog: + """Build one provider-free placement catalog with an optional grounder key.""" + base = _registration().catalog + support_ref = SceneObjectRef("support") + affordance_ref = SceneAffordanceRef("support_top") + scene = SceneManifest( + ( + SceneEntityManifest(ref=SceneObjectRef("cube")), + SceneEntityManifest( + ref=support_ref, + default_affordances={ + PLACE_ON_AFFORDANCE_CAPABILITY: affordance_ref, + }, + ), + SceneEntityManifest( + ref=affordance_ref, + parent=support_ref, + native_name="support_top_surface", + affordance_capabilities=frozenset({PLACE_ON_AFFORDANCE_CAPABILITY}), + affordance_payload_type=_CatalogPlaceAffordance, + affordance_revision="test-v1", + ), + ) + ) + grounder_keys = ( + frozenset( + { + ( + PLACE_ON_AFFORDANCE_CAPABILITY, + _CatalogPlaceAffordance, + "test-v1", + ) + } + ) + if install_grounder_key + else frozenset() + ) + return ExpertProgramIntegrationCatalog( + scene_registry_id="relation_scene", + robot_profile_id=base.robot_profile_id, + scene=scene, + robot_profile=base.robot_profile, + call_catalog=base.call_catalog, + relation_grounder_keys=grounder_keys, + articulation_operation_targets={}, + settle_preset_ids=base.settle_preset_ids, + fingerprint="0" * 64, + _required_skills={}, + ) + + +def _place_relation_payload() -> dict[str, object]: + """Return one Place(on=object) program requiring relation grounding.""" + return { + "schema_version": 1, + "program_id": "catalog_place_relation", + "integration": { + "robot_profile": CUBE_ROBOT_PROFILE_ID, + "scene_registry": "relation_scene", + "runtime_preset": "safe", + }, + "targets": {}, + "program": { + "kind": "invoke", + "call": { + "kind": "place", + "object": "cube", + "on": "support", + }, + }, + } + + +def test_catalog_decodes_compiles_and_links_without_simulation() -> None: + """All external references are linked before a simulation is available.""" + registration = _registration() + + program = decode_expert_program( + _program_payload(), + validation_context=registration.catalog, + ) + compiled = registration.catalog.preflight(program) + + assert tuple(compiled.iter_segments())[0].calls[0].call.semantic_id == "pick" + + +@pytest.mark.parametrize("validation_stage", ("decode", "preflight")) +def test_catalog_rejects_unknown_named_articulation_target_at_exact_path( + validation_stage: str, +) -> None: + """Unknown provider-owned target IDs fail before simulation startup.""" + catalog = OPEN_DRAWER_EXPERT_PROGRAM_REGISTRATION.catalog + payload = _operate_articulation_payload(target="does_not_exist") + + with pytest.raises(ExpertProgramValidationError) as error: + if validation_stage == "decode": + decode_expert_program(payload, validation_context=catalog) + else: + catalog.preflight(decode_expert_program(payload)) + + assert error.value.code == "unknown_articulation_operation_target" + assert error.value.path == ("program", "call", "target") + + +@pytest.mark.parametrize("handle", (None, DRAWER_HANDLE_AFFORDANCE_ID)) +def test_catalog_accepts_named_target_through_default_or_explicit_affordance( + handle: str | None, +) -> None: + """Both handle-selection forms resolve the same registered target table.""" + catalog = OPEN_DRAWER_EXPERT_PROGRAM_REGISTRATION.catalog + program = decode_expert_program( + _operate_articulation_payload(target="open", handle=handle), + validation_context=catalog, + ) + + compiled = catalog.preflight(program) + + call = tuple(compiled.iter_segments())[0].calls[0].call + assert type(call) is OperateArticulation + assert call.target == "open" + + +def test_catalog_owns_immutable_articulation_operation_target_metadata() -> None: + """Named target IDs are a read-only task-registration catalog surface.""" + targets = ( + OPEN_DRAWER_EXPERT_PROGRAM_REGISTRATION.catalog.articulation_operation_targets + ) + + assert targets == {DRAWER_HANDLE_AFFORDANCE_ID: frozenset({"open"})} + with pytest.raises(TypeError): + targets[DRAWER_HANDLE_AFFORDANCE_ID] = frozenset() # type: ignore[index] + + +def test_catalog_rejects_linked_place_relation_without_exact_grounder() -> None: + """A linked affordance cannot defer a missing typed grounder to runtime.""" + catalog = _place_relation_catalog(install_grounder_key=False) + program = decode_expert_program( + _place_relation_payload(), + validation_context=catalog, + ) + + with pytest.raises(ExpertProgramValidationError) as error: + catalog.preflight(program) + + assert error.value.code == "relation_grounder_not_registered" + assert error.value.path == ("program", "call", "on") + + +def test_catalog_accepts_linked_place_relation_with_exact_grounder_key() -> None: + """The capability, payload type, and revision must all match exactly.""" + catalog = _place_relation_catalog(install_grounder_key=True) + program = decode_expert_program( + _place_relation_payload(), + validation_context=catalog, + ) + + compiled = catalog.preflight(program) + + assert tuple(compiled.iter_segments())[0].calls[0].call.semantic_id == "place" + + +@pytest.mark.parametrize( + ("overrides", "path"), + ( + ({"scene_registry": "other_scene"}, ("integration",)), + ({"runtime_preset": "unknown"}, ("integration",)), + ({"object_id": "unknown_object"}, ("program", "call", "object")), + ), +) +def test_catalog_rejects_unknown_references_at_decode_time( + overrides: dict[str, str], + path: tuple[str, ...], +) -> None: + """Invalid task integration references retain exact config paths.""" + registration = _registration() + + with pytest.raises(ExpertProgramValidationError) as error: + decode_expert_program( + _program_payload(**overrides), + validation_context=registration.catalog, + ) + + assert error.value.path == path + + +def test_scene_declare_rejects_orphan_link_without_simulation() -> None: + """Canonical topology failures do not reach native entity lookup.""" + binding = SimulationSceneBinding( + registry_id="orphan_scene", + links=( + SimulationArticulationLinkBinding( + entity_id="handle", + articulation_id="missing_drawer", + native_link_name="handle_link", + ), + ), + ) + + with pytest.raises(KeyError, match="missing_drawer"): + binding.declare() + + +def test_fingerprint_is_stable_for_equivalent_declarations() -> None: + """Fresh equivalent registrations produce the same canonical digest.""" + left = _registration() + right = _registration() + + assert left.fingerprint == right.fingerprint + assert len(left.fingerprint) == 64 + + +def test_fingerprint_is_independent_of_catalog_and_provider_insertion_order() -> None: + """Semantically equivalent unordered registration inputs hash identically.""" + descriptors = tuple(builtin_semantic_call_catalog().descriptors.values()) + first_relation = _CatalogRelationGrounder() + second_relation = _SecondCatalogRelationGrounder() + first_handover = _CatalogHandOverPoseProvider(transfer_height=0.6) + second_handover = _SecondCatalogHandOverPoseProvider() + common = { + "scene_binding": create_cube_scene_binding(grasp_samples=32), + "robot_profile_binding": create_cube_robot_profile_binding(), + } + forward = SimulationExpertProgramRegistration( + **common, + call_catalog=SemanticCallCatalog(descriptors), + relation_grounders=(first_relation, second_relation), + handover_pose_providers=(first_handover, second_handover), + ) + reversed_registration = SimulationExpertProgramRegistration( + **common, + call_catalog=SemanticCallCatalog(tuple(reversed(descriptors))), + relation_grounders=(second_relation, first_relation), + handover_pose_providers=(second_handover, first_handover), + ) + + assert forward.fingerprint == reversed_registration.fingerprint + + +def test_fingerprint_owns_provider_ids_and_declarative_fields() -> None: + """Provider identity and dataclass configuration are registration data.""" + provider = _CatalogHandOverPoseProvider(transfer_height=0.6) + registration = SimulationExpertProgramRegistration( + scene_binding=create_cube_scene_binding(grasp_samples=32), + robot_profile_binding=create_cube_robot_profile_binding(), + relation_grounders=(_CatalogRelationGrounder(),), + handover_pose_providers=(provider,), + ) + changed_value = SimulationExpertProgramRegistration( + scene_binding=create_cube_scene_binding(grasp_samples=32), + robot_profile_binding=create_cube_robot_profile_binding(), + relation_grounders=(_CatalogRelationGrounder(),), + handover_pose_providers=(_CatalogHandOverPoseProvider(transfer_height=0.7),), + ) + + assert registration.handover_pose_providers == (provider,) + assert registration.fingerprint != changed_value.fingerprint + object.__setattr__(provider, "transfer_height", 0.8) + with pytest.raises(IntegrationFingerprintMismatch, match="changed"): + registration.assert_unchanged() + + +def test_registration_rejects_duplicate_provider_keys_and_ids() -> None: + """Provider lookup tables remain unambiguous before simulation startup.""" + common = { + "scene_binding": create_cube_scene_binding(grasp_samples=32), + "robot_profile_binding": create_cube_robot_profile_binding(), + } + + with pytest.raises(ValueError, match="Duplicate relation grounder key"): + SimulationExpertProgramRegistration( + **common, + relation_grounders=( + _CatalogRelationGrounder(), + _CatalogRelationGrounder(), + ), + ) + with pytest.raises(ValueError, match="Duplicate handover pose provider"): + SimulationExpertProgramRegistration( + **common, + handover_pose_providers=( + _CatalogHandOverPoseProvider(transfer_height=0.6), + _CatalogHandOverPoseProvider(transfer_height=0.7), + ), + ) + + +def test_registration_requires_immutable_provider_tuples() -> None: + """Mutable provider containers cannot enter task registration metadata.""" + with pytest.raises(TypeError, match="relation_grounders must be an exact tuple"): + SimulationExpertProgramRegistration( + scene_binding=create_cube_scene_binding(grasp_samples=32), + robot_profile_binding=create_cube_robot_profile_binding(), + relation_grounders=[_CatalogRelationGrounder()], # type: ignore[arg-type] + ) + with pytest.raises( + TypeError, + match="handover_pose_providers must be an exact tuple", + ): + SimulationExpertProgramRegistration( + scene_binding=create_cube_scene_binding(grasp_samples=32), + robot_profile_binding=create_cube_robot_profile_binding(), + handover_pose_providers=[ # type: ignore[arg-type] + _CatalogHandOverPoseProvider(transfer_height=0.6) + ], + ) + + +@pytest.mark.parametrize( + ("field_name", "provider"), + ( + ("relation_grounders", _StatefulCatalogRelationGrounder()), + ("handover_pose_providers", _PrivateSlotHandOverPoseProvider()), + ( + "handover_pose_providers", + _InheritedCachedHandOverPoseProvider(), + ), + ), +) +def test_registration_rejects_stateful_non_dataclass_providers( + field_name: str, + provider: object, +) -> None: + """Public and name-mangled provider state cannot evade fingerprinting.""" + kwargs = {field_name: (provider,)} + + with pytest.raises(TypeError, match="Use a frozen dataclass"): + SimulationExpertProgramRegistration( + scene_binding=create_cube_scene_binding(grasp_samples=32), + robot_profile_binding=create_cube_robot_profile_binding(), + **kwargs, + ) + + +def test_nested_declaration_drift_is_detected_before_live_build() -> None: + """Mutable nested config cannot silently change a registered binding.""" + registration = _registration() + generator_cfg = registration.scene_binding.antipodal_grasps[0].generator_cfg + assert generator_cfg is not None + generator_cfg.antipodal_sampler_cfg.n_sample = 64 + + with pytest.raises(IntegrationFingerprintMismatch, match="changed"): + registration.assert_unchanged() + + +def test_env_spec_keeps_typed_registration_out_of_gym_kwargs() -> None: + """The integration catalog is metadata, not a duplicated Gym config source.""" + + class _Environment: + pass + + registration = _registration() + spec = EnvSpec( + "CatalogTest-v1", + _Environment, + default_kwargs={"physical_option": 3}, + expert_program_registration=registration, + ) + + assert spec.expert_program_registration is registration + assert spec.gym_spec.kwargs == {"physical_option": 3} diff --git a/tests/gym/envs/expert_program/test_simulation_environment.py b/tests/gym/envs/expert_program/test_simulation_environment.py index 119ecca3..c52e18b8 100644 --- a/tests/gym/envs/expert_program/test_simulation_environment.py +++ b/tests/gym/envs/expert_program/test_simulation_environment.py @@ -48,6 +48,7 @@ InvokeCfg, RobotResourceBinding, SharedTickSceneProvider, + SimulationExpertProgramRegistration, SimulationExpertProgramFactory, SimulationPlanningObservationProvider, SimulationRigidObjectBinding, @@ -72,6 +73,7 @@ PlanningContext, StateDelta, TaskState, + TrackingPolicy, ) from embodichain.lab.sim.atomic_actions.runner import ExecutionRunnerCfg from embodichain.lab.sim.atomic_actions.bindings import JointPositionTarget @@ -102,7 +104,6 @@ SemanticObjectTarget, SemanticPose, SemanticRelationTarget, - SemanticValidationError, SkillPolicyPreset, ) from embodichain.lab.sim.skills.effects import ( @@ -685,9 +686,6 @@ class _ForwardedHandOverPoseProvider(HandOverPoseProvider): provider_id: ClassVar[str] = "test.handover_pose" - def __init__(self) -> None: - self.calls = 0 - def resolve( self, call: HandOver, @@ -697,7 +695,6 @@ def resolve( ) -> HandOverPoseTargets: """Return owned direct targets without embedding task-side motion code.""" del call, context, bound - self.calls += 1 pose = SemanticPose( position=(0.0, 0.0, 0.5), quaternion_wxyz=(1.0, 0.0, 0.0, 0.0), @@ -848,6 +845,10 @@ def _profile_binding() -> SimulationRobotSkillProfileBinding: SkillPolicyPreset( "safe", motion_policy=MotionPolicy(control_dt=0.01), + tracking_policy=TrackingPolicy.joint_position( + in_flight_max_abs_error=0.037, + terminal_max_abs_error=0.019, + ), ), ), default_preset="safe", @@ -977,8 +978,10 @@ def _factory() -> tuple[SimulationExpertProgramFactory, _Robot]: SimulationExpertProgramFactory( simulation, # type: ignore[arg-type] robot, # type: ignore[arg-type] - SimulationSceneBinding(registry_id="scene"), - _profile_binding(), + SimulationExpertProgramRegistration( + scene_binding=SimulationSceneBinding(registry_id="scene"), + robot_profile_binding=_profile_binding(), + ), step_dt=_STEP_DT, motion_generator_factory=lambda: _motion_generator(robot), ), @@ -1114,8 +1117,10 @@ def _evidence_adapter_runtime() -> tuple[ factory = SimulationExpertProgramFactory( simulation, # type: ignore[arg-type] robot, # type: ignore[arg-type] - scene_binding, - _evidence_profile_binding(), + SimulationExpertProgramRegistration( + scene_binding=scene_binding, + robot_profile_binding=_evidence_profile_binding(), + ), step_dt=_STEP_DT, motion_generator_factory=lambda: _motion_generator(robot), ) @@ -1525,12 +1530,16 @@ def _assert_invocation_equivalent( def test_simulation_factory_aligns_every_motion_policy_to_gym_step() -> None: - """The environment cadence replaces unrelated preset fallback timing.""" + """Cadence alignment preserves the exact registered tracking contract.""" factory, _ = _factory() profile = factory.create_robot_skill_profile() assert profile.presets["safe"].motion_policy.control_dt == pytest.approx(_STEP_DT) + assert profile.presets["safe"].tracking_policy == TrackingPolicy.joint_position( + in_flight_max_abs_error=0.037, + terminal_max_abs_error=0.019, + ) def test_mllm_config_and_atomic_skills_share_invocations_and_verified_results( @@ -1689,8 +1698,8 @@ def test_simulation_factory_returns_exact_environment_adapter() -> None: assert factory.segment_policy_port is not None -def test_simulation_helper_forwards_semantic_grounding_extensions() -> None: - """Both explicit grounding seams reach the runtime compiler unchanged.""" +def test_simulation_helper_consumes_registered_semantic_grounding_extensions() -> None: + """Both registration-owned grounding seams reach the compiler unchanged.""" robot = _Robot() environment = SimpleNamespace( sim=_Simulation(robot), @@ -1701,11 +1710,13 @@ def test_simulation_helper_forwards_semantic_grounding_extensions() -> None: handover_provider = _ForwardedHandOverPoseProvider() adapter = create_simulation_expert_program_adapter( environment, # type: ignore[arg-type] - scene_binding=SimulationSceneBinding(registry_id="scene"), - robot_profile_binding=_profile_binding(), + registration=SimulationExpertProgramRegistration( + scene_binding=SimulationSceneBinding(registry_id="scene"), + robot_profile_binding=_profile_binding(), + relation_grounders=(relation_grounder,), + handover_pose_providers=(handover_provider,), + ), motion_generator_factory=lambda: _motion_generator(robot), - relation_grounders=(relation_grounder,), - handover_pose_providers=(handover_provider,), ) assembly = adapter.assemble_runtime( @@ -1722,41 +1733,35 @@ def test_simulation_helper_forwards_semantic_grounding_extensions() -> None: ) -def test_simulation_helper_handover_preflight_is_fail_closed_by_default() -> None: - """Selecting a provider ID does not infer or auto-install an implementation.""" - environment, scene_binding, profile_binding = _handover_helper_inputs() - robot = environment.robot - adapter = create_simulation_expert_program_adapter( - environment, # type: ignore[arg-type] - scene_binding=scene_binding, - robot_profile_binding=profile_binding, - motion_generator_factory=lambda: _motion_generator(robot), - ) - compiled = adapter.compile(_handover_program()) +def test_handover_registration_is_fail_closed_without_selected_provider() -> None: + """A profile-selected provider must be installed before simulation startup.""" + _, scene_binding, profile_binding = _handover_helper_inputs() - with pytest.raises(SemanticValidationError) as error: - adapter.create_bridge(compiled) - - assert error.value.diagnostic.code == "handover_grounding_provider_not_installed" + with pytest.raises(ValueError, match="selects handover pose provider"): + SimulationExpertProgramRegistration( + scene_binding=scene_binding, + robot_profile_binding=profile_binding, + ) -def test_simulation_helper_forwards_handover_provider_to_preflight() -> None: - """An explicitly supplied embodiment provider satisfies standard preflight.""" +def test_simulation_helper_uses_registered_handover_provider_for_preflight() -> None: + """A registration-owned embodiment provider satisfies standard preflight.""" environment, scene_binding, profile_binding = _handover_helper_inputs() robot = environment.robot provider = _ForwardedHandOverPoseProvider() adapter = create_simulation_expert_program_adapter( environment, # type: ignore[arg-type] - scene_binding=scene_binding, - robot_profile_binding=profile_binding, + registration=SimulationExpertProgramRegistration( + scene_binding=scene_binding, + robot_profile_binding=profile_binding, + handover_pose_providers=(provider,), + ), motion_generator_factory=lambda: _motion_generator(robot), - handover_pose_providers=(provider,), ) bridge = adapter.create_bridge(adapter.compile(_handover_program())) assert bridge is not None - assert provider.calls == 0 def test_simulation_helper_assembles_mobile_endpoint_and_transport_without_joints() -> ( @@ -1789,8 +1794,10 @@ def test_simulation_helper_assembles_mobile_endpoint_and_transport_without_joint adapter = create_simulation_expert_program_adapter( environment, # type: ignore[arg-type] - scene_binding=SimulationSceneBinding(registry_id="mobile_scene"), - robot_profile_binding=profile_binding, + registration=SimulationExpertProgramRegistration( + scene_binding=SimulationSceneBinding(registry_id="mobile_scene"), + robot_profile_binding=profile_binding, + ), motion_generator_factory=lambda: _motion_generator(robot), # type: ignore[arg-type] endpoint_adapters={_MobileEndpoint: _MobileEndpointAdapter()}, runtime_transports=(_MobileTransportEncoder(),), diff --git a/tests/gym/envs/tasks/test_multi_segments_cube_pick_place.py b/tests/gym/envs/tasks/test_multi_segments_cube_pick_place.py index a54203df..6e6fe949 100644 --- a/tests/gym/envs/tasks/test_multi_segments_cube_pick_place.py +++ b/tests/gym/envs/tasks/test_multi_segments_cube_pick_place.py @@ -40,6 +40,7 @@ from embodichain_tasks.multi_segments.cube_pick_place import ( # noqa: E402 CUBE_ROBOT_PROFILE_ID, CUBE_SCENE_REGISTRY_ID, + CUBE_EXPERT_PROGRAM_REGISTRATION, MultiSegmentsCubePickPlaceEnv, _create_default_env_cfg, create_cube_robot_profile_binding, @@ -70,6 +71,8 @@ def test_registered_task_uses_shared_expert_program_mixin() -> None: spec = REGISTERED_ENVS["MultiSegmentsCubePickPlace-v1"] assert spec.cls is MultiSegmentsCubePickPlaceEnv assert spec.max_episode_steps == 1200 + assert spec.expert_program_registration is CUBE_EXPERT_PROGRAM_REGISTRATION + assert "expert_program_registration" not in spec.default_kwargs assert issubclass(MultiSegmentsCubePickPlaceEnv, ExpertProgramEnvironmentMixin) assert issubclass(MultiSegmentsCubePickPlaceEnv, EmbodiedEnv) @@ -82,11 +85,7 @@ def test_gym_config_selects_packaged_expert_program() -> None: assert payload["expert_program_path"] == ( "../../expert_program/multi_segments/repeated_cube_pick_place.yaml" ) - extensions = payload["env"]["extensions"] - assert extensions == { - "grasp_samples": 10000, - "force_reannotate": False, - } + assert payload["env"]["extensions"] == {} settle = payload["env"]["events"]["settle_cube_on_reset"] assert settle["func"] == "wait_for_dynamic_objects_to_settle" assert settle["mode"] == "reset" @@ -130,7 +129,10 @@ def test_robot_profile_calibrates_physical_tracking_tolerance() -> None: binding = create_cube_robot_profile_binding() assert binding.presets[0].preset_id == "safe" - assert binding.presets[0].recovery_policy.tracking_error_threshold == 0.08 + tracking = binding.presets[0].tracking_policy + assert tracking.in_flight is not None + assert tracking.in_flight.metrics[0].tolerance == 0.08 + assert tracking.terminal.metrics[0].tolerance == 0.08 def test_task_initialization_delegates_to_shared_simulation_factory( @@ -162,14 +164,16 @@ def fake_create_adapter(environment, **kwargs): assert env.expert_program_adapter is adapter assert captured["environment"] is env + registration = captured["registration"] + assert registration is CUBE_EXPERT_PROGRAM_REGISTRATION assert ( - captured["scene_binding"] - .antipodal_grasps[0] - .generator_cfg.antipodal_sampler_cfg.n_sample - == 48 + registration.scene_binding.antipodal_grasps[ + 0 + ].generator_cfg.antipodal_sampler_cfg.n_sample + == 10000 ) - assert captured["scene_binding"].antipodal_grasps[0].force_reannotate is True - assert captured["robot_profile_binding"].profile_id == CUBE_ROBOT_PROFILE_ID + assert registration.scene_binding.antipodal_grasps[0].force_reannotate is False + assert registration.robot_profile_binding.profile_id == CUBE_ROBOT_PROFILE_ID def test_task_config_compiles_through_real_simulation_factory( diff --git a/tests/gym/envs/tasks/test_open_drawer.py b/tests/gym/envs/tasks/test_open_drawer.py index 81893c5a..725d3c77 100644 --- a/tests/gym/envs/tasks/test_open_drawer.py +++ b/tests/gym/envs/tasks/test_open_drawer.py @@ -43,6 +43,7 @@ DRAWER_OPEN_POSITION, DRAWER_ROBOT_PROFILE_ID, DRAWER_UID, + OPEN_DRAWER_EXPERT_PROGRAM_REGISTRATION, OpenDrawerEnv, create_open_drawer_scene_binding, ) @@ -68,6 +69,8 @@ def test_registered_drawer_task_uses_shared_expert_program_mixin() -> None: spec = REGISTERED_ENVS["OpenDrawer-v1"] assert spec.cls is OpenDrawerEnv + assert spec.expert_program_registration is OPEN_DRAWER_EXPERT_PROGRAM_REGISTRATION + assert "expert_program_registration" not in spec.default_kwargs assert issubclass(OpenDrawerEnv, ExpertProgramEnvironmentMixin) assert issubclass(OpenDrawerEnv, EmbodiedEnv) assert "create_demo_action_list" not in OpenDrawerEnv.__dict__ @@ -144,8 +147,10 @@ def fake_create_adapter(environment, **kwargs): assert env.expert_program_adapter is adapter assert captured["environment"] is env - assert captured["scene_binding"].links[0].native_link_name == "handle_xpos" - assert captured["robot_profile_binding"].profile_id == DRAWER_ROBOT_PROFILE_ID + registration = captured["registration"] + assert registration is OPEN_DRAWER_EXPERT_PROGRAM_REGISTRATION + assert registration.scene_binding.links[0].native_link_name == "handle_xpos" + assert registration.robot_profile_binding.profile_id == DRAWER_ROBOT_PROFILE_ID def test_task_config_compiles_through_real_simulation_factory( diff --git a/tests/gym/utils/test_gym_utils.py b/tests/gym/utils/test_gym_utils.py index db311928..c0cd8ee2 100644 --- a/tests/gym/utils/test_gym_utils.py +++ b/tests/gym/utils/test_gym_utils.py @@ -27,6 +27,7 @@ from tensordict import TensorDict +from embodichain.lab.gym.envs.expert_program import IntegrationFingerprintMismatch from embodichain.lab.gym.utils.gym_utils import ( add_env_launcher_args_to_parser, build_env_cfg_from_args, @@ -39,6 +40,11 @@ ) from embodichain.lab.sim.robots import URRobotCfg from embodichain.utils.utility import load_config, save_config +from embodichain_tasks.multi_segments.cube_pick_place import ( + CUBE_EXPERT_PROGRAM_REGISTRATION, + CUBE_ROBOT_PROFILE_ID, + CUBE_SCENE_REGISTRY_ID, +) class TestInitRolloutBufferFromConfig: @@ -513,7 +519,7 @@ class TestConfigToCfgFromFile: def _minimal_gym_config() -> dict[str, object]: """Return a minimal config that reaches the generic parser.""" return { - "id": "EmbodiedEnv-v1", + "id": "MultiSegmentsCubePickPlace-v1", "env": {}, "robot": { "class_type": "URRobot", @@ -529,9 +535,9 @@ def _expert_program_payload() -> dict[str, object]: "schema_version": 1, "program_id": "configured_pick", "integration": { - "robot_profile": "default_robot", - "scene_registry": "default_scene", - "runtime_preset": "default_runtime", + "robot_profile": CUBE_ROBOT_PROFILE_ID, + "scene_registry": CUBE_SCENE_REGISTRY_ID, + "runtime_preset": "safe", }, "targets": {}, "program": { @@ -585,7 +591,7 @@ def test_expert_program_path_is_resolved_from_gym_config_source( ) assert cfg.expert_program.program_id == "configured_pick" - assert cfg.expert_program.integration.scene_registry == "default_scene" + assert cfg.expert_program.integration.scene_registry == CUBE_SCENE_REGISTRY_ID def test_build_env_cfg_loads_source_relative_expert_program( self, @@ -621,6 +627,81 @@ def test_build_env_cfg_loads_source_relative_expert_program( assert cfg.expert_program.program_id == "configured_pick" + def test_cli_program_override_is_selected_and_loaded_once( + self, + tmp_path, + monkeypatch, + ) -> None: + """The CLI override replaces the Gym path at the single loader boundary.""" + from embodichain.lab.gym.envs.expert_program import loader + + gym_path = tmp_path / "gym_config.json" + override_path = tmp_path / "override.yaml" + save_config(override_path, self._expert_program_payload()) + config = self._minimal_gym_config() + config["expert_program_path"] = "must_not_be_loaded.yaml" + save_config(gym_path, config) + args = argparse.Namespace( + gym_config=str(gym_path), + expert_program=str(override_path), + num_envs=1, + device="cpu", + headless=True, + renderer=None, + gpu_id=0, + arena_space=2.0, + max_episodes=None, + filter_visual_rand=False, + filter_dataset_saving=False, + preview=False, + action_config=None, + ) + calls: list[str] = [] + original = loader.load_expert_program + + def load_once(path, **kwargs): + calls.append(str(path)) + return original(path, **kwargs) + + monkeypatch.setattr(loader, "load_expert_program", load_once) + + cfg, _, _ = build_env_cfg_from_args(args) + + assert cfg.expert_program.program_id == "configured_pick" + assert calls == [str(override_path)] + + def test_registration_drift_fails_before_program_loader( + self, + tmp_path, + monkeypatch, + ) -> None: + """The config boundary checks registration integrity before file loading.""" + from embodichain.lab.gym.envs.expert_program import loader + + program_path = tmp_path / "program.yaml" + save_config(program_path, self._expert_program_payload()) + config = self._minimal_gym_config() + config["expert_program_path"] = str(program_path) + generator_cfg = CUBE_EXPERT_PROGRAM_REGISTRATION.scene_binding.antipodal_grasps[ + 0 + ].generator_cfg + assert generator_cfg is not None + sampler_cfg = generator_cfg.antipodal_sampler_cfg + monkeypatch.setattr(sampler_cfg, "n_sample", sampler_cfg.n_sample + 1) + loader_calls: list[str] = [] + + def unexpected_load(path, **kwargs): + del kwargs + loader_calls.append(str(path)) + raise AssertionError("Drift must fail before program loading.") + + monkeypatch.setattr(loader, "load_expert_program", unexpected_load) + + with pytest.raises(IntegrationFingerprintMismatch, match="changed"): + config_to_cfg(config, manager_modules=DEFAULT_MANAGER_MODULES) + + assert loader_calls == [] + def test_config_to_cfg_uses_cwd_without_source_path( self, tmp_path, diff --git a/tests/lab/scripts/test_run_env.py b/tests/lab/scripts/test_run_env.py index 788a89f9..1b0b1d1d 100644 --- a/tests/lab/scripts/test_run_env.py +++ b/tests/lab/scripts/test_run_env.py @@ -24,11 +24,13 @@ import torch from embodichain.lab.gym.envs.demo import DemoEpisodeResult +from embodichain.lab.gym.envs.expert_program.loader import ( + load_expert_program as _load_expert_program, +) from embodichain.lab.gym.utils.gym_utils import merge_args_with_gym_config from embodichain.lab.scripts import run_env from embodichain.lab.scripts.run_env import ( _create_parser, - _load_expert_program, _run_replay_control_loop, generate_function, ) @@ -549,7 +551,7 @@ def test_cli_aborts_before_closing_environment_once(monkeypatch) -> None: assert env.events == [abort_event, abort_event, ("close", None)] -def test_cli_injects_decoded_expert_program_before_environment_creation( +def test_cli_uses_program_already_loaded_by_config_builder( monkeypatch, ) -> None: """The CLI attaches the strict program config to the environment config.""" @@ -569,16 +571,13 @@ def test_cli_injects_decoded_expert_program_before_environment_creation( monkeypatch.setattr(run_env, "_create_parser", lambda: parser) monkeypatch.setattr(run_env, "discover_task_packages", lambda: None) monkeypatch.setattr(run_env, "execute_init_hooks", lambda: None) - monkeypatch.setattr( - run_env, - "build_env_cfg_from_args", - lambda parsed_args: (env_cfg, {"id": GYM_ID}, {}), - ) - monkeypatch.setattr( - run_env, - "_load_expert_program", - MagicMock(return_value=decoded_program), - ) + + def build(parsed_args): + assert parsed_args is args + env_cfg.expert_program = decoded_program + return env_cfg, {"id": GYM_ID}, {} + + monkeypatch.setattr(run_env, "build_env_cfg_from_args", build) monkeypatch.setattr(run_env.gymnasium, "make", make) monkeypatch.setattr(run_env, "main", lambda *args, **kwargs: None) monkeypatch.setattr( From cc246bbd7ade570e365f730ec318024bea325faf Mon Sep 17 00:00:00 2001 From: yuecideng Date: Tue, 11 Aug 2026 17:06:38 +0800 Subject: [PATCH 2/4] fix(expert-program): reject opaque catalog values --- .../lab/gym/envs/expert_program/catalog.py | 19 ++++++++---- tests/gym/envs/expert_program/test_catalog.py | 29 +++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/embodichain/lab/gym/envs/expert_program/catalog.py b/embodichain/lab/gym/envs/expert_program/catalog.py index a683caa1..fe5e5d9c 100644 --- a/embodichain/lab/gym/envs/expert_program/catalog.py +++ b/embodichain/lab/gym/envs/expert_program/catalog.py @@ -143,9 +143,18 @@ def _canonical_value(value: object) -> object: for data_field in fields(value) } return {"type": _qualified_name(value), "fields": metadata} - # Provider objects are not executable catalog data. Their declared type is - # still part of the integration surface, while live identity is excluded. - return {"provider_type": _qualified_name(value)} + raise TypeError( + "Registration fingerprint metadata contains unsupported value type " + f"{_qualified_name(value)!r}. Values must be complete declarative data; " + "live or opaque objects cannot be fingerprinted by type alone." + ) + + +def _provider_fingerprint_declaration(provider: object) -> object: + """Return the complete canonical declaration for one validated provider.""" + if is_dataclass(provider): + return provider + return {"provider_type": _qualified_name(provider)} def _canonical_json(value: object) -> str: @@ -838,7 +847,7 @@ def _registration_payload( "relation_grounders": tuple( { "key": _relation_grounder_key(grounder), - "provider": grounder, + "provider": _provider_fingerprint_declaration(grounder), } for grounder in sorted( relation_grounders, @@ -848,7 +857,7 @@ def _registration_payload( "handover_pose_providers": tuple( { "provider_id": _handover_pose_provider_id(provider), - "provider": provider, + "provider": _provider_fingerprint_declaration(provider), } for provider in sorted( handover_pose_providers, diff --git a/tests/gym/envs/expert_program/test_catalog.py b/tests/gym/envs/expert_program/test_catalog.py index a63cc15c..1d6c07fa 100644 --- a/tests/gym/envs/expert_program/test_catalog.py +++ b/tests/gym/envs/expert_program/test_catalog.py @@ -174,6 +174,25 @@ def __init__(self) -> None: object.__setattr__(self, "cache", {}) +@dataclass(frozen=True, slots=True) +class _OpaqueHandOverPoseProvider(HandOverPoseProvider): + """Provider declaration containing an unsupported opaque nested value.""" + + provider_id: ClassVar[str] = "test.catalog_handover.opaque" + opaque: object + + def resolve( + self, + call: HandOver, + *, + context: PlanningContext, + bound: BoundSemanticCall, + ) -> HandOverPoseTargets: + """Remain unreachable because fingerprinting rejects this provider.""" + del call, context, bound + raise AssertionError("Opaque providers must never reach runtime.") + + def _program_payload( *, scene_registry: str = CUBE_SCENE_REGISTRY_ID, @@ -495,6 +514,16 @@ def test_fingerprint_owns_provider_ids_and_declarative_fields() -> None: registration.assert_unchanged() +def test_fingerprint_rejects_opaque_nested_declaration_values() -> None: + """Unknown nested values cannot silently collapse to their Python type.""" + with pytest.raises(TypeError, match="unsupported value type"): + SimulationExpertProgramRegistration( + scene_binding=create_cube_scene_binding(grasp_samples=32), + robot_profile_binding=create_cube_robot_profile_binding(), + handover_pose_providers=(_OpaqueHandOverPoseProvider(opaque=object()),), + ) + + def test_registration_rejects_duplicate_provider_keys_and_ids() -> None: """Provider lookup tables remain unambiguous before simulation startup.""" common = { From dd0f6133a4d478ce3ca69fb68c63cb136c841b79 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Tue, 11 Aug 2026 17:31:56 +0800 Subject: [PATCH 3/4] feat(expert-program): configure semantic action options --- .../atomic_actions/robot_skill_profiles.md | 30 ++ .../lab/gym/envs/expert_program/catalog.py | 3 +- .../expert_program/simulation_environment.py | 1 + embodichain/lab/sim/skills/compiler.py | 82 ++++-- embodichain/lab/sim/skills/integration.py | 98 +++++++ embodichain/lab/sim/skills/profiles.py | 259 +++++++++++++++++- .../multi_segments/cube_pick_place.py | 6 + .../tableware/open_drawer.py | 10 +- .../envs/expert_program/test_environment.py | 16 +- .../envs/expert_program/test_simulation.py | 8 +- .../test_simulation_environment.py | 36 ++- .../sim/skills/test_articulation_semantics.py | 10 +- tests/sim/skills/test_compiler.py | 153 +++++++++-- ...o_semantic_runtime_dynamic_recovery_gpu.py | 7 +- tests/sim/skills/test_integration.py | 142 +++++++++- tests/sim/skills/test_profiles.py | 159 ++++++++++- 16 files changed, 943 insertions(+), 77 deletions(-) diff --git a/docs/source/overview/sim/atomic_actions/robot_skill_profiles.md b/docs/source/overview/sim/atomic_actions/robot_skill_profiles.md index cbddb478..fc84034b 100644 --- a/docs/source/overview/sim/atomic_actions/robot_skill_profiles.md +++ b/docs/source/overview/sim/atomic_actions/robot_skill_profiles.md @@ -77,7 +77,10 @@ from embodichain.lab.sim.atomic_actions import ( FORWARD_KINEMATICS_CAPABILITY, GRASP_CAPABILITY, ControlPartCommandProfile, + HandOverOptions, MotionPolicy, + PickUpOptions, + PlaceOptions, ) from embodichain.lab.sim.skills import ( COMPOSITE_EFFECT_MONITOR_ID, @@ -140,6 +143,11 @@ profile = RobotSkillProfile( presets={ "default": SkillPolicyPreset( preset_id="default", + action_option_templates={ + "pick": PickUpOptions(), + "place": PlaceOptions(), + "hand_over": HandOverOptions(), + }, motion_policy=MotionPolicy(strategy="ik_interp"), effect_monitors={ semantic_id: EffectMonitorRef( @@ -195,6 +203,28 @@ A linked call receives an effective immutable preset snapshot with Other presets, and scenes without dynamic collision entities, retain their configured collision mode. +## Configure semantic action behavior with the preset + +`SkillPolicyPreset.action_option_templates` is the required, typed action- +behavior table for semantic calls that can select the preset. Each key is the +exact semantic call ID (`pick`, `place`, `hand_over`, or +`operate_articulation`), and each value must be the target action's exact frozen +`ActionOptions` dataclass. Static linking rejects a missing entry, an unknown +call ID, or an options value of the wrong exact type before simulation starts. + +The preset owns independent snapshots of each template. Pick and HandOver +grounding only replace their compiler-owned dynamic target fields; distances, +directions, waypoint counts, and other reusable behavior remain configuration. +A registered semantic lowerer may build a goal but cannot return replacement +options. This keeps task extensions from silently moving action parameters back +into Python code. + +Pick's `downstream_object_target_poses` and HandOver's +`middle_object_pose`/`final_object_pose` are reserved for the semantic compiler +and must remain empty in a template. Planner choice, sample count, tracking, +recovery, runner timing, and effect monitors stay in their dedicated preset +fields rather than `ActionOptions`. + ## Select semantic effect monitors with the preset A {class}`SkillPolicyPreset` owns one coherent runtime choice: planning and diff --git a/embodichain/lab/gym/envs/expert_program/catalog.py b/embodichain/lab/gym/envs/expert_program/catalog.py index fe5e5d9c..06963c26 100644 --- a/embodichain/lab/gym/envs/expert_program/catalog.py +++ b/embodichain/lab/gym/envs/expert_program/catalog.py @@ -80,7 +80,7 @@ from .simulation import SimulationRobotSkillProfileBinding, SimulationSceneBinding from .simulation_policies import default_simulation_settle_presets -_CATALOG_FINGERPRINT_SCHEMA_VERSION = 1 +_CATALOG_FINGERPRINT_SCHEMA_VERSION = 2 _POST_POLICY_KINDS = frozenset({"wait_stable"}) _VALIDATOR_KINDS = frozenset({"object_near_target"}) @@ -810,6 +810,7 @@ def _profile_with_control_dt( recovery_policy=preset.recovery_policy, runner_cfg=preset.runner_cfg, effect_monitors=preset.effect_monitors, + action_option_templates=preset.action_option_templates, ) for preset_id, preset in profile.presets.items() }, diff --git a/embodichain/lab/gym/envs/expert_program/simulation_environment.py b/embodichain/lab/gym/envs/expert_program/simulation_environment.py index eee4660e..6fa9f9df 100644 --- a/embodichain/lab/gym/envs/expert_program/simulation_environment.py +++ b/embodichain/lab/gym/envs/expert_program/simulation_environment.py @@ -936,6 +936,7 @@ def create_robot_skill_profile(self) -> RobotSkillProfile: recovery_policy=preset.recovery_policy, runner_cfg=preset.runner_cfg, effect_monitors=preset.effect_monitors, + action_option_templates=preset.action_option_templates, ) for preset_id, preset in profile.presets.items() } diff --git a/embodichain/lab/sim/skills/compiler.py b/embodichain/lab/sim/skills/compiler.py index def756fc..98040c41 100644 --- a/embodichain/lab/sim/skills/compiler.py +++ b/embodichain/lab/sim/skills/compiler.py @@ -20,9 +20,10 @@ from abc import ABC, abstractmethod from collections.abc import Iterable, Mapping -from dataclasses import dataclass, field +from copy import deepcopy +from dataclasses import dataclass, field, replace from types import MappingProxyType -from typing import ClassVar +from typing import ClassVar, TypeVar from uuid import uuid4 import torch @@ -40,6 +41,7 @@ PlaceGoal, PlaceOptions, OperateArticulationGoal, + OperateArticulationOptions, PlanningContext, PoseGoalValue, SceneArticulationOperationGeometry, @@ -97,6 +99,8 @@ SceneObjectRef, ) +OptionT = TypeVar("OptionT", bound=ActionOptions) + def _validate_identifier(value: str, *, field_name: str) -> str: """Return one exact non-empty identifier.""" @@ -421,8 +425,15 @@ def lower( *, context: PlanningContext, bound: BoundSemanticCall, + option_template: ActionOptions, ) -> SemanticLowering: - """Lower one registered value to goal/options without changing policy.""" + """Lower a registered value with one owned typed option template. + + The lowerer must return :class:`SemanticLowering` with + ``skill_options=None``. The supplied template is an owned read-only + input for goal grounding; the selected policy preset remains the sole + owner of action options. + """ @dataclass(frozen=True, slots=True) @@ -1078,6 +1089,10 @@ def ground( raise AssertionError(f"Unsupported analyzed call {type(call).__name__}.") bound = analyzed.bound + if lowering.skill_options is None: + raise AssertionError( + "Semantic lowering must resolve a non-None action-options value." + ) invocation = ActionInvocation( skill_id=bound.linked.descriptor.skill_id, goal=lowering.goal, @@ -1323,13 +1338,15 @@ def _lower_pick( call.object, affordance=grasp_ref, ) + option_template = self._action_option_template(analyzed, PickUpOptions) return SemanticLowering( goal=GraspGoal(semantics=semantics), - skill_options=PickUpOptions( + skill_options=replace( + option_template, downstream_object_target_poses=tuple( self._ground_object_target(target, context) for target in analyzed.downstream_object_targets - ) + ), ), ) @@ -1367,7 +1384,10 @@ def _lower_place( xpos = self._compose_object_to_eef( object_target, held.object_to_eef, context ) - return SemanticLowering(goal=PlaceGoal(xpos=xpos), skill_options=PlaceOptions()) + return SemanticLowering( + goal=PlaceGoal(xpos=xpos), + skill_options=self._action_option_template(analyzed, PlaceOptions), + ) def _lower_handover( self, @@ -1411,9 +1431,11 @@ def _lower_handover( else targets.final ) final = self._ground_object_target(final_target, context) + option_template = self._action_option_template(analyzed, HandOverOptions) return SemanticLowering( goal=GraspGoal(semantics=semantics), - skill_options=HandOverOptions( + skill_options=replace( + option_template, middle_object_pose=middle, final_object_pose=final, ), @@ -1546,7 +1568,11 @@ def _lower_operate_articulation( source_position=source_position, target_position=target, target_displacement=displacement, - ) + ), + skill_options=self._action_option_template( + analyzed, + OperateArticulationOptions, + ), ) def _lower_registered( @@ -1567,19 +1593,24 @@ def _lower_registered( f"No lowerer is installed for {call.call_id!r}.", tuple(self._registered_lowerers), ) + descriptor = analyzed.bound.linked.descriptor + target = descriptor.target_descriptor + assert target is not None + option_template = self._action_option_template( + analyzed, + target.options_type, + ) lowering = lowerer.lower( call, context=context, bound=analyzed.bound, + option_template=deepcopy(option_template), ) if type(lowering) is not SemanticLowering: raise TypeError( "RegisteredSemanticLowerer.lower() must return exactly " "SemanticLowering." ) - descriptor = analyzed.bound.linked.descriptor - target = descriptor.target_descriptor - assert target is not None expected_goal_types = ( target.goal_type if isinstance(target.goal_type, tuple) @@ -1590,13 +1621,32 @@ def _lower_registered( f"Lowerer {call.call_id!r} produced {type(lowering.goal).__name__}; " f"target skill {target.skill_id!r} expects {target.goal_type!r}." ) - if lowering.skill_options is not None and ( - type(lowering.skill_options) is not target.options_type - ): + if lowering.skill_options is not None: raise TypeError( - f"Lowerer {call.call_id!r} produced incompatible skill options." + f"Lowerer {call.call_id!r} must not return skill_options; " + "the selected policy preset owns action options." + ) + return replace(lowering, skill_options=deepcopy(option_template)) + + @staticmethod + def _action_option_template( + analyzed: AnalyzedSemanticCall, + expected_type: type[OptionT], + ) -> OptionT: + """Return one owned exact template selected by semantic call ID.""" + semantic_id = analyzed.call.semantic_id + try: + template = analyzed.bound.preset.action_option_template(semantic_id) + except KeyError as exc: # pragma: no cover - static linking owns this check + raise AssertionError( + f"Linked call {semantic_id!r} has no action-option template." + ) from exc + if type(template) is not expected_type: + raise AssertionError( + f"Linked call {semantic_id!r} has {type(template).__name__}; " + f"expected exact {expected_type.__name__}." ) - return lowering + return template def _ground_effect_spec( self, diff --git a/embodichain/lab/sim/skills/integration.py b/embodichain/lab/sim/skills/integration.py index 0892f228..9acb7223 100644 --- a/embodichain/lab/sim/skills/integration.py +++ b/embodichain/lab/sim/skills/integration.py @@ -29,6 +29,8 @@ DynamicCollisionMode, DisjointResourceSlots, DisjointSlotEndpoints, + HandOverOptions, + PickUpOptions, SkillResourceSlot, ) @@ -717,6 +719,81 @@ def __post_init__(self) -> None: tuple(self.call_catalog.descriptors), ) ) + unknown_option_ids = sorted( + set(preset.action_option_templates).difference(known_semantic_ids) + ) + if unknown_option_ids: + semantic_id = unknown_option_ids[0] + raise SemanticValidationError( + SemanticDiagnostic( + "unknown_action_option_call", + ( + "integration", + "robot_profile", + "presets", + preset_id, + "action_option_templates", + semantic_id, + ), + f"Action-option configuration references unknown semantic " + f"call {semantic_id!r}.", + tuple(self.call_catalog.descriptors), + ) + ) + for semantic_id, options in preset.action_option_templates.items(): + descriptor = self.call_catalog.descriptors[semantic_id] + target = descriptor.target_descriptor + assert target is not None + option_path = ( + "integration", + "robot_profile", + "presets", + preset_id, + "action_option_templates", + semantic_id, + ) + if type(options) is not target.options_type: + raise SemanticValidationError( + SemanticDiagnostic( + "incompatible_action_option_template", + option_path, + f"Semantic call {semantic_id!r} targets options type " + f"{target.options_type.__name__}, not " + f"{type(options).__name__}.", + (target.options_type.__name__,), + ) + ) + if semantic_id == Pick.call_kind: + assert type(options) is PickUpOptions + if options.downstream_object_target_poses: + raise SemanticValidationError( + SemanticDiagnostic( + "reserved_action_option_field", + (*option_path, "downstream_object_target_poses"), + "Pick downstream targets are compiler-owned and " + "the template field must be empty.", + ) + ) + if semantic_id == HandOver.call_kind: + assert type(options) is HandOverOptions + if options.middle_object_pose is not None: + raise SemanticValidationError( + SemanticDiagnostic( + "reserved_action_option_field", + (*option_path, "middle_object_pose"), + "HandOver middle_object_pose is compiler-owned and " + "the template field must be None.", + ) + ) + if options.final_object_pose is not None: + raise SemanticValidationError( + SemanticDiagnostic( + "reserved_action_option_field", + (*option_path, "final_object_pose"), + "HandOver final_object_pose is compiler-owned and " + "the template field must be None.", + ) + ) if self.runtime_preset is not None: _validate_identifier( self.runtime_preset, @@ -856,6 +933,26 @@ def link_call( descriptor, path=(*path, "preset"), ) + preset = self.robot_profile.presets[preset_id] + if descriptor.call_id not in preset.action_option_templates: + option_path = ( + "integration", + "robot_profile", + "presets", + preset_id, + "action_option_templates", + descriptor.call_id, + ) + raise SemanticValidationError( + SemanticDiagnostic( + "missing_action_option_template", + option_path, + f"Policy preset {preset_id!r} has no action-option template " + f"for semantic call {descriptor.call_id!r} selected at " + f"{_render_path(path)}.", + tuple(preset.action_option_templates), + ) + ) return LinkedSemanticCall( call=normalized_call, descriptor=descriptor, @@ -1390,6 +1487,7 @@ def link_call( recovery_policy=preset.recovery_policy, runner_cfg=preset.runner_cfg, effect_monitors=preset.effect_monitors, + action_option_templates=preset.action_option_templates, ) return BoundSemanticCall._create( linked=linked, diff --git a/embodichain/lab/sim/skills/profiles.py b/embodichain/lab/sim/skills/profiles.py index 2fc875d5..8e6fd365 100644 --- a/embodichain/lab/sim/skills/profiles.py +++ b/embodichain/lab/sim/skills/profiles.py @@ -20,11 +20,14 @@ from abc import ABC, abstractmethod from copy import deepcopy -from dataclasses import dataclass, field +from dataclasses import dataclass, field, fields, is_dataclass +from enum import Enum from itertools import product from types import MappingProxyType from typing import ClassVar, Mapping, TYPE_CHECKING +import torch + from embodichain.lab.sim.atomic_actions.bindings import ( ActionBinding, EndpointBinding, @@ -37,6 +40,7 @@ JointPositionCommand, ) from embodichain.lab.sim.atomic_actions.core import SkillDescriptor +from embodichain.lab.sim.atomic_actions.invocation import ActionOptions from embodichain.lab.sim.atomic_actions.policies import MotionPolicy, RecoveryPolicy from embodichain.lab.sim.atomic_actions.tracking import ( JOINT_POSITION_CHANNEL, @@ -106,6 +110,167 @@ def _validate_identifier(value: str, *, field_name: str) -> str: return value +def _snapshot_graph_tokens( + value: object, + *, + path: str, + visited: set[int], +) -> set[tuple[object, ...]]: + """Collect identities for every mutable value and tensor storage. + + Immutable containers are traversed because they may retain mutable leaves. + Unknown opaque values fail closed: an action-options declaration must expose + its complete snapshot graph through dataclass fields and built-in containers. + """ + if value is None or type(value) in { + bool, + int, + float, + complex, + str, + bytes, + range, + slice, + torch.device, + torch.dtype, + }: + return set() + if isinstance(value, (Enum, type)): + return set() + + value_id = id(value) + if value_id in visited: + return set() + visited.add(value_id) + + if isinstance(value, torch.Tensor): + tokens: set[tuple[object, ...]] = {("object", value_id)} + storage = value.untyped_storage() + if storage.nbytes() > 0: + tokens.add( + ( + "tensor_storage", + value.device.type, + value.device.index, + storage.data_ptr(), + ) + ) + return tokens + if is_dataclass(value) and not isinstance(value, type): + tokens = {("object", value_id)} + for data_field in fields(value): + tokens.update( + _snapshot_graph_tokens( + getattr(value, data_field.name), + path=f"{path}.{data_field.name}", + visited=visited, + ) + ) + return tokens + if type(value) is dict: + tokens = {("object", value_id)} + for key, nested in value.items(): + tokens.update( + _snapshot_graph_tokens( + key, + path=f"{path}.", + visited=visited, + ) + ) + tokens.update( + _snapshot_graph_tokens( + nested, + path=f"{path}[{key!r}]", + visited=visited, + ) + ) + return tokens + if type(value) in {list, set, bytearray}: + tokens = {("object", value_id)} + for index, nested in enumerate(value): + tokens.update( + _snapshot_graph_tokens( + nested, + path=f"{path}[{index}]", + visited=visited, + ) + ) + return tokens + if type(value) in {tuple, frozenset}: + tokens = set() + for index, nested in enumerate(value): + tokens.update( + _snapshot_graph_tokens( + nested, + path=f"{path}[{index}]", + visited=visited, + ) + ) + return tokens + raise TypeError( + f"Action-options snapshot graph contains unsupported opaque value " + f"{type(value).__module__}.{type(value).__qualname__} at {path}." + ) + + +def _snapshot_action_options(options: ActionOptions) -> ActionOptions: + """Return one exact action-options snapshot with no mutable aliasing.""" + if not isinstance(options, ActionOptions): + raise TypeError( + "action_option_templates values must be ActionOptions instances." + ) + option_type = type(options) + dataclass_params = option_type.__dict__.get("__dataclass_params__") + dataclass_fields = option_type.__dict__.get("__dataclass_fields__") + if ( + dataclass_params is None + or dataclass_fields is None + or dataclass_params.frozen is not True + ): + raise TypeError( + "action_option_templates values must be exact frozen @dataclass " + "declarations, not inherited undecorated ActionOptions subclasses." + ) + if hasattr(options, "__dict__"): + raise TypeError("action_option_templates values must not carry __dict__ state.") + field_names = {data_field.name for data_field in fields(options)} + declared_slots: set[str] = set() + for base in option_type.__mro__: + slots = base.__dict__.get("__slots__", ()) + if isinstance(slots, str): + declared_slots.add(slots) + else: + declared_slots.update(slots) + opaque_slots = declared_slots.difference(field_names, {"__weakref__"}) + if opaque_slots: + raise TypeError( + "action_option_templates values must not carry non-dataclass " + f"slot state: {sorted(opaque_slots)}." + ) + snapshot = deepcopy(options) + if type(snapshot) is not option_type or snapshot is options: + raise TypeError( + "action_option_templates values must support independent deep-copy " + "snapshots of their exact type." + ) + source_tokens = _snapshot_graph_tokens( + options, + path=option_type.__name__, + visited=set(), + ) + snapshot_tokens = _snapshot_graph_tokens( + snapshot, + path=option_type.__name__, + visited=set(), + ) + if source_tokens.intersection(snapshot_tokens): + raise TypeError( + "action_option_templates values must support independently owned " + "snapshots without shared mutable objects or tensor storage." + ) + return snapshot + + def _normalize_identifier_set( values: frozenset[str], *, @@ -400,6 +565,21 @@ class ResourceEndpointAdapter(ABC): endpoint_type: ClassVar[type[ResourceEndpoint]] """Exact endpoint declaration type accepted by this adapter.""" + runtime_transport_ids: ClassVar[frozenset[str]] + """Exact endpoint-command transport IDs this adapter may resolve.""" + + runtime_target_types: ClassVar[tuple[type[RuntimeEndpointTarget], ...]] + """Exact immutable runtime-target value types this adapter may resolve.""" + + tracking_feedback_source_keys: ClassVar[frozenset[tuple[str, str]]] + """Exact ``(provider_id, revision)`` tracking-feedback routes emitted.""" + + tracking_projector_keys: ClassVar[frozenset[tuple[str, str]]] + """Exact ``(projector_id, revision)`` desired-state routes emitted.""" + + effect_evidence_source_keys: ClassVar[frozenset[tuple[str, str]]] + """Exact ``(provider_id, revision)`` effect-evidence routes emitted.""" + @abstractmethod def resolve( self, @@ -423,6 +603,26 @@ class ControlPartEndpointAdapter(ResourceEndpointAdapter): adapter_id: ClassVar[str] = "control_part" endpoint_type: ClassVar[type[ResourceEndpoint]] = ControlPartEndpoint + runtime_transport_ids: ClassVar[frozenset[str]] = frozenset( + {JointPositionTarget.TRANSPORT_ID} + ) + runtime_target_types: ClassVar[tuple[type[RuntimeEndpointTarget], ...]] = ( + JointPositionTarget, + ) + tracking_feedback_source_keys: ClassVar[frozenset[tuple[str, str]]] = frozenset( + {("planning_context.robot", "1")} + ) + tracking_projector_keys: ClassVar[frozenset[tuple[str, str]]] = frozenset( + {("joint_position_payload", "1")} + ) + effect_evidence_source_keys: ClassVar[frozenset[tuple[str, str]]] = frozenset( + { + ( + CONTROL_PART_EVIDENCE_PROVIDER_ID, + CONTROL_PART_EVIDENCE_PROVIDER_REVISION, + ) + } + ) def resolve( self, @@ -717,7 +917,7 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True, init=False) class SkillPolicyPreset: - """Versioned planning, tracking, recovery, runner, and monitor bundle.""" + """Versioned policies and typed semantic-call option templates.""" preset_id: str schema_version: int @@ -726,11 +926,14 @@ class SkillPolicyPreset: _recovery_policy: RecoveryPolicy _runner_cfg: ExecutionRunnerCfg _effect_monitors: Mapping[str, EffectMonitorRef] + _action_option_templates: Mapping[str, ActionOptions] def __init__( self, preset_id: str, - schema_version: int = 1, + *, + action_option_templates: Mapping[str, ActionOptions], + schema_version: int = 2, motion_policy: MotionPolicy | None = None, tracking_policy: TrackingPolicy | None = None, recovery_policy: RecoveryPolicy | None = None, @@ -741,10 +944,10 @@ def __init__( _validate_identifier(preset_id, field_name="SkillPolicyPreset.preset_id") if not isinstance(schema_version, int) or isinstance(schema_version, bool): raise TypeError("SkillPolicyPreset.schema_version must be an integer.") - if schema_version != 1: + if schema_version != 2: raise ValueError( "Unsupported SkillPolicyPreset.schema_version " - f"{schema_version}; supported versions are [1]." + f"{schema_version}; supported versions are [2]." ) selected_motion = MotionPolicy() if motion_policy is None else motion_policy selected_tracking = ( @@ -793,6 +996,17 @@ def __init__( "effect_monitors values must be EffectMonitorRef instances." ) normalized_effect_monitors[semantic_id] = monitor_ref.snapshot() + if not isinstance(action_option_templates, Mapping): + raise TypeError("action_option_templates must be a mapping.") + normalized_action_option_templates: dict[str, ActionOptions] = {} + for semantic_id, options in action_option_templates.items(): + _validate_identifier( + semantic_id, + field_name="SkillPolicyPreset action-option semantic IDs", + ) + normalized_action_option_templates[semantic_id] = _snapshot_action_options( + options + ) object.__setattr__(self, "preset_id", preset_id) object.__setattr__(self, "schema_version", schema_version) object.__setattr__(self, "_motion_policy", deepcopy(selected_motion)) @@ -804,6 +1018,11 @@ def __init__( "_effect_monitors", MappingProxyType(normalized_effect_monitors), ) + object.__setattr__( + self, + "_action_option_templates", + MappingProxyType(normalized_action_option_templates), + ) @property def motion_policy(self) -> MotionPolicy: @@ -835,6 +1054,35 @@ def effect_monitors(self) -> Mapping[str, EffectMonitorRef]: } ) + @property + def action_option_templates(self) -> Mapping[str, ActionOptions]: + """Return owned option templates keyed by exact semantic call ID.""" + return MappingProxyType( + { + semantic_id: _snapshot_action_options(options) + for semantic_id, options in self._action_option_templates.items() + } + ) + + def action_option_template(self, semantic_id: str) -> ActionOptions: + """Return one owned template for an exact semantic call ID. + + Raises: + KeyError: If this preset does not declare the semantic call. + """ + _validate_identifier( + semantic_id, + field_name="SkillPolicyPreset action-option semantic ID", + ) + try: + template = self._action_option_templates[semantic_id] + except KeyError as exc: + raise KeyError( + f"Preset {self.preset_id!r} has no action-option template for " + f"semantic call {semantic_id!r}." + ) from exc + return _snapshot_action_options(template) + def snapshot(self) -> SkillPolicyPreset: """Return an independently owned preset value.""" return SkillPolicyPreset( @@ -845,6 +1093,7 @@ def snapshot(self) -> SkillPolicyPreset: recovery_policy=self.recovery_policy, runner_cfg=self.runner_cfg, effect_monitors=self.effect_monitors, + action_option_templates=self.action_option_templates, ) diff --git a/embodichain_tasks/embodichain_tasks/multi_segments/cube_pick_place.py b/embodichain_tasks/embodichain_tasks/multi_segments/cube_pick_place.py index 1a048fd4..eef86d85 100644 --- a/embodichain_tasks/embodichain_tasks/multi_segments/cube_pick_place.py +++ b/embodichain_tasks/embodichain_tasks/multi_segments/cube_pick_place.py @@ -53,6 +53,8 @@ CARTESIAN_POSE_CAPABILITY, FORWARD_KINEMATICS_CAPABILITY, GRASP_CAPABILITY, + PickUpOptions, + PlaceOptions, RecoveryPolicy, TrackingPolicy, ) @@ -294,6 +296,10 @@ def create_cube_robot_profile_binding() -> SimulationRobotSkillProfileBinding: presets=( SkillPolicyPreset( "safe", + action_option_templates={ + "pick": PickUpOptions(), + "place": PlaceOptions(), + }, recovery_policy=RecoveryPolicy(), tracking_policy=TrackingPolicy.joint_position( in_flight_max_abs_error=0.08, diff --git a/embodichain_tasks/embodichain_tasks/tableware/open_drawer.py b/embodichain_tasks/embodichain_tasks/tableware/open_drawer.py index 2661ac88..e645e0ba 100644 --- a/embodichain_tasks/embodichain_tasks/tableware/open_drawer.py +++ b/embodichain_tasks/embodichain_tasks/tableware/open_drawer.py @@ -46,6 +46,7 @@ CARTESIAN_POSE_CAPABILITY, GRASP_CAPABILITY, JOINT_POSITION_CAPABILITY, + OperateArticulationOptions, ) from embodichain.lab.sim.skills import SceneCollisionRole, SceneDynamics from embodichain.lab.sim.skills.profiles import SkillPolicyPreset @@ -201,7 +202,14 @@ def create_open_drawer_robot_profile_binding() -> SimulationRobotSkillProfileBin defaults={ "operate_articulation": {"primary": "right_manipulator"}, }, - presets=(SkillPolicyPreset("safe"),), + presets=( + SkillPolicyPreset( + "safe", + action_option_templates={ + "operate_articulation": OperateArticulationOptions(), + }, + ), + ), default_preset="safe", ) diff --git a/tests/gym/envs/expert_program/test_environment.py b/tests/gym/envs/expert_program/test_environment.py index dcca9b24..9d6108af 100644 --- a/tests/gym/envs/expert_program/test_environment.py +++ b/tests/gym/envs/expert_program/test_environment.py @@ -68,7 +68,10 @@ GRASP_CAPABILITY, JOINT_POSITION_CAPABILITY, MotionPolicy, + OperateArticulationOptions, + PickUpOptions, PlanningContext, + PlaceOptions, RobotObservation, TaskState, ) @@ -200,6 +203,10 @@ def _robot_profile( presets={ "safe": SkillPolicyPreset( "safe", + action_option_templates={ + "pick": PickUpOptions(), + "place": PlaceOptions(), + }, motion_policy=safe_motion_policy, ) }, @@ -278,7 +285,14 @@ def resource(resource_id: str) -> RobotResource: ) for hand in ("left_hand", "right_hand") }, - presets={"safe": SkillPolicyPreset("safe")}, + presets={ + "safe": SkillPolicyPreset( + "safe", + action_option_templates={ + "operate_articulation": OperateArticulationOptions(), + }, + ) + }, default_preset="safe", ) diff --git a/tests/gym/envs/expert_program/test_simulation.py b/tests/gym/envs/expert_program/test_simulation.py index 652df470..b3a6fc66 100644 --- a/tests/gym/envs/expert_program/test_simulation.py +++ b/tests/gym/envs/expert_program/test_simulation.py @@ -44,6 +44,7 @@ ArticulationOperationAffordance, CARTESIAN_POSE_CAPABILITY, GRASP_CAPABILITY, + PickUpOptions, ) from embodichain.lab.sim.skills import ( ARTICULATION_OPERATION_AFFORDANCE_CAPABILITY, @@ -244,7 +245,12 @@ def _profile_binding() -> SimulationRobotSkillProfileBinding: ), ), defaults={"pick_up": {"primary": "manipulator"}}, - presets=(SkillPolicyPreset("safe"),), + presets=( + SkillPolicyPreset( + "safe", + action_option_templates={"pick": PickUpOptions()}, + ), + ), default_preset="safe", ) diff --git a/tests/gym/envs/expert_program/test_simulation_environment.py b/tests/gym/envs/expert_program/test_simulation_environment.py index c52e18b8..bc9b5568 100644 --- a/tests/gym/envs/expert_program/test_simulation_environment.py +++ b/tests/gym/envs/expert_program/test_simulation_environment.py @@ -68,9 +68,12 @@ FORWARD_KINEMATICS_CAPABILITY, GRASP_CAPABILITY, HeldObjectState, + HandOverOptions, MotionPolicy, ObservedArticulationJointState, PlanningContext, + PickUpOptions, + PlaceOptions, StateDelta, TaskState, TrackingPolicy, @@ -844,11 +847,21 @@ def _profile_binding() -> SimulationRobotSkillProfileBinding: presets=( SkillPolicyPreset( "safe", + action_option_templates={ + "pick": PickUpOptions(), + "place": PlaceOptions(), + }, motion_policy=MotionPolicy(control_dt=0.01), tracking_policy=TrackingPolicy.joint_position( in_flight_max_abs_error=0.037, terminal_max_abs_error=0.019, ), + runner_cfg=ExecutionRunnerCfg( + command_timeout=0.37, + safe_stop_timeout=0.61, + minimum_cycle_time=0.04, + hold_on_completion=False, + ), ), ), default_preset="safe", @@ -900,7 +913,12 @@ def _handover_profile_binding() -> SimulationRobotSkillProfileBinding: defaults={ "hand_over": {"source": "left", "destination": "right"}, }, - presets=(SkillPolicyPreset("safe"),), + presets=( + SkillPolicyPreset( + "safe", + action_option_templates={"hand_over": HandOverOptions()}, + ), + ), default_preset="safe", grounding_providers={ "hand_over": _ForwardedHandOverPoseProvider.provider_id, @@ -1032,7 +1050,19 @@ def _evidence_profile_binding() -> SimulationRobotSkillProfileBinding: "pick_up": {"primary": "manipulator"}, "place": {"primary": "manipulator"}, }, - presets=(SkillPolicyPreset("evidence"),), + presets=( + SkillPolicyPreset( + "evidence", + action_option_templates={ + "pick": PickUpOptions(), + "place": PlaceOptions(), + }, + runner_cfg=ExecutionRunnerCfg( + minimum_cycle_time=0.0, + hold_on_completion=False, + ), + ), + ), default_preset="evidence", ) @@ -1783,7 +1813,7 @@ def test_simulation_helper_assembles_mobile_endpoint_and_transport_without_joint }, ), ), - presets=(SkillPolicyPreset("runtime"),), + presets=(SkillPolicyPreset("runtime", action_option_templates={}),), default_preset="runtime", ) environment = SimpleNamespace( diff --git a/tests/sim/skills/test_articulation_semantics.py b/tests/sim/skills/test_articulation_semantics.py index 4cdcd122..ec8910bd 100644 --- a/tests/sim/skills/test_articulation_semantics.py +++ b/tests/sim/skills/test_articulation_semantics.py @@ -35,6 +35,7 @@ JOINT_POSITION_CAPABILITY, ObservedArticulationJointState, OperateArticulationGoal, + OperateArticulationOptions, PlanningContext, RobotObservation, SceneSnapshot, @@ -210,7 +211,14 @@ def _profile() -> RobotSkillProfile: grasp=torch.tensor((1.0,)), ) }, - presets={"safe": SkillPolicyPreset("safe")}, + presets={ + "safe": SkillPolicyPreset( + "safe", + action_option_templates={ + "operate_articulation": OperateArticulationOptions(), + }, + ) + }, default_preset="safe", ) diff --git a/tests/sim/skills/test_compiler.py b/tests/sim/skills/test_compiler.py index 8e5eb6d2..fdac46a7 100644 --- a/tests/sim/skills/test_compiler.py +++ b/tests/sim/skills/test_compiler.py @@ -26,6 +26,7 @@ import torch from embodichain.lab.sim.atomic_actions import ( + ActionOptions, Affordance, AntipodalAffordance, AtomicActionEngine, @@ -41,9 +42,11 @@ HeldObjectState, MotionPolicy, ObjectSemantics, + OperateArticulationOptions, PickUp, PickUpOptions, PlaceGoal, + PlaceOptions, PlanningContext, RobotObservation, SceneEntityPose, @@ -127,6 +130,33 @@ _PICK_TARGET = PickUp.descriptor() +def _action_option_templates(*, registered: bool = False) -> dict[str, object]: + """Return complete exact option declarations for the selected catalog.""" + templates: dict[str, object] = { + "pick": PickUpOptions(), + "place": PlaceOptions(), + "hand_over": HandOverOptions(), + "operate_articulation": OperateArticulationOptions(), + } + if registered: + templates["vendor.inspect"] = PickUpOptions() + return templates + + +def _preset( + preset_id: str, + *, + registered: bool = False, + **kwargs: object, +) -> SkillPolicyPreset: + """Build one complete schema-v2 test preset.""" + kwargs.setdefault( + "action_option_templates", + _action_option_templates(registered=registered), + ) + return SkillPolicyPreset(preset_id, **kwargs) + + class _PoseProvider: """Return a fixed pose while exposing observation call count.""" @@ -177,14 +207,19 @@ class _InspectLowerer(RegisteredSemanticLowerer): schema_version: ClassVar[int] = 1 target_descriptor: ClassVar[SkillDescriptor] = _PICK_TARGET + def __init__(self) -> None: + self.option_templates: list[ActionOptions] = [] + def lower( self, call: RegisteredSemanticCall, *, context: PlanningContext, bound: object, + option_template: ActionOptions, ) -> SemanticLowering: del call, context, bound + self.option_templates.append(option_template) return SemanticLowering( goal=GraspGoal( semantics=ObjectSemantics( @@ -193,7 +228,6 @@ def lower( entity_id="cube", ) ), - skill_options=PickUpOptions(), ) @@ -201,12 +235,8 @@ class _DerivedGraspGoal(GraspGoal): """Executable subclass that an extension must not smuggle into the core.""" -class _DerivedPickUpOptions(PickUpOptions): - """Options subclass that must fail the registered target contract.""" - - class _SubclassOutputLowerer(RegisteredSemanticLowerer): - """Try to bypass exact target contracts with executable subclasses.""" + """Try to bypass exact goal or preset-owned options contracts.""" call_id: ClassVar[str] = "vendor.inspect" schema_version: ClassVar[int] = 1 @@ -221,8 +251,9 @@ def lower( *, context: PlanningContext, bound: BoundSemanticCall, + option_template: ActionOptions, ) -> SemanticLowering: - del call, context, bound + del call, context, bound, option_template semantics = ObjectSemantics( affordance=AntipodalAffordance(), geometry={}, @@ -231,11 +262,10 @@ def lower( if self.output == "goal": return SemanticLowering( goal=_DerivedGraspGoal(semantics=semantics), - skill_options=PickUpOptions(), ) return SemanticLowering( goal=GraspGoal(semantics=semantics), - skill_options=_DerivedPickUpOptions(), + skill_options=PickUpOptions(pre_grasp_distance=0.99), ) @@ -352,7 +382,11 @@ def _scene_registry( return registry, (cube_provider, table_provider) -def _profile(*, preset: SkillPolicyPreset | None = None) -> RobotSkillProfile: +def _profile( + *, + preset: SkillPolicyPreset | None = None, + registered: bool = False, +) -> RobotSkillProfile: return RobotSkillProfile( profile_id="test_robot", resources={ @@ -376,12 +410,20 @@ def _profile(*, preset: SkillPolicyPreset | None = None) -> RobotSkillProfile: grasp=torch.tensor([1.0]), ) }, - presets={"safe": SkillPolicyPreset("safe") if preset is None else preset}, + presets={ + "safe": ( + _preset("safe", registered=registered) if preset is None else preset + ) + }, default_preset="safe", ) -def _dual_profile(*, provider_id: str | None = "dual_center") -> RobotSkillProfile: +def _dual_profile( + *, + provider_id: str | None = "dual_center", + preset: SkillPolicyPreset | None = None, +) -> RobotSkillProfile: resources = { side: RobotResource( resource_id=side, @@ -412,7 +454,7 @@ def _dual_profile(*, provider_id: str | None = "dual_center") -> RobotSkillProfi "pick_up": ResourceBinding({"primary": "left"}), "hand_over": ResourceBinding({"source": "left", "destination": "right"}), }, - presets={"safe": SkillPolicyPreset("safe")}, + presets={"safe": _preset("safe") if preset is None else preset}, default_preset="safe", grounding_providers=({} if provider_id is None else {"hand_over": provider_id}), ) @@ -457,7 +499,7 @@ def _integration( profile: RobotSkillProfile | None = None, supports_dynamic_collision_world: bool = False, ) -> tuple[SemanticIntegrationManifest, AtomicActionEngine]: - selected_profile = _profile() if profile is None else profile + selected_profile = _profile(registered=registered) if profile is None else profile catalog = builtin_semantic_call_catalog() if registered: assert _PICK_TARGET.binding_contract is not None @@ -588,7 +630,7 @@ def test_curated_analysis_selects_exact_preset_monitor_without_creating_it() -> def test_curated_analysis_rejects_explicitly_missing_monitor() -> None: registry, _ = _scene_registry() profile = _profile( - preset=SkillPolicyPreset("safe", effect_monitors={}), + preset=_preset("safe", effect_monitors={}), ) compiler, _ = _compiler(registry, profile=profile) @@ -602,7 +644,7 @@ def test_uninstalled_effect_monitor_fails_analysis_without_factory_creation() -> registry, providers = _scene_registry() factory = _CountingRelationMonitorFactory() profile = _profile( - preset=SkillPolicyPreset( + preset=_preset( "safe", effect_monitors={ "pick": EffectMonitorRef("test.not_installed", "1"), @@ -627,7 +669,7 @@ def test_invalid_effect_monitor_config_fails_analysis_without_side_effects() -> registry, providers = _scene_registry() factory = _CountingRelationMonitorFactory() profile = _profile( - preset=SkillPolicyPreset( + preset=_preset( "safe", effect_monitors={ "pick": EffectMonitorRef( @@ -839,10 +881,21 @@ def test_handover_effect_spec_binds_source_and_destination_relations() -> None: def test_registered_call_without_monitor_has_no_effect_contract() -> None: registry, _ = _scene_registry() factory = _CountingRelationMonitorFactory() + templates = _action_option_templates(registered=True) + templates["vendor.inspect"] = PickUpOptions(pre_grasp_distance=0.07) + profile = _profile( + preset=_preset( + "safe", + registered=True, + action_option_templates=templates, + ) + ) + lowerer = _InspectLowerer() compiler, _ = _compiler( registry, registered=True, - registered_lowerers=(_InspectLowerer(),), + registered_lowerers=(lowerer,), + profile=profile, effect_monitor_registry=EffectMonitorRegistry((factory,)), ) workflow = compiler.analyze((RegisteredSemanticCall(call_id="vendor.inspect"),)) @@ -854,14 +907,21 @@ def test_registered_call_without_monitor_has_no_effect_contract() -> None: assert workflow.calls[0].effect_monitor_ref is None assert grounded.effect_spec is None assert grounded.effect_monitor is None + options = grounded.invocation.skill_options + assert type(options) is PickUpOptions + assert options.pre_grasp_distance == 0.07 + assert len(lowerer.option_templates) == 1 + assert lowerer.option_templates[0] is not options + assert type(lowerer.option_templates[0]) is PickUpOptions assert factory.calls == 0 def test_registered_monitor_without_effect_grounder_fails_during_analysis() -> None: registry, _ = _scene_registry() profile = _profile( - preset=SkillPolicyPreset( + preset=_preset( "safe", + registered=True, effect_monitors={ "vendor.inspect": EffectMonitorRef( COMPOSITE_EFFECT_MONITOR_ID, @@ -903,7 +963,17 @@ def test_ground_wraps_effect_monitor_factory_contract_failure_with_path() -> Non def test_analysis_is_provider_free_and_propagates_object_target() -> None: registry, providers = _scene_registry() - compiler, engine = _compiler(registry) + templates = _action_option_templates() + templates["pick"] = PickUpOptions( + pick_object_part="top", + pre_grasp_distance=0.08, + ) + compiler, engine = _compiler( + registry, + profile=_profile( + preset=_preset("safe", action_option_templates=templates), + ), + ) drop = SemanticPose((0.4, 0.2, 0.3), (1.0, 0.0, 0.0, 0.0)) workflow = compiler.analyze( @@ -923,6 +993,8 @@ def test_analysis_is_provider_free_and_propagates_object_target() -> None: assert grounded.invocation.goal.semantics.entity_id == "cube" options = grounded.invocation.skill_options assert type(options) is PickUpOptions + assert options.pick_object_part == "top" + assert options.pre_grasp_distance == 0.08 torch.testing.assert_close( options.downstream_object_target_poses[0], drop.to_matrix(), @@ -933,7 +1005,7 @@ def test_analysis_is_provider_free_and_propagates_object_target() -> None: def test_grounded_safe_invocation_requires_registered_dynamic_collision() -> None: registry, _ = _scene_registry(dynamic_collision=True) profile = _profile( - preset=SkillPolicyPreset( + preset=_preset( "safe", motion_policy=MotionPolicy(strategy="motion_gen"), tracking_policy=TrackingPolicy.joint_position( @@ -1048,7 +1120,14 @@ def fail_after_capture( def test_handover_uses_profile_selected_named_provider_and_stops_lookahead() -> None: registry, providers = _scene_registry() - profile = _dual_profile() + templates = _action_option_templates() + templates["hand_over"] = HandOverOptions( + receive_pick_object_part="top", + pre_grasp_distance=0.06, + ) + profile = _dual_profile( + preset=_preset("safe", action_option_templates=templates), + ) manifest = SemanticIntegrationManifest( scene=SceneManifest.from_registry(registry), robot_profile=profile, @@ -1101,6 +1180,8 @@ def test_handover_uses_profile_selected_named_provider_and_stops_lookahead() -> assert provider.calls == 2 options = handover.invocation.skill_options assert type(options) is HandOverOptions + assert options.receive_pick_object_part == "top" + assert options.pre_grasp_distance == 0.06 assert type(options.middle_object_pose) is SceneEntityPose assert options.middle_object_pose.entity_id == "table_top" assert options.final_object_pose[0, 3].item() == pytest.approx(0.8) @@ -1192,7 +1273,17 @@ def test_relation_call_requires_exact_typed_versioned_grounder() -> None: def test_place_uses_verified_object_to_eef_transform() -> None: registry, _ = _scene_registry() - compiler, engine = _compiler(registry) + templates = _action_option_templates() + templates["place"] = PlaceOptions( + lift_height=0.22, + cartesian_waypoint_count=3, + ) + compiler, engine = _compiler( + registry, + profile=_profile( + preset=_preset("safe", action_option_templates=templates), + ), + ) drop = SemanticPose((0.5, -0.2, 0.4), (1.0, 0.0, 0.0, 0.0)) workflow = compiler.analyze((Place(object=SceneObjectRef("cube"), at=drop),)) pick_workflow = compiler.analyze((Pick(object=SceneObjectRef("cube")),)) @@ -1208,6 +1299,10 @@ def test_place_uses_verified_object_to_eef_transform() -> None: grounded = compiler.ground(workflow, 0, context) assert type(grounded.invocation.goal) is PlaceGoal + options = grounded.invocation.skill_options + assert type(options) is PlaceOptions + assert options.lift_height == 0.22 + assert options.cartesian_waypoint_count == 3 expected = torch.bmm(drop.to_matrix().repeat(2, 1, 1), object_to_eef) torch.testing.assert_close(grounded.invocation.goal.xpos, expected) engine.resolve(grounded.invocation) @@ -1332,8 +1427,14 @@ def test_registered_lowerer_is_explicit_and_opaque_to_lookahead() -> None: engine.resolve(grounded.invocation) -@pytest.mark.parametrize("output", ["goal", "options"]) -def test_registered_lowerer_cannot_return_target_subclasses(output: str) -> None: +@pytest.mark.parametrize( + ("output", "message"), + (("goal", "produced"), ("options", "must not return skill_options")), +) +def test_registered_lowerer_cannot_replace_owned_contracts( + output: str, + message: str, +) -> None: registry, _ = _scene_registry() compiler, _ = _compiler( registry, @@ -1342,7 +1443,7 @@ def test_registered_lowerer_cannot_return_target_subclasses(output: str) -> None ) workflow = compiler.analyze((RegisteredSemanticCall(call_id="vendor.inspect"),)) - with pytest.raises(TypeError, match="produced|incompatible"): + with pytest.raises(TypeError, match=message): compiler.ground(workflow, 0, _context(registry)) diff --git a/tests/sim/skills/test_curobo_semantic_runtime_dynamic_recovery_gpu.py b/tests/sim/skills/test_curobo_semantic_runtime_dynamic_recovery_gpu.py index 0aa252c9..90b4a092 100644 --- a/tests/sim/skills/test_curobo_semantic_runtime_dynamic_recovery_gpu.py +++ b/tests/sim/skills/test_curobo_semantic_runtime_dynamic_recovery_gpu.py @@ -39,6 +39,7 @@ ExecutionRunnerCfg, MotionPolicy, MoveEndEffector, + MoveEndEffectorOptions, PlanningContext, RecoveryPolicy, RuntimeCommandFrame, @@ -114,8 +115,9 @@ def lower( *, context: PlanningContext, bound: BoundSemanticCall, + option_template: MoveEndEffectorOptions, ) -> SemanticLowering: - del bound + del bound, option_template values = call.arguments.get("xpos") if type(values) is not tuple or len(values) != 16: raise ValueError("xpos must contain one flattened 4x4 pose matrix.") @@ -181,6 +183,9 @@ def _profile() -> RobotSkillProfile: presets={ "safe": SkillPolicyPreset( "safe", + action_option_templates={ + CALL_ID: MoveEndEffectorOptions(), + }, motion_policy=MotionPolicy( strategy="motion_gen", sample_count=SAMPLE_COUNT, diff --git a/tests/sim/skills/test_integration.py b/tests/sim/skills/test_integration.py index 8a32e3bf..1423fb13 100644 --- a/tests/sim/skills/test_integration.py +++ b/tests/sim/skills/test_integration.py @@ -34,7 +34,11 @@ EntityState, FORWARD_KINEMATICS_CAPABILITY, GRASP_CAPABILITY, + HandOverOptions, MotionPolicy, + OperateArticulationOptions, + PickUpOptions, + PlaceOptions, ) from embodichain.lab.sim.skills.calls import ( Pick, @@ -80,6 +84,22 @@ ) +def _action_option_templates() -> dict[str, object]: + """Return exact built-in semantic-call option declarations.""" + return { + "pick": PickUpOptions(), + "place": PlaceOptions(), + "hand_over": HandOverOptions(), + "operate_articulation": OperateArticulationOptions(), + } + + +def _preset(preset_id: str, **kwargs: object) -> SkillPolicyPreset: + """Build one complete schema-v2 test preset.""" + kwargs.setdefault("action_option_templates", _action_option_templates()) + return SkillPolicyPreset(preset_id, **kwargs) + + class _NeverObservedStateProvider: """Fail if provider-backed state leaks into static validation.""" @@ -179,7 +199,7 @@ def _semantic_integration( skill_presets: dict[str, str] | None = None, runtime_preset: str | None = None, ) -> SemanticIntegrationManifest: - selected_preset = SkillPolicyPreset("safe") if preset is None else preset + selected_preset = _preset("safe") if preset is None else preset presets = {selected_preset.preset_id: selected_preset} presets.update( { @@ -429,7 +449,7 @@ def test_semantic_integration_rejects_monitor_for_unknown_call_with_path() -> No with pytest.raises(SemanticValidationError) as error: _semantic_integration( registry, - preset=SkillPolicyPreset( + preset=_preset( "safe", effect_monitors={ unknown_semantic_id: EffectMonitorRef("test.monitor", "1") @@ -452,6 +472,98 @@ def test_semantic_integration_rejects_monitor_for_unknown_call_with_path() -> No ) +def test_semantic_integration_rejects_unknown_action_option_call() -> None: + registry, _ = _scene_registry(with_default=True) + + with pytest.raises(SemanticValidationError) as error: + _semantic_integration( + registry, + preset=SkillPolicyPreset( + "safe", + action_option_templates={"vendor.unknown": PickUpOptions()}, + ), + ) + + diagnostic = error.value.diagnostic + assert diagnostic.code == "unknown_action_option_call" + assert diagnostic.path[-2:] == ( + "action_option_templates", + "vendor.unknown", + ) + + +def test_semantic_integration_validates_exact_action_option_type() -> None: + registry, _ = _scene_registry(with_default=True) + + with pytest.raises(SemanticValidationError) as error: + _semantic_integration( + registry, + preset=SkillPolicyPreset( + "safe", + action_option_templates={"pick": PlaceOptions()}, + ), + ) + + assert error.value.diagnostic.code == "incompatible_action_option_template" + assert error.value.diagnostic.path[-1] == "pick" + + +def test_semantic_integration_rejects_compiler_owned_option_fields() -> None: + registry, _ = _scene_registry(with_default=True) + + with pytest.raises(SemanticValidationError) as pick_error: + _semantic_integration( + registry, + preset=SkillPolicyPreset( + "safe", + action_option_templates={ + "pick": PickUpOptions( + downstream_object_target_poses=(torch.eye(4),) + ) + }, + ), + ) + assert pick_error.value.diagnostic.code == "reserved_action_option_field" + assert pick_error.value.diagnostic.path[-1] == ("downstream_object_target_poses") + + with pytest.raises(SemanticValidationError) as handover_error: + _semantic_integration( + registry, + preset=SkillPolicyPreset( + "safe", + action_option_templates={ + "hand_over": HandOverOptions( + middle_object_pose=torch.eye(4), + ) + }, + ), + ) + assert handover_error.value.diagnostic.code == "reserved_action_option_field" + assert handover_error.value.diagnostic.path[-1] == "middle_object_pose" + + +def test_static_link_requires_selected_preset_action_option_template() -> None: + registry, _ = _scene_registry(with_default=True) + integration = _semantic_integration( + registry, + preset=SkillPolicyPreset("safe", action_option_templates={}), + ) + + with pytest.raises(SemanticValidationError) as error: + integration.link_call(Pick(object=SceneObjectRef("cube"))) + + assert error.value.diagnostic.code == "missing_action_option_template" + assert error.value.diagnostic.path == ( + "integration", + "robot_profile", + "presets", + "safe", + "action_option_templates", + "pick", + ) + assert "selected at call" in error.value.diagnostic.message + + def test_scene_manifest_reports_structured_pathful_diagnostic() -> None: manifest = SceneManifest((SceneEntityManifest(ref=SceneObjectRef("cube")),)) @@ -597,7 +709,7 @@ def test_safe_preset_requires_dynamic_collision_for_dynamic_scene( ) integration = _semantic_integration( registry, - preset=SkillPolicyPreset( + preset=_preset( "safe", motion_policy=MotionPolicy( strategy="motion_gen", @@ -632,7 +744,7 @@ def test_safe_preset_rejects_unsupported_dynamic_planner_before_observation() -> ) integration = _semantic_integration( registry, - preset=SkillPolicyPreset( + preset=_preset( "safe", motion_policy=MotionPolicy(strategy="motion_gen"), ), @@ -668,9 +780,9 @@ def test_per_skill_safe_preset_is_conservatively_preflighted() -> None: pick_skill_id = builtin_semantic_call_catalog().descriptors["pick"].skill_id integration = _semantic_integration( registry, - preset=SkillPolicyPreset("fast"), + preset=_preset("fast"), additional_presets=( - SkillPolicyPreset( + _preset( "safe", motion_policy=MotionPolicy(strategy="motion_gen"), ), @@ -694,11 +806,11 @@ def test_fully_overridden_safe_default_is_not_reachable() -> None: catalog = builtin_semantic_call_catalog() integration = _semantic_integration( registry, - preset=SkillPolicyPreset( + preset=_preset( "safe", motion_policy=MotionPolicy(strategy="motion_gen"), ), - additional_presets=(SkillPolicyPreset("fast"),), + additional_presets=(_preset("fast"),), skill_presets={ descriptor.skill_id: "fast" for descriptor in catalog.descriptors.values() }, @@ -720,11 +832,11 @@ def test_runtime_non_safe_override_makes_safe_default_unreachable() -> None: ) integration = _semantic_integration( registry, - preset=SkillPolicyPreset( + preset=_preset( "safe", motion_policy=MotionPolicy(strategy="motion_gen"), ), - additional_presets=(SkillPolicyPreset("fast"),), + additional_presets=(_preset("fast"),), runtime_preset="fast", ) engine = _engine_for_integration(integration) @@ -744,7 +856,7 @@ def test_bound_integration_cannot_bypass_safe_dynamic_planner_preflight() -> Non ) integration = _semantic_integration( registry, - preset=SkillPolicyPreset( + preset=_preset( "safe", motion_policy=MotionPolicy(strategy="motion_gen"), ), @@ -772,7 +884,7 @@ def test_bind_rejects_invalid_engine_before_safe_capability_lookup() -> None: ) integration = _semantic_integration( registry, - preset=SkillPolicyPreset( + preset=_preset( "safe", motion_policy=MotionPolicy(strategy="motion_gen"), ), @@ -791,7 +903,7 @@ def test_safe_preset_rejects_non_motion_generator_strategy_for_dynamic_scene() - ) integration = _semantic_integration( registry, - preset=SkillPolicyPreset( + preset=_preset( "safe", motion_policy=MotionPolicy(strategy="ik_interp"), ), @@ -822,7 +934,7 @@ def test_non_safe_preset_preserves_dynamic_collision_policy( ) integration = _semantic_integration( registry, - preset=SkillPolicyPreset( + preset=_preset( "fast", motion_policy=MotionPolicy(dynamic_collision_mode=source_mode), ), @@ -848,7 +960,7 @@ def test_safe_preset_preserves_policy_without_dynamic_collision( registry, provider = _scene_registry(with_default=True) integration = _semantic_integration( registry, - preset=SkillPolicyPreset( + preset=_preset( "safe", motion_policy=MotionPolicy(dynamic_collision_mode=source_mode), ), diff --git a/tests/sim/skills/test_profiles.py b/tests/sim/skills/test_profiles.py index 5400a54a..1156786b 100644 --- a/tests/sim/skills/test_profiles.py +++ b/tests/sim/skills/test_profiles.py @@ -45,6 +45,7 @@ JointPositionGoal, MotionPolicy, OPEN_COMMAND, + PickUpOptions, ResolvedActionRequest, SkillBindingContract, SkillEndpointRequirement, @@ -70,6 +71,8 @@ ControlPartEndpoint, ControlPartEndpointAdapter, ControlPartEvidenceAddress, + CONTROL_PART_EVIDENCE_PROVIDER_ID, + CONTROL_PART_EVIDENCE_PROVIDER_REVISION, EffectEvidenceSourceRef, EffectMonitorRef, EndpointResolution, @@ -519,6 +522,29 @@ def test_endpoint_resolution_owns_and_freezes_effect_sources() -> None: resolution.effect_sources["new"] = source # type: ignore[index] +def test_control_part_adapter_declares_every_builtin_integration_route() -> None: + adapter = ControlPartEndpointAdapter + + assert adapter.runtime_transport_ids == frozenset( + {JointPositionTarget.TRANSPORT_ID} + ) + assert adapter.runtime_target_types == (JointPositionTarget,) + assert adapter.tracking_feedback_source_keys == frozenset( + {("planning_context.robot", "1")} + ) + assert adapter.tracking_projector_keys == frozenset( + {("joint_position_payload", "1")} + ) + assert adapter.effect_evidence_source_keys == frozenset( + { + ( + CONTROL_PART_EVIDENCE_PROVIDER_ID, + CONTROL_PART_EVIDENCE_PROVIDER_REVISION, + ) + } + ) + + @pytest.mark.parametrize("returns_self", [False, True]) def test_endpoint_resolution_rejects_invalid_target_snapshot( returns_self: bool, @@ -1380,6 +1406,9 @@ def test_generic_profile_supports_base_and_whole_body_without_arm_tool_fields() def test_presets_are_versioned_snapshots_and_validate_planner() -> None: preset = SkillPolicyPreset( "safe", + action_option_templates={ + "pick": PickUpOptions(pre_grasp_distance=0.08), + }, motion_policy=MotionPolicy(planner="stub_planner", sample_count=80), tracking_policy=TrackingPolicy.joint_position( in_flight_max_abs_error=0.125, @@ -1400,9 +1429,16 @@ def test_presets_are_versioned_snapshots_and_validate_planner() -> None: second = bound.preset() assert first is not second - assert first.schema_version == 1 + assert first.schema_version == 2 assert first.motion_policy.sample_count == 80 assert first.tracking_policy is not second.tracking_policy + assert first.action_option_templates["pick"] is not ( + second.action_option_templates["pick"] + ) + assert ( + first.action_option_templates["pick"].pre_grasp_distance # type: ignore[attr-defined] + == 0.08 + ) first_tracking = first.tracking_policy.in_flight assert first_tracking is not None assert isinstance(first_tracking.metrics[0], JointPositionTrackingMetric) @@ -1414,8 +1450,8 @@ def test_presets_are_versioned_snapshots_and_validate_planner() -> None: bound.preset(skill_id="typo") with pytest.raises(KeyError, match="not an installed"): bound.preset("safe", skill_id="typo") - with pytest.raises(ValueError, match=r"supported versions are \[1\]"): - SkillPolicyPreset("future", schema_version=2) + with pytest.raises(ValueError, match=r"supported versions are \[2\]"): + SkillPolicyPreset("legacy", action_option_templates={}, schema_version=1) incompatible = RobotSkillProfile( "bad_preset", @@ -1424,6 +1460,7 @@ def test_presets_are_versioned_snapshots_and_validate_planner() -> None: presets={ "other": SkillPolicyPreset( "other", + action_option_templates={}, motion_policy=MotionPolicy(planner="other_planner"), ) }, @@ -1433,7 +1470,7 @@ def test_presets_are_versioned_snapshots_and_validate_planner() -> None: def test_policy_preset_defaults_exact_builtin_effect_monitor_refs() -> None: - preset = SkillPolicyPreset("safe") + preset = SkillPolicyPreset("safe", action_option_templates={}) assert set(preset.effect_monitors) == { "pick", @@ -1448,7 +1485,11 @@ def test_policy_preset_defaults_exact_builtin_effect_monitor_refs() -> None: def test_policy_preset_distinguishes_explicit_empty_effect_monitor_mapping() -> None: - preset = SkillPolicyPreset("unmonitored", effect_monitors={}) + preset = SkillPolicyPreset( + "unmonitored", + action_option_templates={}, + effect_monitors={}, + ) assert dict(preset.effect_monitors) == {} assert dict(preset.snapshot().effect_monitors) == {} @@ -1461,7 +1502,11 @@ def test_policy_preset_owns_and_snapshots_effect_monitor_refs() -> None: } source_ref = EffectMonitorRef("test.monitor", "2", source_params) source_mapping = {"pick": source_ref} - preset = SkillPolicyPreset("custom", effect_monitors=source_mapping) + preset = SkillPolicyPreset( + "custom", + action_option_templates={}, + effect_monitors=source_mapping, + ) source_params["consecutive_samples"] = 99 source_params["metadata"][1]["source"] = "mutated" # type: ignore[index] @@ -1485,6 +1530,108 @@ def test_policy_preset_owns_and_snapshots_effect_monitor_refs() -> None: first["pick"].params["consecutive_samples"] = 4 # type: ignore[index] +def test_policy_preset_owns_and_freezes_action_option_templates() -> None: + direction = torch.tensor([0.0, 1.0, 0.0]) + source = PickUpOptions( + pick_object_part="top", + approach_direction=direction, + ) + source_mapping = {"pick": source} + preset = SkillPolicyPreset( + "custom", + action_option_templates=source_mapping, + ) + + direction.fill_(9.0) + source.approach_direction.fill_(8.0) + source_mapping.clear() + first = preset.action_option_templates + second = preset.snapshot().action_option_templates + selected = preset.action_option_template("pick") + + assert type(first["pick"]) is PickUpOptions + assert first["pick"] is not source + assert second["pick"] is not first["pick"] + assert selected is not first["pick"] + assert first["pick"].pick_object_part == "top" # type: ignore[attr-defined] + torch.testing.assert_close( + first["pick"].approach_direction, # type: ignore[attr-defined] + torch.tensor([0.0, 1.0, 0.0]), + ) + with pytest.raises(TypeError): + first["place"] = PickUpOptions() # type: ignore[index] + with pytest.raises(KeyError, match="no action-option template"): + preset.action_option_template("place") + + +def test_policy_preset_allows_empty_templates_but_rejects_invalid_values() -> None: + with pytest.raises(TypeError, match="action_option_templates"): + SkillPolicyPreset("missing") # type: ignore[call-arg] + + assert ( + dict( + SkillPolicyPreset( + "empty", action_option_templates={} + ).action_option_templates + ) + == {} + ) + + with pytest.raises(TypeError, match="ActionOptions"): + SkillPolicyPreset( + "invalid", + action_option_templates={"pick": object()}, # type: ignore[dict-item] + ) + + +def test_policy_preset_rejects_inherited_action_options_with_extra_slot_state() -> None: + class InheritedOptions(PickUpOptions): + __slots__ = ("runtime_cache",) + + options = InheritedOptions() + object.__setattr__(options, "runtime_cache", ["live"]) + + with pytest.raises(TypeError, match="exact frozen @dataclass"): + SkillPolicyPreset( + "invalid", + action_option_templates={"pick": options}, + ) + + +def test_policy_preset_rejects_deepcopy_with_nested_mutable_aliases() -> None: + @dataclass(frozen=True, slots=True) + class AliasingOptions(ActionOptions): + values: list[int] + + def __deepcopy__(self, memo: dict[int, object]) -> AliasingOptions: + del memo + return type(self)(self.values) + + with pytest.raises(TypeError, match="without shared mutable objects"): + SkillPolicyPreset( + "invalid", + action_option_templates={"vendor.alias": AliasingOptions([1])}, + ) + + +def test_policy_preset_rejects_deepcopy_with_shared_tensor_storage() -> None: + @dataclass(frozen=True, slots=True) + class TensorViewOptions(ActionOptions): + values: torch.Tensor + + def __deepcopy__(self, memo: dict[int, object]) -> TensorViewOptions: + del memo + return type(self)(self.values.view_as(self.values)) + + with pytest.raises(TypeError, match="tensor storage"): + SkillPolicyPreset( + "invalid", + action_option_templates={ + "vendor.tensor_alias": TensorViewOptions(torch.ones(2)) + }, + ) + + def test_profile_owns_named_grounding_provider_selections() -> None: selections = {"hand_over": "dual_center"} profile = RobotSkillProfile( From f167a2a68fde74e4507ed514a7ac82e17384f04e Mon Sep 17 00:00:00 2001 From: yuecideng Date: Tue, 11 Aug 2026 18:11:00 +0800 Subject: [PATCH 4/4] feat(expert-program): own standard runtime extensions --- .../design/declarative_expert_program_plan.md | 27 + .../sim/atomic_actions/expert_programs.md | 39 +- .../atomic_actions/robot_skill_profiles.md | 28 +- .../lab/gym/envs/expert_program/__init__.py | 14 + .../lab/gym/envs/expert_program/bridge.py | 149 ++- .../lab/gym/envs/expert_program/catalog.py | 530 +++++++++- .../gym/envs/expert_program/environment.py | 268 +++++- .../lab/gym/envs/expert_program/extensions.py | 908 ++++++++++++++++++ .../expert_program/simulation_environment.py | 168 +--- .../lab/sim/skills/parallel_runtime.py | 58 +- tests/gym/envs/expert_program/test_bridge.py | 150 ++- tests/gym/envs/expert_program/test_catalog.py | 340 ++++++- .../envs/expert_program/test_extensions.py | 542 +++++++++++ .../test_simulation_environment.py | 844 +++++++++++++++- tests/sim/skills/test_parallel_runtime.py | 148 ++- 15 files changed, 3961 insertions(+), 252 deletions(-) create mode 100644 embodichain/lab/gym/envs/expert_program/extensions.py create mode 100644 tests/gym/envs/expert_program/test_extensions.py diff --git a/docs/design/declarative_expert_program_plan.md b/docs/design/declarative_expert_program_plan.md index 7a2ac157..e5224082 100644 --- a/docs/design/declarative_expert_program_plan.md +++ b/docs/design/declarative_expert_program_plan.md @@ -1270,6 +1270,33 @@ validator and parallel physical integration remain pending. The PourWater task migration is outside the current scope because it would require modifying Action Bank code. +The current follow-up also makes task registration the sole standard-runtime +extension owner. `SkillPolicyPreset` schema version 2 requires exact typed +action-option templates for every reachable semantic call; lowering may fill +only explicitly compiler-owned dynamic target fields. Endpoint adapters, +ordered Gym transports, and a parallel-safety factory are declared on +`SimulationExpertProgramRegistration`, enter its provider-free fingerprint, +and are cross-checked again against live endpoint resolution. The standard +factory consumes the same registration objects, freezes the assembled command +encoder, takes runner timing from the selected preset, and creates a fresh live +safety validator for every runtime assembly. No helper argument can replace +those registered components after preflight. Stateful extension declarations +must be frozen dataclasses with recursively immutable configuration, preventing +nested mutable values from becoming a post-registration runtime side channel. + +This registration slice deliberately covers command transport, not arbitrary +closed-loop backend injection. In C1, every custom endpoint adapter must declare +empty tracking and effect-evidence route sets and therefore supports only +timed/open-loop completion. The built-in `ControlPartEndpoint` retains its exact +built-in routes. A non-joint feedback provider, desired-state projector, metric +evaluator, or effect-evidence backend needs a separate registration-owned +live-provider factory contract before it can be advertised as standard +mobile/whole-body closed-loop support. Such providers must become fingerprinted +capabilities; they must not return as task-side runtime callbacks. Transport +`hold()` remains a trusted safe primitive owned and tested by each transport, +while the parallel safety validator authorizes active merged command frames +before dispatch. + Deliverables: - `Parallel` and explicit `Barrier` nodes in a new schema version; diff --git a/docs/source/overview/sim/atomic_actions/expert_programs.md b/docs/source/overview/sim/atomic_actions/expert_programs.md index 532138d9..3ba563d8 100644 --- a/docs/source/overview/sim/atomic_actions/expert_programs.md +++ b/docs/source/overview/sim/atomic_actions/expert_programs.md @@ -127,13 +127,18 @@ task then delegates runtime assembly to the shared factory; it does not construct approach, grasp, pull, or placement trajectories: ```python +MY_EXPERT_PROGRAM_REGISTRATION = SimulationExpertProgramRegistration( + scene_binding=create_my_scene_binding(), + robot_profile_binding=create_my_robot_profile_binding(), +) + + class MyTaskEnv(ExpertProgramEnvironmentMixin, EmbodiedEnv): def __init__(self, cfg, **kwargs): super().__init__(cfg, **kwargs) self._expert_program_adapter = create_simulation_expert_program_adapter( self, - scene_binding=create_my_scene_binding(), - robot_profile_binding=create_my_robot_profile_binding(), + registration=MY_EXPERT_PROGRAM_REGISTRATION, ) @property @@ -149,8 +154,20 @@ monitor selection. `SimulationRobotSkillProfileBinding` accepts generic `ResourceEndpoint` values; `ControlPartResourceBinding` is its stricter joint-backed convenience. Endpoint adapters and runtime transports are the extension boundary for mobile-base, whole-body, or non-joint controllers and -are accepted by the standard simulation helper. Task programs keep the same -semantic calls and do not gain controller-shaped fields. +are owned by `SimulationExpertProgramRegistration`, not passed as live helper +overrides. Their exact static target, payload, route, and transport declarations +enter the catalog fingerprint, while transport tuple order defines deterministic +Gym-action composition order. Task programs keep the same semantic calls and do +not gain controller-shaped fields. + +The current standard registration installs built-in joint tracking and effect +evidence providers only for `ControlPartEndpoint`. Every custom endpoint adapter +must declare empty tracking/evidence route sets and therefore uses +timed/open-loop completion. A non-joint closed-loop projector, feedback source, +or effect-evidence backend still requires the planned registration-owned +provider-factory extension; it must not be injected from a task after preflight. +Whole-body controllers expressed through existing joint control parts continue +to use the built-in joint route. Relation and rendezvous semantics are also explicit integration capabilities. `Place(on=...)` and `Place(inside=...)` require an exact typed/versioned @@ -185,9 +202,12 @@ of evidence: - the live object-to-endpoint pose relation from the shared scene snapshot. The command-state update is transactional: encoder, buffer, cancellation, or -safe-stop failures invalidate it. An integration with contact, constraint, -force, or wrench sensing can install typed evidence callbacks without changing -the semantic call or program. +safe-stop failures invalidate it. The current C1 standard path does not accept +task-side evidence callbacks: custom endpoint adapters must expose empty +tracking and effect-evidence route sets. Contact, constraint, force, wrench, or +other custom closed-loop sensing requires a future registration-owned provider +factory whose declaration enters the integration fingerprint; this will not +change the semantic call or program. Program/demo-segment metadata records runtime call results, named trajectory segments, effect decisions, recovery events, scene and collision revisions, @@ -199,7 +219,10 @@ Schema-version-2 parallel blocks additionally require an authoritative `ParallelCommandSafetyValidator`. Resource-claim disjointness is necessary but is not treated as proof of physical safety. If no validator is installed, the parallel block refuses to start; the standard simulation adapter intentionally -does not invent one from resource names. Every parallel frame must occupy +does not invent one from resource names. Its task registration must instead +declare a safety factory covering the exact registered transport set; each +runtime assembly receives a fresh validator instance from that factory. Every +parallel frame must occupy exactly one `BaseEnv.step_dt`; shorter lanes repeat their last safe target as hold padding, while fractional frames are rejected rather than resampled. Version 2 also uses strict symbolic key-level conflict detection at the barrier: diff --git a/docs/source/overview/sim/atomic_actions/robot_skill_profiles.md b/docs/source/overview/sim/atomic_actions/robot_skill_profiles.md index fc84034b..9e827d7e 100644 --- a/docs/source/overview/sim/atomic_actions/robot_skill_profiles.md +++ b/docs/source/overview/sim/atomic_actions/robot_skill_profiles.md @@ -387,13 +387,33 @@ profile = SimulationRobotSkillProfileBinding( adapter = create_simulation_expert_program_adapter( env, - scene_binding=scene_binding, - robot_profile_binding=profile, - endpoint_adapters={MobileVelocityEndpoint: MobileVelocityEndpointAdapter()}, - runtime_transports=(MobileVelocityGymEncoder(),), + registration=SimulationExpertProgramRegistration( + scene_binding=scene_binding, + robot_profile_binding=profile, + endpoint_adapters=(MobileVelocityEndpointAdapter(),), + runtime_transports=(MobileVelocityGymEncoder(),), + ), ) ``` +The adapter and encoder publish exact class-level declarations before a live +robot is created. The adapter declares its endpoint type, runtime target types, +transport IDs, and versioned tracking/evidence routes. The encoder declares its +transport ID plus exact target and payload types; each target and payload type +declares the same `TRANSPORT_ID`. Registration rejects missing, unused, +duplicate, or conflicting declarations, and runtime profile binding verifies +that `adapter.resolve()` returns only those declared routes. A stateful adapter, +transport, grounding provider, or safety factory must be a frozen dataclass whose +configuration is recursively immutable; mutable leaves such as lists, mappings, +sets, byte arrays, and tensors are rejected before registration. + +The standard factory currently accepts its built-in tracking feedback, +projector, evaluator, and effect-evidence routes only for +`ControlPartEndpoint`. In C1, every custom endpoint adapter must declare empty +route sets and therefore supports timed/open-loop execution only. Custom +closed-loop mobile or whole-body tracking/evidence needs a registration-owned +provider factory in C2; task code must not supply a live provider side channel. + `RobotResourceBinding` snapshots arbitrary typed `ResourceEndpoint` values. `ControlPartResourceBinding` remains the stricter joint-backed convenience and continues to validate native control parts, joint IDs, and command-preset diff --git a/embodichain/lab/gym/envs/expert_program/__init__.py b/embodichain/lab/gym/envs/expert_program/__init__.py index 6a39b35e..a3cae517 100644 --- a/embodichain/lab/gym/envs/expert_program/__init__.py +++ b/embodichain/lab/gym/envs/expert_program/__init__.py @@ -135,6 +135,14 @@ IntegrationFingerprintMismatch, SimulationExpertProgramRegistration, ) +from .extensions import ( + EndpointAdapterDeclaration, + ParallelCommandSafetyValidatorFactory, + ParallelSafetyDeclaration, + RuntimeTransportDeclaration, + StandardExtensionDeclarations, + VersionedKey, +) from .simulation_environment import ( ControlCommandStateEvidenceTracker, MotionGeneratorFactory, @@ -182,6 +190,7 @@ "EXPERT_PROGRAM_SCHEMA_VERSION_V2", "EnvironmentStepClock", "EnvironmentStepTimingError", + "EndpointAdapterDeclaration", "ExpertProgramCfg", "ExpertProgramCompileError", "ExpertProgramCompiler", @@ -212,6 +221,8 @@ "ObjectNearTargetValidatorCfg", "OperateArticulationCfg", "ParallelCfg", + "ParallelCommandSafetyValidatorFactory", + "ParallelSafetyDeclaration", "PickCfg", "PlaceCfg", "PlanningObservationPort", @@ -222,6 +233,7 @@ "RepeatCfg", "RobotResourceBinding", "RuntimeCommandFrameEncoder", + "RuntimeTransportDeclaration", "RuntimeTransportActionEncoder", "SceneReferenceRole", "SceneRegistryProgramResolver", @@ -246,11 +258,13 @@ "SimulationRobotSkillProfileBinding", "SimulationSceneBinding", "SimulationSegmentPolicyPort", + "StandardExtensionDeclarations", "SUPPORTED_EXPERT_PROGRAM_SCHEMA_VERSIONS", "TargetCfg", "TargetRefCfg", "UnsupportedRuntimeTransportError", "ValidatorCfg", + "VersionedKey", "WaitStablePostCfg", "create_simulation_expert_program_adapter", "default_simulation_settle_presets", diff --git a/embodichain/lab/gym/envs/expert_program/bridge.py b/embodichain/lab/gym/envs/expert_program/bridge.py index f72e616d..aeb2506a 100644 --- a/embodichain/lab/gym/envs/expert_program/bridge.py +++ b/embodichain/lab/gym/envs/expert_program/bridge.py @@ -27,9 +27,10 @@ from collections import deque from collections.abc import Callable, Iterable, Iterator, Mapping +from copy import deepcopy from dataclasses import dataclass, field import math -from typing import Any, Protocol, runtime_checkable +from typing import Any, ClassVar, Protocol, runtime_checkable import torch @@ -41,11 +42,13 @@ from embodichain.lab.sim.atomic_actions.runner import ( CommandAcknowledgement, ExecutionClock, + ExecutionRunnerCfg, ) from embodichain.lab.sim.atomic_actions.runtime_commands import ( EndpointCommand, JointPositionPayload, RuntimeCommandFrame, + RuntimeCommandPayload, ) from embodichain.lab.sim.atomic_actions.state import PlanningContext, TaskState from embodichain.lab.sim.skills.parallel import ParallelTimingPolicy @@ -109,9 +112,14 @@ class RuntimeTransportActionEncoder(Protocol): action manager exposes a structured controller boundary. """ - @property - def transport_id(self) -> str: - """Return the exact runtime transport ID handled by this encoder.""" + transport_id: ClassVar[str] + """Exact runtime transport ID handled by this encoder.""" + + target_types: ClassVar[tuple[type[RuntimeEndpointTarget], ...]] + """Exact runtime-target types accepted by this encoder.""" + + payload_types: ClassVar[tuple[type[RuntimeCommandPayload], ...]] + """Exact runtime-payload types accepted by this encoder.""" def encode( self, @@ -129,7 +137,12 @@ def hold( base_action: EnvAction, context: PlanningContext, ) -> EnvAction: - """Merge this transport's safe state into ``base_action``.""" + """Merge this transport's self-proven safe hold into ``base_action``. + + The transport remains authoritative for neutralizing its own controller; + parallel command validation does not replace this transport-specific hold + contract. + """ @runtime_checkable @@ -423,10 +436,13 @@ def advance_after_env_step(self, steps: int = 1) -> None: class JointPositionGymTransportEncoder: """Built-in ``robot.joint_position`` to full-qpos action encoder.""" - @property - def transport_id(self) -> str: - """Return the built-in joint-position transport ID.""" - return JointPositionTarget.TRANSPORT_ID + transport_id: ClassVar[str] = JointPositionTarget.TRANSPORT_ID + target_types: ClassVar[tuple[type[RuntimeEndpointTarget], ...]] = ( + JointPositionTarget, + ) + payload_types: ClassVar[tuple[type[RuntimeCommandPayload], ...]] = ( + JointPositionPayload, + ) def encode( self, @@ -495,7 +511,10 @@ class RuntimeCommandFrameEncoder: Args: qpos_provider: Full-qpos source aligned to a frame's explicit ``env_ids``. transports: Optional additional transport encoders. The built-in - joint-position encoder is always installed first. + joint-position encoder precedes them when enabled. + include_joint_position: Whether to install the built-in joint-position + encoder. Standard assemblies disable it when their exact profile uses + only custom endpoint transports. """ def __init__( @@ -503,12 +522,17 @@ def __init__( qpos_provider: CurrentQposProvider, *, transports: Iterable[RuntimeTransportActionEncoder] = (), + include_joint_position: bool = True, ) -> None: if not isinstance(qpos_provider, CurrentQposProvider): raise TypeError("qpos_provider must implement CurrentQposProvider.") + if type(include_joint_position) is not bool: + raise TypeError("include_joint_position must be a bool.") self._qpos_provider = qpos_provider self._transports: dict[str, RuntimeTransportActionEncoder] = {} - self.register_transport(JointPositionGymTransportEncoder()) + self._frozen = False + if include_joint_position: + self.register_transport(JointPositionGymTransportEncoder()) for transport in transports: self.register_transport(transport) @@ -517,6 +541,15 @@ def transport_ids(self) -> tuple[str, ...]: """Return registered transport IDs in deterministic encoding order.""" return tuple(self._transports) + @property + def is_frozen(self) -> bool: + """Return whether runtime transport registration is permanently closed.""" + return self._frozen + + def freeze(self) -> None: + """Permanently close transport registration for a standard assembly.""" + self._frozen = True + def register_transport( self, transport: RuntimeTransportActionEncoder, @@ -524,18 +557,84 @@ def register_transport( replace: bool = False, ) -> None: """Register one shared transport-to-Gym action encoder.""" + if self._frozen: + raise RuntimeError( + "Runtime transport registration is frozen for this command encoder." + ) if not isinstance(transport, RuntimeTransportActionEncoder): raise TypeError("transport must implement RuntimeTransportActionEncoder.") + transport_type = type(transport) transport_id = _validate_identifier( - transport.transport_id, + getattr(transport_type, "transport_id", None), field_name="RuntimeTransportActionEncoder.transport_id", ) + self._validate_declared_types( + getattr(transport_type, "target_types", None), + base_type=RuntimeEndpointTarget, + field_name="RuntimeTransportActionEncoder.target_types", + ) + self._validate_declared_types( + getattr(transport_type, "payload_types", None), + base_type=RuntimeCommandPayload, + field_name="RuntimeTransportActionEncoder.payload_types", + ) if type(replace) is not bool: raise TypeError("replace must be a bool.") if transport_id in self._transports and not replace: raise ValueError(f"Transport {transport_id!r} is already registered.") self._transports[transport_id] = transport + @staticmethod + def _validate_declared_types( + values: object, + *, + base_type: type[object], + field_name: str, + ) -> None: + """Validate one non-empty exact tuple of supported runtime types.""" + if type(values) is not tuple or not values: + raise TypeError(f"{field_name} must be a non-empty exact tuple.") + if not all( + isinstance(value, type) and issubclass(value, base_type) for value in values + ): + raise TypeError( + f"{field_name} must contain {base_type.__name__} subclasses." + ) + if len(set(values)) != len(values): + raise ValueError(f"{field_name} must not contain duplicate types.") + + @staticmethod + def _validate_command_types( + transport: RuntimeTransportActionEncoder, + command: EndpointCommand, + ) -> None: + """Require exact target and payload coverage before transport routing.""" + transport_type = type(transport) + if type(command.target) not in transport_type.target_types: + raise TypeError( + f"Transport {transport_type.transport_id!r} does not declare exact " + f"target type {type(command.target).__name__}." + ) + if type(command.payload) not in transport_type.payload_types: + raise TypeError( + f"Transport {transport_type.transport_id!r} does not declare exact " + f"payload type {type(command.payload).__name__}." + ) + + @staticmethod + def _validate_hold_target_types( + transport: RuntimeTransportActionEncoder, + targets: Iterable[RuntimeEndpointTarget], + ) -> None: + """Require exact target coverage before safe-hold routing.""" + transport_type = type(transport) + for target in targets: + if type(target) not in transport_type.target_types: + raise TypeError( + f"Transport {transport_type.transport_id!r} does not declare " + f"exact hold target type {type(target).__name__}." + ) + def _base_qpos(self, env_ids: torch.Tensor) -> torch.Tensor: """Capture and validate one owned full-qpos hold action.""" qpos = self._qpos_provider.current_qpos(env_ids) @@ -556,6 +655,7 @@ def encode(self, frame: RuntimeCommandFrame) -> EnvAction: if not isinstance(frame, RuntimeCommandFrame): raise TypeError("frame must be a RuntimeCommandFrame.") action: EnvAction = self._base_qpos(frame.env_ids) + by_transport: dict[str, list[EndpointCommand]] = {} for command in frame.commands: transport = self._transports.get(command.transport_id) if transport is None: @@ -563,11 +663,15 @@ def encode(self, frame: RuntimeCommandFrame) -> EnvAction: f"No Gym action encoder is registered for runtime transport " f"{command.transport_id!r}." ) - action = transport.encode( - command, - base_action=action, - active_mask=frame.active_mask, - ) + self._validate_command_types(transport, command) + by_transport.setdefault(command.transport_id, []).append(command) + for transport_id, transport in self._transports.items(): + for command in by_transport.get(transport_id, ()): + action = transport.encode( + command, + base_action=action, + active_mask=frame.active_mask, + ) return action def encode_hold( @@ -591,6 +695,11 @@ def encode_hold( f"No Gym action encoder is registered for runtime transport " f"{transport_id!r}." ) + self._validate_hold_target_types(transport, grouped) + for transport_id, transport in self._transports.items(): + grouped = by_transport.get(transport_id) + if grouped is None: + continue action = transport.hold( tuple(grouped), base_action=action, @@ -897,6 +1006,7 @@ class AtomicDemoBridge: clock: The same environment-step clock installed in ``runtime``. post_policy_port: Optional environment-aware post-policy executor. validator_port: Optional environment-aware validator executor. + runner_cfg: Runner transport policy selected by the runtime preset. parallel_safety_validator: Optional authoritative physical-safety gate required before any parallel branch can start. @@ -914,6 +1024,7 @@ def __init__( *, post_policy_port: SegmentPostPolicyPort | None = None, validator_port: SegmentValidatorPort | None = None, + runner_cfg: ExecutionRunnerCfg | None = None, parallel_safety_validator: ParallelCommandSafetyValidator | None = None, ) -> None: if not isinstance(program, CompiledProgramPort): @@ -937,6 +1048,8 @@ def __init__( validator_port, SegmentValidatorPort ): raise TypeError("validator_port must implement SegmentValidatorPort.") + if runner_cfg is not None and not isinstance(runner_cfg, ExecutionRunnerCfg): + raise TypeError("runner_cfg must be an ExecutionRunnerCfg or None.") if parallel_safety_validator is not None and not isinstance( parallel_safety_validator, ParallelCommandSafetyValidator ): @@ -950,6 +1063,7 @@ def __init__( self._clock = clock self._post_policy_port = post_policy_port self._validator_port = validator_port + self._runner_cfg = deepcopy(runner_cfg or ExecutionRunnerCfg()) self._parallel_safety_validator = parallel_safety_validator self._active_segment_id: str | None = None self._eligible_mask: torch.Tensor | None = None @@ -1351,6 +1465,7 @@ def _parallel_runtime(self, segment: Any) -> ParallelSkillRuntime: self._parallel_safety_validator, timeout_steps=barrier.timeout_steps, failure_policy=barrier.failure_policy, + runner_cfg=self._runner_cfg, workflow_id=( f"{self._program.program_id}/{segment.segment_id}:parallel_analysis" ), diff --git a/embodichain/lab/gym/envs/expert_program/catalog.py b/embodichain/lab/gym/envs/expert_program/catalog.py index 06963c26..8db7f334 100644 --- a/embodichain/lab/gym/envs/expert_program/catalog.py +++ b/embodichain/lab/gym/envs/expert_program/catalog.py @@ -24,6 +24,8 @@ import hashlib import json import math +from _thread import LockType +from threading import Lock from types import MappingProxyType import torch @@ -32,18 +34,36 @@ Affordance, ArticulationOperationAffordance, AtomicActionEngine, + EndpointTrackingFeedbackAddress, + GRASP_CAPABILITY, + JOINT_POSITION_CHANNEL, SkillDescriptor, ) from embodichain.lab.sim.atomic_actions.primitives import BUILTIN_ACTION_TYPES +from embodichain.lab.sim.atomic_actions.tracking import ( + FeedbackTerminalAcceptance, + TrackingRuntime, +) from embodichain.lab.sim.skills import ( ARTICULATION_OPERATION_AFFORDANCE_CAPABILITY, + CONTACT_EFFECT_CHANNEL, + CONSTRAINT_EFFECT_CHANNEL, + ControlPartEndpoint, + ControlPartEvidenceAddress, + FORCE_EFFECT_CHANNEL, + JOINT_STATE_EFFECT_CHANNEL, PLACE_IN_AFFORDANCE_CAPABILITY, PLACE_ON_AFFORDANCE_CAPABILITY, + POSE_RELATION_EFFECT_CHANNEL, + BoundRobotSkillProfile, HandOverPoseProvider, OperateArticulation, Place, RelationTargetGrounder, RobotSkillProfile, + RegisteredSemanticCall, + ResourceEndpoint, + ResourceEndpointAdapter, SceneAffordanceRef, SceneArticulationRef, SceneEntityRef, @@ -56,6 +76,15 @@ SkillPolicyPreset, builtin_semantic_call_catalog, ) +from embodichain.lab.sim.skills.effects import ( + COMPOSITE_EFFECT_MONITOR_ID, + COMPOSITE_EFFECT_MONITOR_REVISION, + CompositeEffectMonitorFactory, + EffectMonitorRegistry, +) +from embodichain.lab.sim.skills.parallel_runtime import ( + ParallelCommandSafetyValidator, +) from .cfg import ( ExpertProgramCfg, @@ -77,6 +106,16 @@ ExpertProgramValidationError, SceneReferenceRole, ) +from .bridge import RuntimeTransportActionEncoder +from .extensions import ( + EndpointAdapterDeclaration, + ParallelCommandSafetyValidatorFactory, + ParallelSafetyDeclaration, + RuntimeTransportDeclaration, + StandardExtensionDeclarations, + build_standard_extension_declarations, + validate_immutable_extension_declaration, +) from .simulation import SimulationRobotSkillProfileBinding, SimulationSceneBinding from .simulation_policies import default_simulation_settle_presets @@ -239,51 +278,6 @@ def _relation_grounder_order_key( return capability, _qualified_name(affordance_type), revision -def _validate_provider_declaration(provider: object, *, field_name: str) -> None: - """Accept only frozen dataclass declarations or stateless providers.""" - dataclass_declaration = is_dataclass(provider) - dataclass_field_names: set[str] = set() - if dataclass_declaration: - params = getattr(type(provider), "__dataclass_params__", None) - if params is None or not params.frozen: - raise TypeError( - f"{field_name} stateful declarations must be frozen dataclasses " - "so every configuration field enters the registration fingerprint." - ) - dataclass_field_names.update( - declaration_field.name for declaration_field in fields(provider) - ) - - state_names: set[str] = set() - instance_state = getattr(provider, "__dict__", None) - if isinstance(instance_state, Mapping): - state_names.update(instance_state) - for owner in type(provider).__mro__: - declared_slots = getattr(owner, "__slots__", ()) - slots = (declared_slots,) if isinstance(declared_slots, str) else declared_slots - for slot_name in slots: - if slot_name in {"__dict__", "__weakref__"}: - continue - storage_name = ( - f"_{owner.__name__.lstrip('_')}{slot_name}" - if slot_name.startswith("__") and not slot_name.endswith("__") - else slot_name - ) - if hasattr(provider, storage_name): - state_names.add(storage_name) - undeclared_state = ( - state_names.difference(dataclass_field_names) - if dataclass_declaration - else state_names - ) - if undeclared_state: - raise TypeError( - f"{field_name} providers contain unfingerprinted state " - f"{sorted(undeclared_state)}. Use a frozen dataclass declaration with " - "every state field declared; non-dataclass providers must be stateless." - ) - - def _snapshot_relation_grounders( values: tuple[RelationTargetGrounder, ...], ) -> tuple[RelationTargetGrounder, ...]: @@ -296,7 +290,7 @@ def _snapshot_relation_grounders( raise TypeError( "relation_grounders must contain RelationTargetGrounder instances." ) - _validate_provider_declaration( + validate_immutable_extension_declaration( grounder, field_name="relation_grounders", ) @@ -351,7 +345,7 @@ def _snapshot_handover_pose_providers( raise TypeError( "handover_pose_providers must contain HandOverPoseProvider instances." ) - _validate_provider_declaration( + validate_immutable_extension_declaration( provider, field_name="handover_pose_providers", ) @@ -362,6 +356,72 @@ def _snapshot_handover_pose_providers( return tuple(values) +def _validate_standard_call_catalog(call_catalog: SemanticCallCatalog) -> None: + """Reject semantic lowerer extensions from the standard registration path.""" + builtins = builtin_semantic_call_catalog().descriptors + for descriptor in call_catalog.descriptors.values(): + if descriptor.spec_type is RegisteredSemanticCall: + raise ValueError( + f"Registered semantic call {descriptor.call_id!r} is not " + "supported by the standard simulation registration; only " + "curated semantic calls may be registered." + ) + expected = builtins.get(descriptor.call_id) + if expected != descriptor: + raise ValueError( + f"Semantic call {descriptor.call_id!r} does not match its exact " + "curated descriptor." + ) + + +def _validate_standard_effect_monitors(profile: RobotSkillProfile) -> None: + """Require every preset to use the exact built-in effect-monitor factory.""" + registry = EffectMonitorRegistry((CompositeEffectMonitorFactory(),)) + builtin_key = ( + COMPOSITE_EFFECT_MONITOR_ID, + COMPOSITE_EFFECT_MONITOR_REVISION, + ) + for preset_id, preset in profile.presets.items(): + for semantic_id, monitor_ref in preset.effect_monitors.items(): + key = monitor_ref.monitor_id, monitor_ref.revision + if key != builtin_key: + raise ValueError( + f"Preset {preset_id!r} semantic call {semantic_id!r} selects " + f"non-built-in effect monitor {key!r}; the standard " + "simulation registration supports only {builtin_key!r}." + ) + try: + registry.validate_ref(monitor_ref) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError( + f"Preset {preset_id!r} semantic call {semantic_id!r} has an " + "invalid built-in effect-monitor declaration." + ) from exc + + +def _validate_standard_tracking_metrics(profile: RobotSkillProfile) -> None: + """Resolve every reachable metric through the exact built-in evaluator table.""" + evaluators = TrackingRuntime.with_builtins().evaluators + for preset_id, preset in profile.presets.items(): + policy = preset.tracking_policy + metric_groups = [] + if policy.in_flight is not None: + metric_groups.append(("in_flight", policy.in_flight.metrics)) + if isinstance(policy.terminal, FeedbackTerminalAcceptance): + metric_groups.append(("terminal", policy.terminal.metrics)) + for phase, metrics in metric_groups: + for metric in metrics: + try: + evaluators.resolve(metric) + except (KeyError, TypeError, ValueError) as exc: + key = metric.metric_id, metric.revision, _qualified_name(metric) + raise ValueError( + f"Preset {preset_id!r} {phase} tracking metric {key!r} " + "has no exact built-in evaluator in the standard " + "simulation registration." + ) from exc + + def _declared_articulation_operation_targets( scene_binding: SimulationSceneBinding, ) -> dict[str, frozenset[str]]: @@ -491,6 +551,11 @@ class ExpertProgramIntegrationCatalog: relation_grounder_keys: frozenset[tuple[str, type[Affordance], str]] articulation_operation_targets: Mapping[str, frozenset[str]] settle_preset_ids: frozenset[str] + endpoint_adapter_declarations: Mapping[ + type[ResourceEndpoint], EndpointAdapterDeclaration + ] + runtime_transport_declarations: tuple[RuntimeTransportDeclaration, ...] + parallel_safety_declaration: ParallelSafetyDeclaration | None fingerprint: str _required_skills: Mapping[str, SkillDescriptor] = field( repr=False, @@ -521,6 +586,36 @@ def __post_init__(self) -> None: scene=self.scene, ), ) + extensions = StandardExtensionDeclarations( + endpoint_adapters=self.endpoint_adapter_declarations, + runtime_transports=self.runtime_transport_declarations, + parallel_safety=self.parallel_safety_declaration, + ) + profile_endpoint_types = frozenset( + type(endpoint) + for resource in self.robot_profile.resources.values() + for endpoint in resource.endpoints.values() + ) + if profile_endpoint_types != frozenset(extensions.endpoint_adapters): + raise ValueError( + "endpoint_adapter_declarations must cover every exact robot " + "profile endpoint type and no others." + ) + object.__setattr__( + self, + "endpoint_adapter_declarations", + extensions.endpoint_adapters, + ) + object.__setattr__( + self, + "runtime_transport_declarations", + extensions.runtime_transports, + ) + object.__setattr__( + self, + "parallel_safety_declaration", + extensions.parallel_safety, + ) if self.robot_profile.profile_id != self.robot_profile_id: raise ValueError("robot_profile_id must match robot_profile.profile_id.") preset_ids = frozenset(self.settle_preset_ids) @@ -754,6 +849,16 @@ def preflight(self, program: ExpertProgramCfg) -> CompiledProgram: runtime_preset=program.integration.runtime_preset, ) for segment in compiled.iter_segments(): + if ( + segment.parallel_block is not None + and self.parallel_safety_declaration is None + ): + raise ExpertProgramValidationError( + "parallel_safety_factory_not_registered", + segment.parallel_block.source_path, + "Parallel execution requires a task-registration-owned " + "physical safety-validator factory.", + ) for call in segment.calls: if ( type(call.call) is OperateArticulation @@ -791,6 +896,200 @@ def validate_engine(self, engine: AtomicActionEngine) -> None: f"Live skill {skill_id!r} differs from the registered " "semantic target descriptor." ) + bound_profile = engine.skill_profile + if type(bound_profile) is not BoundRobotSkillProfile: + raise IntegrationFingerprintMismatch( + "The standard live engine must own one exact bound robot profile." + ) + self.validate_bound_endpoint_extensions(bound_profile) + + def validate_bound_endpoint_extensions( + self, + bound_profile: BoundRobotSkillProfile, + ) -> None: + """Match every live resolved endpoint to its fingerprinted declaration.""" + if type(bound_profile) is not BoundRobotSkillProfile: + raise TypeError("bound_profile must be exactly BoundRobotSkillProfile.") + if bound_profile.profile_id != self.robot_profile_id: + raise IntegrationFingerprintMismatch( + "The bound robot profile ID differs from the registered profile." + ) + + transport_owner_by_target_type = { + target_type: transport + for transport in self.runtime_transport_declarations + for target_type in transport.target_types + } + expected_resource_ids = frozenset(self.robot_profile.resources) + live_resource_ids = frozenset(bound_profile.resources) + if live_resource_ids != expected_resource_ids: + raise IntegrationFingerprintMismatch( + "Bound robot resource IDs differ from the registered profile; " + f"expected {sorted(expected_resource_ids)}, " + f"got {sorted(live_resource_ids)}." + ) + for resource_id, resource in bound_profile.resources.items(): + expected_resource = self.robot_profile.resources[resource_id] + if resource.resource_id != expected_resource.resource_id: + raise IntegrationFingerprintMismatch( + f"Bound resource {resource_id!r} declaration ID differs from " + "the registered profile." + ) + if resource.members != expected_resource.members: + raise IntegrationFingerprintMismatch( + f"Bound resource {resource_id!r} members differ from the " + "registered profile." + ) + expected_endpoint_ids = frozenset(expected_resource.endpoints) + live_endpoint_ids = frozenset(resource.endpoints) + if live_endpoint_ids != expected_endpoint_ids: + raise IntegrationFingerprintMismatch( + f"Bound resource {resource_id!r} endpoint IDs differ from the " + f"registered profile; expected {sorted(expected_endpoint_ids)}, " + f"got {sorted(live_endpoint_ids)}." + ) + for endpoint_id, endpoint in resource.endpoints.items(): + location = f"{resource_id}.{endpoint_id}" + expected_endpoint = expected_resource.endpoints[endpoint_id] + if type(endpoint.endpoint) is not type(expected_endpoint) or ( + _canonical_json(endpoint.endpoint) + != _canonical_json(expected_endpoint) + ): + raise IntegrationFingerprintMismatch( + f"Bound endpoint {location!r} declaration differs from the " + "registered robot profile." + ) + endpoint_type = type(endpoint.endpoint) + declaration = self.endpoint_adapter_declarations.get(endpoint_type) + if declaration is None: + raise IntegrationFingerprintMismatch( + f"Bound endpoint {location!r} has undeclared exact type " + f"{_qualified_name(endpoint_type)!r}." + ) + if endpoint.adapter_id != declaration.adapter_id: + raise IntegrationFingerprintMismatch( + f"Bound endpoint {location!r} adapter ID " + f"{endpoint.adapter_id!r} differs from registered " + f"{declaration.adapter_id!r}." + ) + + target = endpoint.runtime_target + target_type = type(target) + if target_type not in declaration.runtime_target_types: + raise IntegrationFingerprintMismatch( + f"Bound endpoint {location!r} resolved undeclared exact " + f"runtime target type {_qualified_name(target_type)!r}." + ) + owner = transport_owner_by_target_type.get(target_type) + if owner is None or owner.transport_id not in ( + declaration.runtime_transport_ids + ): + raise IntegrationFingerprintMismatch( + f"Bound endpoint {location!r} target type has no registered " + "adapter transport owner." + ) + if target.transport_id != owner.transport_id: + raise IntegrationFingerprintMismatch( + f"Bound endpoint {location!r} live transport " + f"{target.transport_id!r} differs from target type owner " + f"{owner.transport_id!r}." + ) + + feedback_keys = frozenset( + (binding.source.provider_id, binding.source.revision) + for binding in endpoint.tracking_channels.values() + ) + if feedback_keys != declaration.tracking_feedback_source_keys: + raise IntegrationFingerprintMismatch( + f"Bound endpoint {location!r} tracking-feedback routes " + "differ from its registered adapter declaration." + ) + projector_keys = frozenset( + (binding.projector.projector_id, binding.projector.revision) + for binding in endpoint.tracking_channels.values() + ) + if projector_keys != declaration.tracking_projector_keys: + raise IntegrationFingerprintMismatch( + f"Bound endpoint {location!r} tracking-projector routes " + "differ from its registered adapter declaration." + ) + evidence_keys = frozenset( + (source.provider_id, source.revision) + for source in endpoint.effect_sources.values() + ) + if evidence_keys != declaration.effect_evidence_source_keys: + raise IntegrationFingerprintMismatch( + f"Bound endpoint {location!r} effect-evidence routes " + "differ from its registered adapter declaration." + ) + if endpoint_type is ControlPartEndpoint: + control_part = endpoint.endpoint.control_part + if getattr(target, "control_part", None) != control_part: + raise IntegrationFingerprintMismatch( + f"Bound endpoint {location!r} runtime target addresses " + "a different control part." + ) + if frozenset(endpoint.tracking_channels) != frozenset( + {JOINT_POSITION_CHANNEL} + ): + raise IntegrationFingerprintMismatch( + f"Bound endpoint {location!r} must expose exactly the " + "built-in joint-position tracking channel." + ) + tracking = endpoint.tracking_channels[JOINT_POSITION_CHANNEL] + feedback_address = tracking.source.address + if type(feedback_address) is not EndpointTrackingFeedbackAddress: + raise IntegrationFingerprintMismatch( + f"Bound endpoint {location!r} must use the exact " + "built-in endpoint tracking address." + ) + if ( + feedback_address.channel_id != JOINT_POSITION_CHANNEL + or type(feedback_address.target) is not target_type + or _canonical_json(feedback_address.target) + != _canonical_json(target) + ): + raise IntegrationFingerprintMismatch( + f"Bound endpoint {location!r} tracking address differs " + "from its runtime target or channel." + ) + + expected_effect_channels = { + POSE_RELATION_EFFECT_CHANNEL, + JOINT_STATE_EFFECT_CHANNEL, + } + if GRASP_CAPABILITY in endpoint.endpoint.capabilities: + expected_effect_channels.update( + { + CONTACT_EFFECT_CHANNEL, + CONSTRAINT_EFFECT_CHANNEL, + FORCE_EFFECT_CHANNEL, + } + ) + if frozenset(endpoint.effect_sources) != frozenset( + expected_effect_channels + ): + raise IntegrationFingerprintMismatch( + f"Bound endpoint {location!r} effect-evidence channels " + "differ from the exact built-in control-part routes." + ) + for channel, source in endpoint.effect_sources.items(): + address = source.address + if ( + type(address) is not ControlPartEvidenceAddress + or address.control_part != control_part + or address.channel != channel + ): + raise IntegrationFingerprintMismatch( + f"Bound endpoint {location!r} effect-evidence " + f"address for channel {channel!r} differs from its " + "control part or channel." + ) + elif endpoint.tracking_channels or endpoint.effect_sources: + raise IntegrationFingerprintMismatch( + f"Bound custom endpoint {location!r} exposes closed-loop " + "routes forbidden by the C1 standard runtime." + ) def _profile_with_control_dt( @@ -829,6 +1128,10 @@ def _registration_payload( relation_grounder_keys: frozenset[tuple[str, type[Affordance], str]], relation_grounders: tuple[RelationTargetGrounder, ...], handover_pose_providers: tuple[HandOverPoseProvider, ...], + extensions: StandardExtensionDeclarations, + endpoint_adapters: tuple[ResourceEndpointAdapter, ...], + runtime_transports: tuple[RuntimeTransportActionEncoder, ...], + parallel_safety_factory: ParallelCommandSafetyValidatorFactory | None, ) -> dict[str, object]: """Build the versioned canonical fingerprint payload.""" return { @@ -865,6 +1168,48 @@ def _registration_payload( key=_handover_pose_provider_id, ) ), + "standard_extensions": { + "endpoint_adapters": tuple( + sorted( + extensions.endpoint_adapters.values(), + key=lambda declaration: declaration.adapter_id, + ) + ), + "runtime_transports": extensions.runtime_transports, + "parallel_safety": extensions.parallel_safety, + }, + "endpoint_adapters": tuple( + { + "declaration": extensions.endpoint_adapters[ + getattr(type(adapter), "endpoint_type") + ], + "provider": _provider_fingerprint_declaration(adapter), + } + for adapter in sorted( + endpoint_adapters, + key=lambda value: getattr(type(value), "adapter_id"), + ) + ), + "runtime_transports": tuple( + { + "declaration": next( + declaration + for declaration in extensions.runtime_transports + if declaration.transport_id + == getattr(type(transport), "transport_id") + ), + "provider": _provider_fingerprint_declaration(transport), + } + for transport in runtime_transports + ), + "parallel_safety_factory": ( + None + if parallel_safety_factory is None + else { + "declaration": extensions.parallel_safety, + "provider": _provider_fingerprint_declaration(parallel_safety_factory), + } + ), "post_policy_kinds": _POST_POLICY_KINDS, "settle_presets": settle_presets, "validator_kinds": _VALIDATOR_KINDS, @@ -885,7 +1230,20 @@ class SimulationExpertProgramRegistration: ) relation_grounders: tuple[RelationTargetGrounder, ...] = () handover_pose_providers: tuple[HandOverPoseProvider, ...] = () + endpoint_adapters: tuple[ResourceEndpointAdapter, ...] = () + runtime_transports: tuple[RuntimeTransportActionEncoder, ...] = () + parallel_safety_factory: ParallelCommandSafetyValidatorFactory | None = None catalog: ExpertProgramIntegrationCatalog = field(init=False) + _parallel_safety_validator_history: list[ParallelCommandSafetyValidator] = field( + init=False, + repr=False, + compare=False, + ) + _parallel_safety_validator_lock: LockType = field( + init=False, + repr=False, + compare=False, + ) def __post_init__(self) -> None: if type(self.scene_binding) is not SimulationSceneBinding: @@ -897,6 +1255,7 @@ def __post_init__(self) -> None: ) if type(self.call_catalog) is not SemanticCallCatalog: raise TypeError("call_catalog must be exactly SemanticCallCatalog.") + _validate_standard_call_catalog(self.call_catalog) settle_presets = _snapshot_settle_presets(self.settle_presets) object.__setattr__(self, "settle_presets", settle_presets) relation_grounders = _snapshot_relation_grounders(self.relation_grounders) @@ -918,6 +1277,14 @@ def __post_init__(self) -> None: self.scene_binding ) profile = self.robot_profile_binding.declare() + _validate_standard_effect_monitors(profile) + _validate_standard_tracking_metrics(profile) + extensions = build_standard_extension_declarations( + profile=profile, + endpoint_adapters=self.endpoint_adapters, + runtime_transports=self.runtime_transports, + parallel_safety_factory=self.parallel_safety_factory, + ) selected_handover_provider = profile.grounding_providers.get("hand_over") registered_handover_provider_ids = { _handover_pose_provider_id(provider) for provider in handover_pose_providers @@ -961,6 +1328,10 @@ def __post_init__(self) -> None: relation_grounder_keys=relation_grounder_keys, relation_grounders=relation_grounders, handover_pose_providers=handover_pose_providers, + extensions=extensions, + endpoint_adapters=self.endpoint_adapters, + runtime_transports=self.runtime_transports, + parallel_safety_factory=self.parallel_safety_factory, ) ) object.__setattr__( @@ -975,10 +1346,15 @@ def __post_init__(self) -> None: relation_grounder_keys=relation_grounder_keys, articulation_operation_targets=articulation_operation_targets, settle_preset_ids=frozenset(settle_presets), + endpoint_adapter_declarations=extensions.endpoint_adapters, + runtime_transport_declarations=extensions.runtime_transports, + parallel_safety_declaration=extensions.parallel_safety, fingerprint=fingerprint, _required_skills=required_skills, ), ) + object.__setattr__(self, "_parallel_safety_validator_history", []) + object.__setattr__(self, "_parallel_safety_validator_lock", Lock()) @property def fingerprint(self) -> str: @@ -993,6 +1369,15 @@ def assert_unchanged(self) -> None: ) profile = self.robot_profile_binding.declare() try: + _validate_standard_call_catalog(self.call_catalog) + _validate_standard_effect_monitors(profile) + _validate_standard_tracking_metrics(profile) + extensions = build_standard_extension_declarations( + profile=profile, + endpoint_adapters=self.endpoint_adapters, + runtime_transports=self.runtime_transports, + parallel_safety_factory=self.parallel_safety_factory, + ) relation_grounders = _snapshot_relation_grounders(self.relation_grounders) relation_grounder_keys = frozenset( _relation_grounder_key(grounder) for grounder in relation_grounders @@ -1012,6 +1397,10 @@ def assert_unchanged(self) -> None: relation_grounder_keys=relation_grounder_keys, relation_grounders=relation_grounders, handover_pose_providers=handover_pose_providers, + extensions=extensions, + endpoint_adapters=self.endpoint_adapters, + runtime_transports=self.runtime_transports, + parallel_safety_factory=self.parallel_safety_factory, ) ) except (TypeError, ValueError) as exc: @@ -1025,11 +1414,58 @@ def assert_unchanged(self) -> None: "registration." ) + @property + def endpoint_adapter_map( + self, + ) -> Mapping[type[ResourceEndpoint], ResourceEndpointAdapter]: + """Return custom live adapters keyed by their exact endpoint type.""" + return MappingProxyType( + { + getattr(type(adapter), "endpoint_type"): adapter + for adapter in self.endpoint_adapters + } + ) + + def create_parallel_safety_validator( + self, + *, + simulation: object, + robot: object, + ) -> ParallelCommandSafetyValidator | None: + """Create and strictly validate the registration-owned live safety gate.""" + self.assert_unchanged() + factory = self.parallel_safety_factory + if factory is None: + return None + with self._parallel_safety_validator_lock: + validator = factory.create(simulation=simulation, robot=robot) + if not isinstance(validator, ParallelCommandSafetyValidator): + raise TypeError( + "parallel_safety_factory.create() must return a " + "ParallelCommandSafetyValidator." + ) + if any( + validator is previous + for previous in self._parallel_safety_validator_history + ): + raise ValueError( + "ParallelCommandSafetyValidatorFactory.create() must return a " + "fresh validator for every runtime assembly owned by this " + "registration." + ) + self._parallel_safety_validator_history.append(validator) + return validator + def validate_scene_registry(self, registry: SceneRegistry) -> None: """Validate a live registry against the registered scene declaration.""" self.assert_unchanged() self.catalog.scene.validate_registry(registry) + def validate_engine(self, engine: AtomicActionEngine) -> None: + """Validate live skills and resolved endpoints against this registration.""" + self.assert_unchanged() + self.catalog.validate_engine(engine) + def validate_robot_profile( self, profile: RobotSkillProfile, diff --git a/embodichain/lab/gym/envs/expert_program/environment.py b/embodichain/lab/gym/envs/expert_program/environment.py index 2e75c934..580e7b61 100644 --- a/embodichain/lab/gym/envs/expert_program/environment.py +++ b/embodichain/lab/gym/envs/expert_program/environment.py @@ -26,6 +26,7 @@ from __future__ import annotations from collections.abc import Iterable, Mapping +from copy import deepcopy from dataclasses import dataclass import math from typing import Protocol, runtime_checkable @@ -61,6 +62,7 @@ analyze_parallel_branches, ) from embodichain.lab.sim.skills.profiles import ( + BoundRobotSkillProfile, ResourceEndpoint, ResourceEndpointAdapter, RobotSkillProfile, @@ -75,12 +77,17 @@ CurrentQposProvider, DemoBridgeError, EnvironmentStepClock, + JointPositionGymTransportEncoder, RuntimeCommandFrameEncoder, RuntimeTransportActionEncoder, SegmentPostPolicyPort, SegmentValidatorPort, ) -from .catalog import ExpertProgramIntegrationCatalog +from .catalog import ( + ExpertProgramIntegrationCatalog, + IntegrationFingerprintMismatch, + SimulationExpertProgramRegistration, +) from .cfg import ExpertProgramCfg, ExpertProgramIntegrationCfg from .compiler import ( CompiledProgram, @@ -216,6 +223,34 @@ def create_accepted_runtime_command_observer( """Return the observer shared by the command sink and evidence ports.""" +@runtime_checkable +class ParallelCommandSafetyValidatorProvider(Protocol): + """Runtime-factory capability for a fresh registration-owned safety gate.""" + + def create_parallel_command_safety_validator( + self, + *, + scene_registry: SceneRegistry, + engine: AtomicActionEngine, + observation_provider: PlanningObservationPort, + ) -> ParallelCommandSafetyValidator: + """Create the live gate for the exact assembled runtime components.""" + + +@runtime_checkable +class _RegistrationOwningExpertProgramFactory(Protocol): + """Internal capability exposing one exact standard registration owner.""" + + @property + def expert_program_registration(self) -> SimulationExpertProgramRegistration: + """Return the exact registration owned by this live factory.""" + + def registration_owned_segment_policy_ports( + self, + ) -> tuple[SegmentPostPolicyPort | None, SegmentValidatorPort | None]: + """Return factory-owned post-policy and validator ports.""" + + @dataclass(frozen=True, slots=True) class ExpertProgramRuntimeAssembly: """Auditable result of one fresh environment runtime assembly. @@ -233,6 +268,8 @@ class ExpertProgramRuntimeAssembly: command_encoder: Runtime-frame to Gym-action encoder. command_sink: Buffered Gym command sink. accepted_command_observer: Optional transactional command-state owner. + runner_cfg: Runner policy selected by the integration runtime preset. + parallel_safety_validator: Optional fresh registration-owned safety gate. runtime: Nonblocking semantic skill runtime. """ @@ -248,6 +285,8 @@ class ExpertProgramRuntimeAssembly: command_encoder: RuntimeCommandFrameEncoder command_sink: BufferedGymCommandSink accepted_command_observer: AcceptedRuntimeCommandObserver | None + runner_cfg: ExecutionRunnerCfg + parallel_safety_validator: ParallelCommandSafetyValidator | None runtime: SkillRuntime @@ -271,6 +310,8 @@ class ExpertProgramEnvironmentAdapter: step_dt: Authoritative Gym control cadence in seconds. integration_catalog: Optional immutable task-registration catalog used for provider-free compilation. + registration: Optional exact standard task registration. When present, + every compiler/runtime extension comes exclusively from it. call_catalog: Optional immutable semantic call catalog. The built-in catalog is used when omitted. endpoint_adapters: Optional custom robot endpoint adapters. @@ -295,6 +336,7 @@ def __init__( *, step_dt: float, integration_catalog: ExpertProgramIntegrationCatalog | None = None, + registration: SimulationExpertProgramRegistration | None = None, call_catalog: SemanticCallCatalog | None = None, endpoint_adapters: ( Mapping[type[ResourceEndpoint], ResourceEndpointAdapter] | None @@ -331,6 +373,86 @@ def __init__( "integration_catalog must be exactly " "ExpertProgramIntegrationCatalog or None." ) + if ( + registration is not None + and type(registration) is not SimulationExpertProgramRegistration + ): + raise TypeError( + "registration must be exactly " + "SimulationExpertProgramRegistration or None." + ) + registration_owner = ( + factory + if isinstance(factory, _RegistrationOwningExpertProgramFactory) + else None + ) + if registration_owner is not None: + owned_registration = registration_owner.expert_program_registration + if type(owned_registration) is not SimulationExpertProgramRegistration: + raise TypeError( + "A registration-owning factory must expose exactly " + "SimulationExpertProgramRegistration." + ) + if registration is None: + raise ValueError( + "A registration-owning factory requires its exact registration; " + "catalog-only or unregistered adapter construction is forbidden." + ) + if registration is not owned_registration: + raise ValueError( + "registration must be the exact object owned by the factory." + ) + elif registration is not None: + raise TypeError( + "registration requires a factory that exposes exact registration " + "ownership and factory-owned segment policy ports." + ) + registered_lowerer_values = tuple(registered_lowerers) + relation_grounder_values = tuple(relation_grounders) + handover_pose_provider_values = tuple(handover_pose_providers) + runtime_transport_values = tuple(runtime_transports) + if registration is not None: + if integration_catalog is not None: + raise ValueError( + "integration_catalog cannot override an exact task registration." + ) + forbidden = { + "call_catalog": call_catalog is not None, + "endpoint_adapters": endpoint_adapters is not None, + "registered_lowerers": bool(registered_lowerer_values), + "relation_grounders": bool(relation_grounder_values), + "handover_pose_providers": bool(handover_pose_provider_values), + "effect_monitor_registry": effect_monitor_registry is not None, + "runtime_transports": bool(runtime_transport_values), + "runner_cfg": runner_cfg is not None, + "post_policy_port": post_policy_port is not None, + "validator_port": validator_port is not None, + "parallel_safety_validator": parallel_safety_validator is not None, + } + supplied = tuple(name for name, present in forbidden.items() if present) + if supplied: + raise ValueError( + "Standard task registration owns all semantic and runtime " + f"extensions; external overrides are forbidden: {supplied}." + ) + registration.assert_unchanged() + integration_catalog = registration.catalog + endpoint_adapters = dict(registration.endpoint_adapter_map) + registered_lowerer_values = () + relation_grounder_values = registration.relation_grounders + handover_pose_provider_values = registration.handover_pose_providers + effect_monitor_registry = None + runtime_transport_values = registration.runtime_transports + runner_cfg = None + parallel_safety_validator = None + assert registration_owner is not None + owned_ports = registration_owner.registration_owned_segment_policy_ports() + if type(owned_ports) is not tuple or len(owned_ports) != 2: + raise TypeError( + "registration_owned_segment_policy_ports() must return an " + "exact 2-tuple." + ) + post_policy_port, validator_port = owned_ports if integration_catalog is not None: if integration_catalog.scene_registry_id != scene_registry_id: raise ValueError( @@ -382,16 +504,17 @@ def __init__( self._scene_registry_id = scene_registry_id self._robot_profile_id = robot_profile_id self._step_dt = float(step_dt) + self._registration = registration self._integration_catalog = integration_catalog self._call_catalog = selected_catalog self._endpoint_adapters = ( None if endpoint_adapters is None else dict(endpoint_adapters) ) - self._registered_lowerers = tuple(registered_lowerers) - self._relation_grounders = tuple(relation_grounders) - self._handover_pose_providers = tuple(handover_pose_providers) + self._registered_lowerers = registered_lowerer_values + self._relation_grounders = relation_grounder_values + self._handover_pose_providers = handover_pose_provider_values self._effect_monitor_registry = effect_monitor_registry - self._runtime_transports = tuple(runtime_transports) + self._runtime_transports = runtime_transport_values self._runner_cfg = runner_cfg self._post_policy_port = post_policy_port self._validator_port = validator_port @@ -473,6 +596,7 @@ def _assemble_semantic_components( f"{self._robot_profile_id!r}, got {current_profile_id!r}." ) profile = self._factory.create_robot_skill_profile() + self._validate_registration_ownership() if type(profile) is not RobotSkillProfile: raise TypeError( "create_robot_skill_profile() must return exactly RobotSkillProfile." @@ -482,12 +606,31 @@ def _assemble_semantic_components( "Factory robot profile declaration drifted: expected " f"{self._robot_profile_id!r}, got {profile.profile_id!r}." ) + if self._registration is not None: + self._registration.validate_robot_profile( + profile, + step_dt=self._step_dt, + ) engine = self._factory.create_atomic_action_engine(profile) + self._validate_registration_ownership() if not isinstance(engine, AtomicActionEngine): raise TypeError( "create_atomic_action_engine() must return an AtomicActionEngine." ) + if self._registration is not None: + bound_profile = engine.skill_profile + if type(bound_profile) is not BoundRobotSkillProfile: + raise IntegrationFingerprintMismatch( + "The standard factory engine must own one exact bound robot " + "profile." + ) + if bound_profile.source_profile is not profile: + raise IntegrationFingerprintMismatch( + "The standard factory engine is bound to a different robot " + "profile object than the adapter validated." + ) + self._registration.validate_engine(engine) manifest = self._create_manifest( registry, @@ -499,6 +642,12 @@ def _assemble_semantic_components( engine, endpoint_adapters=self._endpoint_adapters, ) + if self._registration is not None: + self._validate_registration_ownership() + # ``manifest.bind`` resolves endpoints again and replaces the + # engine-owned bound profile. Revalidate that second live result so a + # provider cannot pass factory construction and drift before compile. + self._registration.validate_engine(engine) compiler = SemanticSkillCompiler( bound, registered_lowerers=self._registered_lowerers, @@ -528,6 +677,7 @@ def _assemble_execution_runtime( """Attach live observation, evidence, command, and runtime boundaries.""" if type(semantic) is not _ExpertProgramSemanticAssembly: raise TypeError("semantic must be exactly _ExpertProgramSemanticAssembly.") + self._validate_registration_ownership() clock = EnvironmentStepClock(self._step_dt) observation_provider = self._factory.create_planning_observation_provider( @@ -535,6 +685,7 @@ def _assemble_execution_runtime( engine=semantic.engine, clock=clock, ) + self._validate_registration_ownership() if not isinstance(observation_provider, PlanningObservationPort): raise TypeError( "create_planning_observation_provider() must return a port " @@ -545,6 +696,7 @@ def _assemble_execution_runtime( engine=semantic.engine, observation_provider=observation_provider, ) + self._validate_registration_ownership() if isinstance(providers, (str, bytes)): raise TypeError( "create_effect_evidence_providers() must return an iterable of " @@ -560,10 +712,30 @@ def _assemble_execution_runtime( evidence_collector = EffectEvidenceCollector( EffectEvidenceProviderRegistry(provider_values) ) + expected_transport_ids: tuple[str, ...] | None = None + include_joint_position = True + if self._registration is not None: + expected_transport_ids = tuple( + declaration.transport_id + for declaration in ( + self._registration.catalog.runtime_transport_declarations + ) + ) + include_joint_position = ( + JointPositionGymTransportEncoder.transport_id in expected_transport_ids + ) command_encoder = RuntimeCommandFrameEncoder( observation_provider, transports=self._runtime_transports, + include_joint_position=include_joint_position, ) + if expected_transport_ids is not None: + if command_encoder.transport_ids != expected_transport_ids: + raise IntegrationFingerprintMismatch( + "Live command encoder transport order differs from the exact " + "registration catalog." + ) + command_encoder.freeze() accepted_command_observer: AcceptedRuntimeCommandObserver | None = None if isinstance(self._factory, AcceptedRuntimeCommandObserverFactory): accepted_command_observer = ( @@ -573,6 +745,7 @@ def _assemble_execution_runtime( observation_provider=observation_provider, ) ) + self._validate_registration_ownership() if not isinstance( accepted_command_observer, AcceptedRuntimeCommandObserver, @@ -586,14 +759,56 @@ def _assemble_execution_runtime( clock, accepted_command_observer=accepted_command_observer, ) + try: + selected_preset = semantic.robot_profile.presets[ + semantic.integration.runtime_preset + ] + except KeyError as exc: + raise ValueError( + "The selected runtime preset is absent from the assembled robot " + "profile." + ) from exc + selected_runner_cfg = selected_preset.runner_cfg + if self._registration is None and self._runner_cfg is not None: + selected_runner_cfg = deepcopy(self._runner_cfg) runtime = SkillRuntime.from_components( semantic.compiler, observation_provider, command_sink, evidence_collector, clock=clock, - runner_cfg=self._runner_cfg, + runner_cfg=deepcopy(selected_runner_cfg), ) + parallel_safety_validator = self._parallel_safety_validator + if ( + self._registration is not None + and self._registration.parallel_safety_factory is not None + ): + if not isinstance( + self._factory, + ParallelCommandSafetyValidatorProvider, + ): + raise TypeError( + "A registration-owned parallel_safety_factory requires the " + "environment factory to implement " + "ParallelCommandSafetyValidatorProvider." + ) + parallel_safety_validator = ( + self._factory.create_parallel_command_safety_validator( + scene_registry=semantic.scene_registry, + engine=semantic.engine, + observation_provider=observation_provider, + ) + ) + self._validate_registration_ownership() + if not isinstance( + parallel_safety_validator, + ParallelCommandSafetyValidator, + ): + raise TypeError( + "create_parallel_command_safety_validator() must return a " + "ParallelCommandSafetyValidator." + ) return ExpertProgramRuntimeAssembly( integration=semantic.integration, scene_registry=semantic.scene_registry, @@ -607,6 +822,8 @@ def _assemble_execution_runtime( command_encoder=command_encoder, command_sink=command_sink, accepted_command_observer=accepted_command_observer, + runner_cfg=selected_runner_cfg, + parallel_safety_validator=parallel_safety_validator, runtime=runtime, ) @@ -634,7 +851,8 @@ def create_bridge(self, program: CompiledProgram) -> AtomicDemoBridge: assembly.clock, post_policy_port=self._post_policy_port, validator_port=self._validator_port, - parallel_safety_validator=self._parallel_safety_validator, + runner_cfg=assembly.runner_cfg, + parallel_safety_validator=assembly.parallel_safety_validator, ) def _preflight_program_surfaces( @@ -684,12 +902,13 @@ def _preflight_program( raise TypeError("compiler must be a SemanticSkillCompiler.") analyses = program.preflight_analyses() if any(analysis.kind == "parallel_branch" for analysis in analyses) and ( - self._parallel_safety_validator is None + not self._parallel_safety_is_registered ): raise ValueError( "Expert Programs containing parallel blocks require an explicit " "ParallelCommandSafetyValidator before bridge creation." ) + index = 0 while index < len(analyses): analysis = analyses[index] @@ -723,6 +942,13 @@ def _preflight_program( branch_paths=branch_paths, ) + @property + def _parallel_safety_is_registered(self) -> bool: + """Whether static assembly owns an authoritative parallel safety gate.""" + if self._registration is not None: + return self._registration.parallel_safety_factory is not None + return self._parallel_safety_validator is not None + def _validate_selection( self, integration: ExpertProgramIntegrationCfg, @@ -730,6 +956,7 @@ def _validate_selection( """Reject an integration selection owned by another adapter.""" if type(integration) is not ExpertProgramIntegrationCfg: raise TypeError("integration must be exactly ExpertProgramIntegrationCfg.") + self._validate_registration_ownership() current_scene_id = _validate_identifier( self._factory.scene_registry_id, field_name="factory.scene_registry_id", @@ -761,6 +988,28 @@ def _validate_selection( f"only {self._robot_profile_id!r}." ) + def _validate_registration_ownership(self) -> None: + """Reject a standard factory whose exact registration owner drifted.""" + registration = self._registration + if registration is None: + return + if not isinstance(self._factory, _RegistrationOwningExpertProgramFactory): + raise IntegrationFingerprintMismatch( + "The standard environment factory no longer exposes registration " + "ownership." + ) + current = self._factory.expert_program_registration + if type(current) is not SimulationExpertProgramRegistration: + raise IntegrationFingerprintMismatch( + "The standard environment factory no longer exposes an exact " + "SimulationExpertProgramRegistration." + ) + if current is not registration: + raise IntegrationFingerprintMismatch( + "The standard environment factory registration ownership changed " + "after adapter construction." + ) + def _create_scene_registry(self) -> SceneRegistry: """Create and validate one exact live scene registry.""" current_id = _validate_identifier( @@ -773,10 +1022,13 @@ def _create_scene_registry(self) -> SceneRegistry: f"{self._scene_registry_id!r}, got {current_id!r}." ) registry = self._factory.create_scene_registry() + self._validate_registration_ownership() if type(registry) is not SceneRegistry: raise TypeError( "create_scene_registry() must return exactly SceneRegistry." ) + if self._registration is not None: + self._registration.validate_scene_registry(registry) return registry def _create_manifest( diff --git a/embodichain/lab/gym/envs/expert_program/extensions.py b/embodichain/lab/gym/envs/expert_program/extensions.py new file mode 100644 index 00000000..a0d549f0 --- /dev/null +++ b/embodichain/lab/gym/envs/expert_program/extensions.py @@ -0,0 +1,908 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Typed standard-runtime extension declarations for Expert Programs. + +The values in this module deliberately describe extension wiring without +creating a simulator or resolving one live robot endpoint. A task +registration owns the corresponding adapter, transport, and safety-factory +instances, while its provider-free catalog owns the exact declarations below. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, fields, is_dataclass +from enum import Enum +from types import MappingProxyType +from typing import ClassVar, Protocol, runtime_checkable + +import torch + +from embodichain.lab.sim.atomic_actions.bindings import ( + JointPositionTarget, + RuntimeEndpointTarget, +) +from embodichain.lab.sim.atomic_actions.runtime_commands import RuntimeCommandPayload +from embodichain.lab.sim.skills.effects import ( + CONTROL_PART_EVIDENCE_PROVIDER_ID, + CONTROL_PART_EVIDENCE_PROVIDER_REVISION, +) +from embodichain.lab.sim.skills.parallel_runtime import ( + ParallelCommandSafetyValidator, +) +from embodichain.lab.sim.skills.profiles import ( + ControlPartEndpoint, + ControlPartEndpointAdapter, + ResourceEndpoint, + ResourceEndpointAdapter, + RobotSkillProfile, +) + +from .bridge import ( + JointPositionGymTransportEncoder, + RuntimeTransportActionEncoder, +) + +VersionedKey = tuple[str, str] +"""Exact ``(provider_or_projector_id, revision)`` registry key.""" + +_BUILTIN_TRACKING_FEEDBACK_SOURCE_KEYS = frozenset({("planning_context.robot", "1")}) +_BUILTIN_TRACKING_PROJECTOR_KEYS = frozenset({("joint_position_payload", "1")}) +_BUILTIN_EFFECT_EVIDENCE_SOURCE_KEYS = frozenset( + { + ( + CONTROL_PART_EVIDENCE_PROVIDER_ID, + CONTROL_PART_EVIDENCE_PROVIDER_REVISION, + ) + } +) + + +def _identifier(value: object, *, field_name: str) -> str: + """Validate one exact, non-empty registration identifier.""" + if type(value) is not str or not value or value != value.strip(): + raise ValueError( + f"{field_name} must be a non-empty string without outer whitespace." + ) + return value + + +def _qualified_name(value: type[object] | object) -> str: + """Return one deterministic diagnostic name.""" + value_type = value if isinstance(value, type) else type(value) + return f"{value_type.__module__}.{value_type.__qualname__}" + + +def _class_attribute(value: object, name: str, *, field_name: str) -> object: + """Read registration metadata from the provider type, never instance state.""" + owner = type(value) + if not hasattr(owner, name): + raise TypeError(f"{field_name} must be declared on {owner.__name__}.") + return getattr(owner, name) + + +def _versioned_keys(value: object, *, field_name: str) -> frozenset[VersionedKey]: + """Validate one exact immutable set of versioned registry keys.""" + if type(value) is not frozenset: + raise TypeError(f"{field_name} must be an exact frozenset.") + normalized: set[VersionedKey] = set() + for key in value: + if type(key) is not tuple or len(key) != 2: + raise TypeError(f"{field_name} must contain exact 2-tuples.") + identifier, revision = key + normalized.add( + ( + _identifier(identifier, field_name=f"{field_name} IDs"), + _identifier(revision, field_name=f"{field_name} revisions"), + ) + ) + return frozenset(normalized) + + +def _identifier_set(value: object, *, field_name: str) -> frozenset[str]: + """Validate one exact immutable set of identifiers.""" + if type(value) is not frozenset: + raise TypeError(f"{field_name} must be an exact frozenset.") + return frozenset(_identifier(item, field_name=field_name) for item in value) + + +def _type_tuple( + value: object, + *, + base_type: type[object], + field_name: str, +) -> tuple[type[object], ...]: + """Validate one non-empty exact tuple of unique exact value types.""" + if type(value) is not tuple or not value: + raise TypeError(f"{field_name} must be a non-empty exact tuple.") + normalized: list[type[object]] = [] + for item in value: + if not isinstance(item, type) or not issubclass(item, base_type): + raise TypeError( + f"{field_name} values must be {base_type.__name__} subclasses." + ) + normalized.append(item) + if len(set(normalized)) != len(normalized): + raise ValueError(f"{field_name} must not contain duplicate exact types.") + return tuple(normalized) + + +def validate_immutable_extension_declaration( + value: object, + *, + field_name: str, +) -> None: + """Accept only a deeply immutable frozen dataclass or stateless instance. + + Frozen dataclass fields may contain only immutable scalar values, types, + enums with immutable values, exact tuples, exact frozensets, and recursively + frozen dataclasses. + Mutable leaves such as mappings, lists, sets, bytearrays, and tensors are + rejected because registration-owned live extensions are shared with an + assembled runtime. A non-dataclass extension must not have instance or + slot state at all. + """ + if isinstance(value, type): + raise TypeError(f"{field_name} must contain instances, not types.") + + def validate_state( + declaration: object, + *, + path: str, + ) -> tuple[bool, tuple[str, ...]]: + """Validate declared state and return dataclass field names.""" + dataclass_declaration = is_dataclass(declaration) + dataclass_field_names: set[str] = set() + if dataclass_declaration: + params = getattr(type(declaration), "__dataclass_params__", None) + if params is None or not params.frozen: + raise TypeError( + f"{path} stateful declarations must be frozen dataclasses." + ) + dataclass_field_names.update(item.name for item in fields(declaration)) + + state_names: set[str] = set() + instance_state = getattr(declaration, "__dict__", None) + if isinstance(instance_state, Mapping): + state_names.update(instance_state) + for owner in type(declaration).__mro__: + declared_slots = getattr(owner, "__slots__", ()) + slots = ( + (declared_slots,) if isinstance(declared_slots, str) else declared_slots + ) + for slot_name in slots: + if slot_name in {"__dict__", "__weakref__"}: + continue + storage_name = ( + f"_{owner.__name__.lstrip('_')}{slot_name}" + if slot_name.startswith("__") and not slot_name.endswith("__") + else slot_name + ) + if hasattr(declaration, storage_name): + state_names.add(storage_name) + undeclared_state = ( + state_names.difference(dataclass_field_names) + if dataclass_declaration + else state_names + ) + if undeclared_state: + raise TypeError( + f"{path} contains unfingerprinted state " + f"{sorted(undeclared_state)}; Use a frozen dataclass with every " + "configuration field declared, or a stateless instance." + ) + return dataclass_declaration, tuple(sorted(dataclass_field_names)) + + def validate_nested( + nested: object, + *, + path: str, + active: set[int], + ) -> None: + """Reject every mutable or opaque leaf in one declaration graph.""" + if nested is None or type(nested) in {bool, int, float, str}: + return + if isinstance(nested, type): + return + if isinstance(nested, Enum): + validate_nested( + nested.value, + path=f"{path}.value", + active=active, + ) + return + if isinstance(nested, torch.Tensor) or type(nested) in { + list, + dict, + set, + bytearray, + }: + raise TypeError( + f"{path} must be deeply immutable; mutable value type " + f"{_qualified_name(nested)!r} is forbidden." + ) + if isinstance(nested, Mapping): + raise TypeError( + f"{path} must be deeply immutable; mapping values are forbidden." + ) + + nested_id = id(nested) + if nested_id in active: + raise TypeError(f"{path} must not contain a cyclic declaration graph.") + if type(nested) in {tuple, frozenset}: + active.add(nested_id) + try: + for index, item in enumerate(nested): + validate_nested( + item, + path=f"{path}[{index}]", + active=active, + ) + finally: + active.remove(nested_id) + return + if is_dataclass(nested) and not isinstance(nested, type): + active.add(nested_id) + try: + _, nested_field_names = validate_state(nested, path=path) + for nested_field_name in nested_field_names: + validate_nested( + getattr(nested, nested_field_name), + path=f"{path}.{nested_field_name}", + active=active, + ) + finally: + active.remove(nested_id) + return + raise TypeError( + f"{path} contains unsupported value type " + f"{_qualified_name(nested)!r}; extension declarations must be " + "complete deeply immutable data." + ) + + dataclass_declaration, dataclass_field_names = validate_state( + value, + path=field_name, + ) + if dataclass_declaration: + for dataclass_field_name in dataclass_field_names: + validate_nested( + getattr(value, dataclass_field_name), + path=f"{field_name}.{dataclass_field_name}", + active={id(value)}, + ) + + +@dataclass(frozen=True, slots=True) +class EndpointAdapterDeclaration: + """Provider-free declaration of one exact endpoint adapter.""" + + endpoint_type: type[ResourceEndpoint] + adapter_type: type[ResourceEndpointAdapter] + adapter_id: str + runtime_transport_ids: frozenset[str] + runtime_target_types: tuple[type[RuntimeEndpointTarget], ...] + tracking_feedback_source_keys: frozenset[VersionedKey] + tracking_projector_keys: frozenset[VersionedKey] + effect_evidence_source_keys: frozenset[VersionedKey] + + def __post_init__(self) -> None: + if not isinstance(self.endpoint_type, type) or not issubclass( + self.endpoint_type, ResourceEndpoint + ): + raise TypeError("endpoint_type must be a ResourceEndpoint subclass.") + if not isinstance(self.adapter_type, type) or not issubclass( + self.adapter_type, ResourceEndpointAdapter + ): + raise TypeError("adapter_type must be a ResourceEndpointAdapter subclass.") + _identifier(self.adapter_id, field_name="adapter_id") + object.__setattr__( + self, + "runtime_transport_ids", + _identifier_set( + self.runtime_transport_ids, + field_name="runtime_transport_ids", + ), + ) + if not self.runtime_transport_ids: + raise ValueError("runtime_transport_ids must not be empty.") + object.__setattr__( + self, + "runtime_target_types", + _type_tuple( + self.runtime_target_types, + base_type=RuntimeEndpointTarget, + field_name="runtime_target_types", + ), + ) + object.__setattr__( + self, + "tracking_feedback_source_keys", + _versioned_keys( + self.tracking_feedback_source_keys, + field_name="tracking_feedback_source_keys", + ), + ) + object.__setattr__( + self, + "tracking_projector_keys", + _versioned_keys( + self.tracking_projector_keys, + field_name="tracking_projector_keys", + ), + ) + object.__setattr__( + self, + "effect_evidence_source_keys", + _versioned_keys( + self.effect_evidence_source_keys, + field_name="effect_evidence_source_keys", + ), + ) + + +@dataclass(frozen=True, slots=True) +class RuntimeTransportDeclaration: + """Provider-free declaration of one ordered runtime transport encoder.""" + + transport_type: type[RuntimeTransportActionEncoder] + transport_id: str + target_types: tuple[type[RuntimeEndpointTarget], ...] + payload_types: tuple[type[RuntimeCommandPayload], ...] + + def __post_init__(self) -> None: + if not isinstance(self.transport_type, type): + raise TypeError("transport_type must be a type.") + _identifier(self.transport_id, field_name="transport_id") + object.__setattr__( + self, + "target_types", + _type_tuple( + self.target_types, + base_type=RuntimeEndpointTarget, + field_name="target_types", + ), + ) + object.__setattr__( + self, + "payload_types", + _type_tuple( + self.payload_types, + base_type=RuntimeCommandPayload, + field_name="payload_types", + ), + ) + for field_name, declared_types in ( + ("target_types", self.target_types), + ("payload_types", self.payload_types), + ): + for declared_type in declared_types: + try: + type_transport_id = declared_type.__dict__["TRANSPORT_ID"] + except KeyError as exc: + raise TypeError( + f"{field_name} value {declared_type.__name__} must declare " + "an exact ClassVar TRANSPORT_ID on that type; inherited or " + "instance-only transport IDs are forbidden." + ) from exc + _identifier( + type_transport_id, + field_name=f"{declared_type.__name__}.TRANSPORT_ID", + ) + if type_transport_id != self.transport_id: + raise ValueError( + f"{field_name} value {declared_type.__name__} declares " + f"transport {type_transport_id!r}, not " + f"{self.transport_id!r}." + ) + + +@dataclass(frozen=True, slots=True) +class ParallelSafetyDeclaration: + """Provider-free identity and transport coverage of one safety factory.""" + + factory_type: type[object] + validator_id: str + revision: str + supported_transport_ids: frozenset[str] + + def __post_init__(self) -> None: + if not isinstance(self.factory_type, type): + raise TypeError("factory_type must be a type.") + _identifier(self.validator_id, field_name="validator_id") + _identifier(self.revision, field_name="revision") + object.__setattr__( + self, + "supported_transport_ids", + _identifier_set( + self.supported_transport_ids, + field_name="supported_transport_ids", + ), + ) + if not self.supported_transport_ids: + raise ValueError("supported_transport_ids must not be empty.") + + +@runtime_checkable +class ParallelCommandSafetyValidatorFactory(Protocol): + """Registration-owned factory for one authoritative live safety gate.""" + + validator_id: ClassVar[str] + revision: ClassVar[str] + supported_transport_ids: ClassVar[frozenset[str]] + + def create( + self, + *, + simulation: object, + robot: object, + ) -> ParallelCommandSafetyValidator: + """Create one live validator bound to the exact simulation and robot.""" + + +@dataclass(frozen=True, slots=True) +class StandardExtensionDeclarations: + """Cross-checked provider-free declarations for the standard factory.""" + + endpoint_adapters: Mapping[type[ResourceEndpoint], EndpointAdapterDeclaration] + runtime_transports: tuple[RuntimeTransportDeclaration, ...] + parallel_safety: ParallelSafetyDeclaration | None + + def __post_init__(self) -> None: + if not isinstance(self.endpoint_adapters, Mapping): + raise TypeError("endpoint_adapters must be a mapping.") + normalized: dict[type[ResourceEndpoint], EndpointAdapterDeclaration] = {} + for endpoint_type, declaration in self.endpoint_adapters.items(): + if type(declaration) is not EndpointAdapterDeclaration: + raise TypeError( + "endpoint_adapters values must be EndpointAdapterDeclaration " + "values." + ) + if endpoint_type is not declaration.endpoint_type: + raise ValueError( + "endpoint_adapters keys must exactly match declaration " + "endpoint_type values." + ) + normalized[endpoint_type] = declaration + object.__setattr__(self, "endpoint_adapters", MappingProxyType(normalized)) + transports = tuple(self.runtime_transports) + if not transports or not all( + type(value) is RuntimeTransportDeclaration for value in transports + ): + raise TypeError( + "runtime_transports must contain RuntimeTransportDeclaration values." + ) + object.__setattr__(self, "runtime_transports", transports) + if ( + self.parallel_safety is not None + and type(self.parallel_safety) is not ParallelSafetyDeclaration + ): + raise TypeError( + "parallel_safety must be ParallelSafetyDeclaration or None." + ) + adapter_ids = [value.adapter_id for value in normalized.values()] + if len(set(adapter_ids)) != len(adapter_ids): + raise ValueError("Endpoint adapter IDs must be unique.") + transport_ids = [value.transport_id for value in transports] + if len(set(transport_ids)) != len(transport_ids): + raise ValueError("Runtime transport IDs must be unique.") + transport_by_id = {value.transport_id: value for value in transports} + required_transport_ids = frozenset( + transport_id + for declaration in normalized.values() + for transport_id in declaration.runtime_transport_ids + ) + if required_transport_ids != frozenset(transport_by_id): + raise ValueError( + "Provider-free runtime transports must exactly cover endpoint " + f"adapter transport IDs; expected {sorted(required_transport_ids)}, " + f"got {sorted(transport_by_id)}." + ) + target_owners: dict[type[RuntimeEndpointTarget], str] = {} + for transport in transports: + for target_type in transport.target_types: + if target_type in target_owners: + raise ValueError( + f"Runtime target type {_qualified_name(target_type)!r} has " + "multiple transport owners." + ) + target_owners[target_type] = transport.transport_id + declared_target_types: set[type[RuntimeEndpointTarget]] = set() + for adapter in normalized.values(): + counts = {transport_id: 0 for transport_id in adapter.runtime_transport_ids} + for target_type in adapter.runtime_target_types: + owner = target_owners.get(target_type) + if owner is None or owner not in counts: + raise ValueError( + f"Endpoint adapter {adapter.adapter_id!r} target type " + f"{_qualified_name(target_type)!r} has no matching " + "declared transport." + ) + counts[owner] += 1 + declared_target_types.add(target_type) + unused = sorted(key for key, count in counts.items() if count == 0) + if unused: + raise ValueError( + f"Endpoint adapter {adapter.adapter_id!r} declares unused " + f"transport IDs {unused}." + ) + if declared_target_types != set(target_owners): + raise ValueError( + "Provider-free runtime target types must be covered exactly by " + "endpoint adapter declarations." + ) + _validate_builtin_routes(normalized) + if self.parallel_safety is not None and ( + self.parallel_safety.supported_transport_ids != frozenset(transport_by_id) + ): + raise ValueError( + "Parallel safety transport coverage must exactly match the " + "provider-free runtime transports." + ) + + +def declare_endpoint_adapter( + adapter: ResourceEndpointAdapter, +) -> EndpointAdapterDeclaration: + """Read one endpoint adapter's exact static extension contract.""" + if not isinstance(adapter, ResourceEndpointAdapter): + raise TypeError("endpoint adapters must be ResourceEndpointAdapter instances.") + validate_immutable_extension_declaration( + adapter, + field_name="endpoint_adapters", + ) + endpoint_type = _class_attribute( + adapter, + "endpoint_type", + field_name="ResourceEndpointAdapter.endpoint_type", + ) + if not isinstance(endpoint_type, type) or not issubclass( + endpoint_type, ResourceEndpoint + ): + raise TypeError( + "ResourceEndpointAdapter.endpoint_type must be a ResourceEndpoint " + "subclass." + ) + return EndpointAdapterDeclaration( + endpoint_type=endpoint_type, + adapter_type=type(adapter), + adapter_id=_identifier( + _class_attribute( + adapter, + "adapter_id", + field_name="ResourceEndpointAdapter.adapter_id", + ), + field_name="ResourceEndpointAdapter.adapter_id", + ), + runtime_transport_ids=_class_attribute( + adapter, + "runtime_transport_ids", + field_name="ResourceEndpointAdapter.runtime_transport_ids", + ), + runtime_target_types=_class_attribute( + adapter, + "runtime_target_types", + field_name="ResourceEndpointAdapter.runtime_target_types", + ), + tracking_feedback_source_keys=_class_attribute( + adapter, + "tracking_feedback_source_keys", + field_name="ResourceEndpointAdapter.tracking_feedback_source_keys", + ), + tracking_projector_keys=_class_attribute( + adapter, + "tracking_projector_keys", + field_name="ResourceEndpointAdapter.tracking_projector_keys", + ), + effect_evidence_source_keys=_class_attribute( + adapter, + "effect_evidence_source_keys", + field_name="ResourceEndpointAdapter.effect_evidence_source_keys", + ), + ) + + +def declare_runtime_transport( + transport: RuntimeTransportActionEncoder, +) -> RuntimeTransportDeclaration: + """Read one runtime encoder's exact static target/payload contract.""" + if not isinstance(transport, RuntimeTransportActionEncoder): + raise TypeError( + "runtime_transports must implement RuntimeTransportActionEncoder." + ) + validate_immutable_extension_declaration( + transport, + field_name="runtime_transports", + ) + return RuntimeTransportDeclaration( + transport_type=type(transport), + transport_id=_identifier( + _class_attribute( + transport, + "transport_id", + field_name="RuntimeTransportActionEncoder.transport_id", + ), + field_name="RuntimeTransportActionEncoder.transport_id", + ), + target_types=_class_attribute( + transport, + "target_types", + field_name="RuntimeTransportActionEncoder.target_types", + ), + payload_types=_class_attribute( + transport, + "payload_types", + field_name="RuntimeTransportActionEncoder.payload_types", + ), + ) + + +def declare_parallel_safety_factory( + factory: ParallelCommandSafetyValidatorFactory, +) -> ParallelSafetyDeclaration: + """Read one safety factory's exact static identity and coverage.""" + create = getattr(factory, "create", None) + if not callable(create): + raise TypeError("parallel_safety_factory must define create().") + validate_immutable_extension_declaration( + factory, + field_name="parallel_safety_factory", + ) + return ParallelSafetyDeclaration( + factory_type=type(factory), + validator_id=_identifier( + _class_attribute( + factory, + "validator_id", + field_name="ParallelCommandSafetyValidatorFactory.validator_id", + ), + field_name="ParallelCommandSafetyValidatorFactory.validator_id", + ), + revision=_identifier( + _class_attribute( + factory, + "revision", + field_name="ParallelCommandSafetyValidatorFactory.revision", + ), + field_name="ParallelCommandSafetyValidatorFactory.revision", + ), + supported_transport_ids=_class_attribute( + factory, + "supported_transport_ids", + field_name=( + "ParallelCommandSafetyValidatorFactory.supported_transport_ids" + ), + ), + ) + + +def _profile_endpoint_types( + profile: RobotSkillProfile, +) -> frozenset[type[ResourceEndpoint]]: + """Return every exact endpoint declaration type used by one profile.""" + if type(profile) is not RobotSkillProfile: + raise TypeError("profile must be exactly RobotSkillProfile.") + return frozenset( + type(endpoint) + for resource in profile.resources.values() + for endpoint in resource.endpoints.values() + ) + + +def _validate_builtin_routes( + declarations: Mapping[type[ResourceEndpoint], EndpointAdapterDeclaration], +) -> None: + """Keep C1 custom endpoints open-loop and preserve exact built-in routes.""" + for endpoint_type, declaration in declarations.items(): + if endpoint_type is ControlPartEndpoint: + if ( + declaration.tracking_feedback_source_keys + != _BUILTIN_TRACKING_FEEDBACK_SOURCE_KEYS + or declaration.tracking_projector_keys + != _BUILTIN_TRACKING_PROJECTOR_KEYS + or declaration.effect_evidence_source_keys + != _BUILTIN_EFFECT_EVIDENCE_SOURCE_KEYS + ): + raise ValueError( + "The built-in ControlPartEndpoint adapter must retain its " + "exact tracking and effect-evidence routes." + ) + continue + if ( + declaration.tracking_feedback_source_keys + or declaration.tracking_projector_keys + or declaration.effect_evidence_source_keys + ): + raise ValueError( + f"Custom endpoint adapter {declaration.adapter_id!r} must declare " + "empty tracking and effect-evidence routes; the C1 standard " + "simulation factory does not install custom closed-loop providers." + ) + + +def build_standard_extension_declarations( + *, + profile: RobotSkillProfile, + endpoint_adapters: tuple[ResourceEndpointAdapter, ...], + runtime_transports: tuple[RuntimeTransportActionEncoder, ...], + parallel_safety_factory: ParallelCommandSafetyValidatorFactory | None, +) -> StandardExtensionDeclarations: + """Cross-check standard-runtime extensions against one exact profile. + + The built-in control-part adapter and joint-position transport cannot be + overridden. They are installed first only when the profile uses a + :class:`ControlPartEndpoint`; a pure-custom profile contains only its custom + declarations. Custom adapters and transports must cover exactly the endpoint + types and transport IDs used by the registered profile; unused declarations + fail closed. + """ + if type(endpoint_adapters) is not tuple: + raise TypeError("endpoint_adapters must be an exact tuple.") + if type(runtime_transports) is not tuple: + raise TypeError("runtime_transports must be an exact tuple.") + + builtin_adapter = declare_endpoint_adapter(ControlPartEndpointAdapter()) + custom_adapters = tuple( + declare_endpoint_adapter(adapter) for adapter in endpoint_adapters + ) + adapter_declarations = (builtin_adapter, *custom_adapters) + endpoint_types = [value.endpoint_type for value in adapter_declarations] + adapter_ids = [value.adapter_id for value in adapter_declarations] + if len(set(endpoint_types)) != len(endpoint_types): + raise ValueError( + "Endpoint adapter declarations contain a duplicate exact endpoint " + "type or attempt to override the built-in ControlPartEndpoint." + ) + if len(set(adapter_ids)) != len(adapter_ids): + raise ValueError( + "Endpoint adapter declarations contain a duplicate adapter ID or " + "attempt to override a built-in adapter." + ) + installed_by_type = { + declaration.endpoint_type: declaration for declaration in adapter_declarations + } + used_endpoint_types = _profile_endpoint_types(profile) + missing_adapters = used_endpoint_types - set(installed_by_type) + unused_adapters = set(installed_by_type) - used_endpoint_types + unused_adapters.discard(ControlPartEndpoint) + if missing_adapters or unused_adapters: + raise ValueError( + "Endpoint adapter coverage must exactly match profile endpoint types; " + f"missing={sorted(_qualified_name(value) for value in missing_adapters)}, " + f"unused={sorted(_qualified_name(value) for value in unused_adapters)}." + ) + if ControlPartEndpoint not in used_endpoint_types: + installed_by_type.pop(ControlPartEndpoint) + + _validate_builtin_routes(installed_by_type) + + builtin_transport = declare_runtime_transport(JointPositionGymTransportEncoder()) + custom_transports = tuple( + declare_runtime_transport(transport) for transport in runtime_transports + ) + transport_declarations = (builtin_transport, *custom_transports) + transport_ids = [value.transport_id for value in transport_declarations] + if len(set(transport_ids)) != len(transport_ids): + raise ValueError( + "Runtime transport declarations contain a duplicate transport ID or " + "attempt to override the built-in joint-position transport." + ) + transport_by_id = { + declaration.transport_id: declaration for declaration in transport_declarations + } + required_transport_ids = frozenset( + transport_id + for declaration in installed_by_type.values() + for transport_id in declaration.runtime_transport_ids + ) + missing_transports = required_transport_ids - set(transport_by_id) + unused_transports = set(transport_by_id) - required_transport_ids + unused_transports.discard(JointPositionTarget.TRANSPORT_ID) + if missing_transports or unused_transports: + raise ValueError( + "Runtime transport coverage must exactly match endpoint adapters; " + f"missing={sorted(missing_transports)}, " + f"unused={sorted(unused_transports)}." + ) + if JointPositionTarget.TRANSPORT_ID not in required_transport_ids: + transport_declarations = custom_transports + transport_by_id.pop(JointPositionTarget.TRANSPORT_ID) + + target_owners: dict[type[RuntimeEndpointTarget], str] = {} + for transport in transport_declarations: + for target_type in transport.target_types: + previous = target_owners.get(target_type) + if previous is not None: + raise ValueError( + f"Runtime target type {_qualified_name(target_type)!r} is " + f"declared by both transports {previous!r} and " + f"{transport.transport_id!r}." + ) + target_owners[target_type] = transport.transport_id + + adapter_target_types: set[type[RuntimeEndpointTarget]] = set() + for adapter in installed_by_type.values(): + for transport_id in adapter.runtime_transport_ids: + if transport_id not in transport_by_id: + raise ValueError( + f"Endpoint adapter {adapter.adapter_id!r} requires missing " + f"transport {transport_id!r}." + ) + per_transport_counts = { + transport_id: 0 for transport_id in adapter.runtime_transport_ids + } + for target_type in adapter.runtime_target_types: + owner = target_owners.get(target_type) + if owner is None or owner not in adapter.runtime_transport_ids: + raise ValueError( + f"Endpoint adapter {adapter.adapter_id!r} target type " + f"{_qualified_name(target_type)!r} is not covered by one of " + f"its transports {sorted(adapter.runtime_transport_ids)}." + ) + per_transport_counts[owner] += 1 + adapter_target_types.add(target_type) + unused_adapter_transport_ids = sorted( + transport_id + for transport_id, count in per_transport_counts.items() + if count == 0 + ) + if unused_adapter_transport_ids: + raise ValueError( + f"Endpoint adapter {adapter.adapter_id!r} declares unused " + f"transport IDs {unused_adapter_transport_ids}." + ) + extra_transport_target_types = set(target_owners) - adapter_target_types + if extra_transport_target_types: + raise ValueError( + "Runtime transports declare target types unused by endpoint adapters: " + f"{sorted(_qualified_name(value) for value in extra_transport_target_types)}." + ) + + parallel_safety = ( + None + if parallel_safety_factory is None + else declare_parallel_safety_factory(parallel_safety_factory) + ) + if parallel_safety is not None: + installed_transport_ids = frozenset(transport_by_id) + if parallel_safety.supported_transport_ids != installed_transport_ids: + raise ValueError( + "parallel_safety_factory must support exactly the registered " + f"runtime transports; expected {sorted(installed_transport_ids)}, " + f"got {sorted(parallel_safety.supported_transport_ids)}." + ) + + return StandardExtensionDeclarations( + endpoint_adapters=installed_by_type, + runtime_transports=transport_declarations, + parallel_safety=parallel_safety, + ) + + +__all__ = [ + "EndpointAdapterDeclaration", + "ParallelCommandSafetyValidatorFactory", + "ParallelSafetyDeclaration", + "RuntimeTransportDeclaration", + "StandardExtensionDeclarations", + "VersionedKey", + "build_standard_extension_declarations", + "declare_endpoint_adapter", + "declare_parallel_safety_factory", + "declare_runtime_transport", + "validate_immutable_extension_declaration", +] diff --git a/embodichain/lab/gym/envs/expert_program/simulation_environment.py b/embodichain/lab/gym/envs/expert_program/simulation_environment.py index 6fa9f9df..abcacd90 100644 --- a/embodichain/lab/gym/envs/expert_program/simulation_environment.py +++ b/embodichain/lab/gym/envs/expert_program/simulation_environment.py @@ -58,7 +58,6 @@ ControlPartCommandProfile, JointPositionCommand, ) -from embodichain.lab.sim.atomic_actions.runner import ExecutionRunnerCfg from embodichain.lab.sim.atomic_actions.runtime_commands import ( JointPositionPayload, RuntimeCommandFrame, @@ -69,30 +68,22 @@ MotionGenerator, ToppraPlannerCfg, ) -from embodichain.lab.sim.skills.compiler import ( - RegisteredSemanticLowerer, -) from embodichain.lab.sim.skills.effects import ( ControlPartEvidenceAddress, - EffectMonitorRegistry, ) from embodichain.lab.sim.skills.evidence import ( BinaryEffectEvidenceQuery, - BinaryObservationCallback, BinaryEffectObservation, ControlPartRobotEvidenceSource, ControlPartSimulationEvidenceProvider, EffectEvidenceCollectionContext, EffectEvidenceProvider, - ScalarObservationCallback, SceneArticulationEvidenceProvider, ) from embodichain.lab.sim.skills.parallel_runtime import ( ParallelCommandSafetyValidator, ) from embodichain.lab.sim.skills.profiles import ( - ResourceEndpoint, - ResourceEndpointAdapter, RobotSkillProfile, SkillPolicyPreset, ) @@ -102,7 +93,6 @@ AcceptedRuntimeCommandObserver, EnvironmentStepClock, GymPlanningObservationProvider, - RuntimeTransportActionEncoder, ) from .catalog import SimulationExpertProgramRegistration from .environment import ( @@ -725,14 +715,8 @@ class SimulationExpertProgramFactory(ExpertProgramEnvironmentFactory): motion_generator_factory: Optional fresh-generator factory. It is mutually exclusive with ``planner_cfg`` and intended for custom planners and isolated tests. - endpoint_adapters: Explicit adapters for non-built-in resource endpoint - types. translation_threshold: Material scene translation threshold. rotation_threshold: Material scene rotation threshold. - contact_observer: Optional raw contact evidence callback. - constraint_observer: Optional raw constraint evidence callback. - force_observer: Optional raw force evidence callback. - wrench_observer: Optional raw wrench evidence callback. Every profile policy is rebuilt with ``control_dt == step_dt``. The Gym cadence is authoritative because commands cannot be emitted between @@ -749,15 +733,8 @@ def __init__( step_dt: float, planner_cfg: BasePlannerCfg | None = None, motion_generator_factory: MotionGeneratorFactory | None = None, - endpoint_adapters: ( - Mapping[type[ResourceEndpoint], ResourceEndpointAdapter] | None - ) = None, translation_threshold: float = 1.0e-4, rotation_threshold: float = 1.0e-3, - contact_observer: BinaryObservationCallback | None = None, - constraint_observer: BinaryObservationCallback | None = None, - force_observer: ScalarObservationCallback | None = None, - wrench_observer: ScalarObservationCallback | None = None, ) -> None: if type(registration) is not SimulationExpertProgramRegistration: raise TypeError( @@ -774,17 +751,6 @@ def __init__( motion_generator_factory ): raise TypeError("motion_generator_factory must be callable or None.") - if endpoint_adapters is not None and not isinstance(endpoint_adapters, Mapping): - raise TypeError("endpoint_adapters must be a mapping or None.") - for name, callback in ( - ("contact_observer", contact_observer), - ("constraint_observer", constraint_observer), - ("force_observer", force_observer), - ("wrench_observer", wrench_observer), - ): - if callback is not None and not callable(callback): - raise TypeError(f"{name} must be callable or None.") - robot_uid = _robot_uid(robot) get_robot = getattr(simulation, "get_robot", None) if not callable(get_robot): @@ -811,9 +777,7 @@ def __init__( self._step_dt = _positive_finite(step_dt, field_name="step_dt") self._planner_cfg = selected_planner_cfg self._motion_generator_factory = motion_generator_factory - self._endpoint_adapters = ( - None if endpoint_adapters is None else dict(endpoint_adapters) - ) + self._endpoint_adapters = dict(registration.endpoint_adapter_map) self._translation_threshold = _non_negative_finite( translation_threshold, field_name="translation_threshold", @@ -822,10 +786,6 @@ def __init__( rotation_threshold, field_name="rotation_threshold", ) - self._contact_observer = contact_observer - self._constraint_observer = constraint_observer - self._force_observer = force_observer - self._wrench_observer = wrench_observer self._owner_token = object() qpos = _full_robot_tensor(robot, "get_qpos", required=True) @@ -851,15 +811,8 @@ def from_environment( registration: SimulationExpertProgramRegistration, planner_cfg: BasePlannerCfg | None = None, motion_generator_factory: MotionGeneratorFactory | None = None, - endpoint_adapters: ( - Mapping[type[ResourceEndpoint], ResourceEndpointAdapter] | None - ) = None, translation_threshold: float = 1.0e-4, rotation_threshold: float = 1.0e-3, - contact_observer: BinaryObservationCallback | None = None, - constraint_observer: BinaryObservationCallback | None = None, - force_observer: ScalarObservationCallback | None = None, - wrench_observer: ScalarObservationCallback | None = None, ) -> SimulationExpertProgramFactory: """Create a factory from the explicit standard Gym environment surface.""" simulation = getattr(environment, "sim", None) @@ -877,13 +830,8 @@ def from_environment( step_dt=step_dt, planner_cfg=planner_cfg, motion_generator_factory=motion_generator_factory, - endpoint_adapters=endpoint_adapters, translation_threshold=translation_threshold, rotation_threshold=rotation_threshold, - contact_observer=contact_observer, - constraint_observer=constraint_observer, - force_observer=force_observer, - wrench_observer=wrench_observer, ) @property @@ -901,19 +849,21 @@ def step_dt(self) -> float: """Return the authoritative Gym control cadence.""" return self._step_dt + @property + def expert_program_registration(self) -> SimulationExpertProgramRegistration: + """Return the exact standard registration owned by this factory.""" + return self._registration + @property def segment_policy_port(self) -> SimulationSegmentPolicyPort: """Return the shared simulation post-policy and validator port.""" return self._segment_policy_port - @property - def endpoint_adapters( + def registration_owned_segment_policy_ports( self, - ) -> Mapping[type[ResourceEndpoint], ResourceEndpointAdapter] | None: - """Return an owned copy of installed custom endpoint adapters.""" - return ( - None if self._endpoint_adapters is None else dict(self._endpoint_adapters) - ) + ) -> tuple[SimulationSegmentPolicyPort, SimulationSegmentPolicyPort]: + """Return the exact factory-owned segment policy ports.""" + return self._segment_policy_port, self._segment_policy_port def create_scene_registry(self) -> SceneRegistry: """Build one fresh authoritative registry from explicit bindings.""" @@ -974,7 +924,7 @@ def create_atomic_action_engine( skill_profile=profile, endpoint_adapters=self._endpoint_adapters, ) - self._registration.catalog.validate_engine(engine) + self._registration.validate_engine(engine) return engine def create_planning_observation_provider( @@ -1038,18 +988,14 @@ def create_effect_evidence_providers( raise ValueError("observation_provider belongs to another factory.") scene_provider = observation_provider.scene_provider command_state_tracker = observation_provider.command_state_tracker - contact_observer = self._contact_observer or command_state_tracker - constraint_observer = self._constraint_observer or command_state_tracker providers: list[EffectEvidenceProvider] = [] if isinstance(self._robot, ControlPartRobotEvidenceSource): providers.append( ControlPartSimulationEvidenceProvider( self._robot, scene_provider=scene_provider, - contact_observer=contact_observer, - constraint_observer=constraint_observer, - force_observer=self._force_observer, - wrench_observer=self._wrench_observer, + contact_observer=command_state_tracker, + constraint_observer=command_state_tracker, ) ) providers.append( @@ -1080,31 +1026,49 @@ def create_accepted_runtime_command_observer( raise ValueError("observation_provider belongs to another factory.") return observation_provider.command_state_tracker - def create_adapter( + def create_parallel_command_safety_validator( self, *, - registered_lowerers: Iterable[RegisteredSemanticLowerer] = (), - effect_monitor_registry: EffectMonitorRegistry | None = None, - runtime_transports: Iterable[RuntimeTransportActionEncoder] = (), - runner_cfg: ExecutionRunnerCfg | None = None, - parallel_safety_validator: ParallelCommandSafetyValidator | None = None, - ) -> ExpertProgramEnvironmentAdapter: + scene_registry: SceneRegistry, + engine: AtomicActionEngine, + observation_provider: PlanningObservationPort, + ) -> ParallelCommandSafetyValidator: + """Create one fresh live gate from the registration-owned factory.""" + self._registration.assert_unchanged() + if type(scene_registry) is not SceneRegistry: + raise TypeError("scene_registry must be exactly SceneRegistry.") + if ( + not isinstance(engine, AtomicActionEngine) + or engine.robot is not self._robot + ): + raise ValueError("engine must own the exact factory robot.") + if type(observation_provider) is not SimulationPlanningObservationProvider: + raise TypeError( + "observation_provider must be exactly " + "SimulationPlanningObservationProvider." + ) + if not observation_provider.is_owned_by(self._owner_token): + raise ValueError("observation_provider belongs to another factory.") + if self._registration.parallel_safety_factory is None: + raise RuntimeError("No parallel_safety_factory is registered.") + validator = self._registration.create_parallel_safety_validator( + simulation=self._simulation, + robot=self._robot, + ) + if not isinstance(validator, ParallelCommandSafetyValidator): + raise TypeError( + "ParallelCommandSafetyValidatorFactory.create() must return a " + "ParallelCommandSafetyValidator." + ) + return validator + + def create_adapter(self) -> ExpertProgramEnvironmentAdapter: """Create the exact Gym adapter with shared simulation policy ports.""" self._registration.assert_unchanged() return ExpertProgramEnvironmentAdapter( self, step_dt=self._step_dt, - integration_catalog=self._registration.catalog, - endpoint_adapters=self._endpoint_adapters, - registered_lowerers=registered_lowerers, - relation_grounders=self._registration.relation_grounders, - handover_pose_providers=self._registration.handover_pose_providers, - effect_monitor_registry=effect_monitor_registry, - runtime_transports=runtime_transports, - runner_cfg=runner_cfg, - post_policy_port=self._segment_policy_port, - validator_port=self._segment_policy_port, - parallel_safety_validator=parallel_safety_validator, + registration=self._registration, ) def _create_motion_generator(self) -> MotionGenerator: @@ -1131,17 +1095,8 @@ def create_simulation_expert_program_adapter( registration: SimulationExpertProgramRegistration, planner_cfg: BasePlannerCfg | None = None, motion_generator_factory: MotionGeneratorFactory | None = None, - endpoint_adapters: ( - Mapping[type[ResourceEndpoint], ResourceEndpointAdapter] | None - ) = None, - runtime_transports: Iterable[RuntimeTransportActionEncoder] = (), translation_threshold: float = 1.0e-4, rotation_threshold: float = 1.0e-3, - contact_observer: BinaryObservationCallback | None = None, - constraint_observer: BinaryObservationCallback | None = None, - force_observer: ScalarObservationCallback | None = None, - wrench_observer: ScalarObservationCallback | None = None, - parallel_safety_validator: ParallelCommandSafetyValidator | None = None, ) -> ExpertProgramEnvironmentAdapter: """Create a complete production adapter from one standard Gym environment. @@ -1149,11 +1104,9 @@ def create_simulation_expert_program_adapter( grounders and embodiment-owned handover pose providers come exclusively from ``registration``, so the statically fingerprinted objects are the exact objects consumed by the runtime compiler. Calls that require an unregistered - provider remain fail-closed during program preflight. Advanced callers can - retain :class:`SimulationExpertProgramFactory` and call ``create_adapter`` - directly to install registered semantic lowerers or custom monitors. Custom - endpoint adapters and their matching Gym runtime transports are accepted - here so a non-joint endpoint remains executable through the one-line path. + provider remain fail-closed during program preflight. Endpoint adapters, + runtime transports, and parallel safety are also registration-owned; the + standard helper exposes no live extension override surface. Args: environment: Standard Gym simulation environment exposing ``sim``, @@ -1161,15 +1114,8 @@ def create_simulation_expert_program_adapter( registration: Exact task registration used during static config loading. planner_cfg: Optional planner configuration owned by the factory. motion_generator_factory: Optional factory for one fresh motion generator. - endpoint_adapters: Optional exact-type custom endpoint adapters. - runtime_transports: Additional runtime-command-to-Gym encoders. translation_threshold: Scene translation revision threshold. rotation_threshold: Scene rotation revision threshold. - contact_observer: Optional raw contact evidence callback. - constraint_observer: Optional raw constraint evidence callback. - force_observer: Optional raw force evidence callback. - wrench_observer: Optional raw wrench evidence callback. - parallel_safety_validator: Optional authoritative parallel-command gate. Returns: Complete production Expert Program environment adapter. @@ -1179,18 +1125,10 @@ def create_simulation_expert_program_adapter( registration=registration, planner_cfg=planner_cfg, motion_generator_factory=motion_generator_factory, - endpoint_adapters=endpoint_adapters, translation_threshold=translation_threshold, rotation_threshold=rotation_threshold, - contact_observer=contact_observer, - constraint_observer=constraint_observer, - force_observer=force_observer, - wrench_observer=wrench_observer, - ) - return factory.create_adapter( - runtime_transports=runtime_transports, - parallel_safety_validator=parallel_safety_validator, ) + return factory.create_adapter() __all__ = [ diff --git a/embodichain/lab/sim/skills/parallel_runtime.py b/embodichain/lab/sim/skills/parallel_runtime.py index 235bb7e1..bbaf43e9 100644 --- a/embodichain/lab/sim/skills/parallel_runtime.py +++ b/embodichain/lab/sim/skills/parallel_runtime.py @@ -19,6 +19,7 @@ from __future__ import annotations from collections.abc import Hashable, Mapping +from copy import deepcopy from dataclasses import dataclass, field import math from types import MappingProxyType @@ -30,6 +31,7 @@ CommandAcknowledgement, CommandSink, ExecutionClock, + ExecutionRunnerCfg, PlanningContext, RuntimeCommandFrame, RuntimeEndpointTarget, @@ -644,6 +646,7 @@ def __init__( *, timeout_steps: int, failure_policy: str = "fail_fast", + runner_cfg: ExecutionRunnerCfg | None = None, ) -> None: if not isinstance(branches, tuple) or len(branches) < 2: raise ValueError("ParallelSkillRuntime requires at least two branches.") @@ -674,6 +677,8 @@ def __init__( raise ValueError("timeout_steps must be positive.") if failure_policy != "fail_fast": raise ValueError("failure_policy must be exactly 'fail_fast'.") + if runner_cfg is not None and not isinstance(runner_cfg, ExecutionRunnerCfg): + raise TypeError("runner_cfg must be an ExecutionRunnerCfg or None.") initial = branches[0].runtime.result for branch in branches[1:]: result = branch.runtime.result @@ -696,6 +701,7 @@ def __init__( self._clock = clock self._timing_policy = timing_policy self._safety_validator = safety_validator + self._runner_cfg = deepcopy(runner_cfg or ExecutionRunnerCfg()) self._timeout_steps = timeout_steps self._initial_state = initial.task_state self._task_state = initial.task_state @@ -731,6 +737,7 @@ def from_template( *, timeout_steps: int, failure_policy: str = "fail_fast", + runner_cfg: ExecutionRunnerCfg | None = None, workflow_id: str = "parallel_static_analysis", branch_paths: Mapping[str, tuple[PathPart, ...]] | None = None, ) -> ParallelSkillRuntime: @@ -750,6 +757,8 @@ def from_template( synchronized outbound command. timeout_steps: Maximum environment steps at the barrier. failure_policy: Row-local barrier failure policy. + runner_cfg: Shared command timeout, safe-stop, completion-hold, and + minimum-cycle policy selected by the runtime preset. workflow_id: Stable prefix for provider-free claim analysis. branch_paths: Optional exact source path for every branch. @@ -790,6 +799,7 @@ def from_template( safety_validator, timeout_steps=timeout_steps, failure_policy=failure_policy, + runner_cfg=runner_cfg, ) @property @@ -824,6 +834,11 @@ def branch_claims(self) -> Mapping[str, ResourceClaim]: {branch.branch_id: branch.claim for branch in self._branches} ) + @property + def runner_cfg(self) -> ExecutionRunnerCfg: + """Return an owned copy of the coordinator transport policy.""" + return deepcopy(self._runner_cfg) + def start( self, *, @@ -914,6 +929,10 @@ def step(self) -> ParallelSkillResult: accepted and not self._pending.any() and self._status is SkillStatus.RUNNING + and ( + self._runner_cfg.hold_on_completion + or bool((self._failure | self._cancelled).any().item()) + ) ): self._terminal_hold_pending = True self._finish_if_complete() @@ -1068,8 +1087,12 @@ def _remaining_transport_wait(self) -> float: def _record_transport_action(self) -> None: """Arm the next physical grid boundary after one accepted action.""" - self._next_transport_at = self._read_clock() + self._timing_policy.step_dt - self._wait_duration = self._timing_policy.step_dt + interval = max( + self._timing_policy.step_dt, + self._runner_cfg.minimum_cycle_time, + ) + self._next_transport_at = self._read_clock() + interval + self._wait_duration = interval def _update_barrier(self) -> None: results = {branch.branch_id: branch.runtime.result for branch in self._branches} @@ -1209,7 +1232,15 @@ def _dispatch_grid_frame(self) -> None: # without producing another action, then send exactly one grid frame. self._dispatch_requested_hold() accepted = self._send_merged_frame(frame, lane_frames) - if accepted and not self._pending.any() and self._status is SkillStatus.RUNNING: + if ( + accepted + and not self._pending.any() + and self._status is SkillStatus.RUNNING + and ( + self._runner_cfg.hold_on_completion + or bool((self._failure | self._cancelled).any().item()) + ) + ): self._terminal_hold_pending = True def _dispatch_deferred_frame(self) -> bool: @@ -1247,7 +1278,10 @@ def _send_merged_frame( raise ParallelSafetyError( "ParallelCommandSafetyValidator.validate() must return None." ) - acknowledgement = self._command_sink.send(frame, timeout=1.0) + acknowledgement = self._command_sink.send( + frame, + timeout=self._runner_cfg.command_timeout, + ) if not isinstance(acknowledgement, CommandAcknowledgement): raise TypeError("CommandSink.send() returned an invalid value.") if not acknowledgement.accepted: @@ -1314,7 +1348,7 @@ def _dispatch_requested_hold( acknowledgement = self._command_sink.hold( tuple(targets.values()), context, - timeout=1.0, + timeout=self._runner_cfg.safe_stop_timeout, ) if not isinstance(acknowledgement, CommandAcknowledgement): raise TypeError("CommandSink.hold() returned an invalid value.") @@ -1366,7 +1400,10 @@ def _forward_safe_stop(self) -> tuple[bool, str | None]: snapshots = tuple(targets.values()) errors: list[str] = [] try: - cancel_ack = self._command_sink.cancel(snapshots, timeout=1.0) + cancel_ack = self._command_sink.cancel( + snapshots, + timeout=self._runner_cfg.safe_stop_timeout, + ) if not isinstance(cancel_ack, CommandAcknowledgement): raise TypeError("CommandSink.cancel() returned an invalid value.") if not cancel_ack.accepted: @@ -1380,7 +1417,7 @@ def _forward_safe_stop(self) -> tuple[bool, str | None]: hold_ack = self._command_sink.hold( snapshots, context, - timeout=1.0, + timeout=self._runner_cfg.safe_stop_timeout, ) if not isinstance(hold_ack, CommandAcknowledgement): raise TypeError("CommandSink.hold() returned an invalid value.") @@ -1464,7 +1501,12 @@ def _finish_if_complete(self) -> None: return self._merge_verified_state() if self._status is SkillStatus.RUNNING and not self._terminal_stop_forwarded: - self._dispatch_requested_hold(required=True, include_last_targets=True) + terminal_failure = bool((self._failure | self._cancelled).any().item()) + require_hold = self._runner_cfg.hold_on_completion or terminal_failure + self._dispatch_requested_hold( + required=require_hold, + include_last_targets=require_hold, + ) self._wait_duration = 0.0 if self._failure.any(): self._status = SkillStatus.FAILED diff --git a/tests/gym/envs/expert_program/test_bridge.py b/tests/gym/envs/expert_program/test_bridge.py index 9389fd5a..7859d006 100644 --- a/tests/gym/envs/expert_program/test_bridge.py +++ b/tests/gym/envs/expert_program/test_bridge.py @@ -43,6 +43,7 @@ ExecutionEvent, ExecutionEventKind, ) +from embodichain.lab.sim.atomic_actions.runner import ExecutionRunnerCfg from embodichain.lab.sim.atomic_actions.runtime_commands import ( EndpointCommand, JointPositionPayload, @@ -173,9 +174,9 @@ def snapshot(self) -> _DummyPayload: class _DummyTransportEncoder: """Test registration proving the frame encoder is transport-extensible.""" - @property - def transport_id(self) -> str: - return "test.transport" + transport_id = "test.transport" + target_types = (_DummyTarget,) + payload_types = (_DummyPayload,) def encode( self, @@ -863,6 +864,7 @@ def _bridge( post_policy_port: object | None = None, validator_port: object | None = None, parallel_safety_validator: object | None = None, + runner_cfg: ExecutionRunnerCfg | None = None, ) -> tuple[AtomicDemoBridge, _FakeRuntime, EnvironmentStepClock]: clock = EnvironmentStepClock(STEP_DT) encoder = RuntimeCommandFrameEncoder( @@ -877,11 +879,23 @@ def _bridge( clock, post_policy_port=post_policy_port, validator_port=validator_port, + runner_cfg=runner_cfg, parallel_safety_validator=parallel_safety_validator, ) return bridge, runtime, clock +def test_bridge_snapshots_runner_cfg_before_lazy_parallel_creation() -> None: + """Later advanced-path config mutation cannot change lazy bridge policy.""" + runner_cfg = ExecutionRunnerCfg(command_timeout=0.25) + bridge, _, _ = _bridge(duration=STEP_DT, runner_cfg=runner_cfg) + + runner_cfg.command_timeout = 9.0 + + assert bridge._runner_cfg is not runner_cfg + assert bridge._runner_cfg.command_timeout == pytest.approx(0.25) + + def test_environment_step_clock_advances_only_explicitly() -> None: clock = EnvironmentStepClock(STEP_DT) @@ -947,6 +961,133 @@ def test_frame_encoder_supports_registered_future_transport() -> None: assert action[1, 0].item() == 0.0 +def test_frame_encoder_composes_in_registration_not_frame_order( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Transport registration is the stable controller composition order.""" + calls: list[str] = [] + original_joint_encode = bridge_module.JointPositionGymTransportEncoder.encode + original_dummy_encode = _DummyTransportEncoder.encode + + def record_joint(self: object, *args: object, **kwargs: object) -> object: + calls.append("joint") + return original_joint_encode(self, *args, **kwargs) + + def record_dummy(self: object, *args: object, **kwargs: object) -> object: + calls.append("dummy") + return original_dummy_encode(self, *args, **kwargs) + + monkeypatch.setattr( + bridge_module.JointPositionGymTransportEncoder, + "encode", + record_joint, + ) + monkeypatch.setattr(_DummyTransportEncoder, "encode", record_dummy) + joint = _joint_frame(duration=STEP_DT).commands[0] + dummy = _dummy_frame().commands[0] + frame = RuntimeCommandFrame( + commands=(dummy, joint), + active_mask=torch.tensor([True, False]), + env_ids=torch.tensor([7, 3], dtype=torch.long), + hold_duration=torch.full((BATCH_SIZE,), STEP_DT), + ) + encoder = RuntimeCommandFrameEncoder( + _QposProvider(torch.zeros(BATCH_SIZE, ROBOT_DOF)), + transports=(_DummyTransportEncoder(),), + ) + + encoder.encode(frame) + + assert calls == ["joint", "dummy"] + + +def test_hold_encoder_composes_in_registration_not_target_order( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Safe-hold transport composition uses the same registered ordering.""" + calls: list[str] = [] + original_joint_hold = bridge_module.JointPositionGymTransportEncoder.hold + original_dummy_hold = _DummyTransportEncoder.hold + + def record_joint(self: object, *args: object, **kwargs: object) -> object: + calls.append("joint") + return original_joint_hold(self, *args, **kwargs) + + def record_dummy(self: object, *args: object, **kwargs: object) -> object: + calls.append("dummy") + return original_dummy_hold(self, *args, **kwargs) + + monkeypatch.setattr( + bridge_module.JointPositionGymTransportEncoder, + "hold", + record_joint, + ) + monkeypatch.setattr(_DummyTransportEncoder, "hold", record_dummy) + encoder = RuntimeCommandFrameEncoder( + _QposProvider(torch.zeros(BATCH_SIZE, ROBOT_DOF)), + transports=(_DummyTransportEncoder(),), + ) + dummy_target = _dummy_frame().targets[0] + joint_target = _joint_frame(duration=STEP_DT).targets[0] + + encoder.encode_hold((dummy_target, joint_target), _context()) + + assert calls == ["joint", "dummy"] + + +def test_frame_encoder_rejects_transport_without_static_type_declarations() -> None: + """Every runtime transport declares its exact pre-sim routing surface.""" + + class MissingDeclarations: + transport_id = "test.missing" + + def encode(self, *args: object, **kwargs: object) -> object: + raise AssertionError + + def hold(self, *args: object, **kwargs: object) -> object: + raise AssertionError + + encoder = RuntimeCommandFrameEncoder( + _QposProvider(torch.zeros(BATCH_SIZE, ROBOT_DOF)) + ) + + with pytest.raises(TypeError, match="RuntimeTransportActionEncoder"): + encoder.register_transport(MissingDeclarations()) # type: ignore[arg-type] + + +def test_frame_encoder_requires_exact_declared_target_coverage() -> None: + """Transport routing never widens a declaration through subclass checks.""" + + class WrongTargetCoverage(_DummyTransportEncoder): + target_types = (JointPositionTarget,) + + encoder = RuntimeCommandFrameEncoder( + _QposProvider(torch.zeros(BATCH_SIZE, ROBOT_DOF)), + transports=(WrongTargetCoverage(),), + ) + + with pytest.raises(TypeError, match="does not declare exact target type"): + encoder.encode(_dummy_frame()) + + with pytest.raises(TypeError, match="does not declare exact hold target type"): + encoder.encode_hold(_dummy_frame().targets, _context()) + + +def test_frame_encoder_requires_exact_declared_payload_coverage() -> None: + """Payload declarations are enforced independently of target coverage.""" + + class WrongPayloadCoverage(_DummyTransportEncoder): + payload_types = (JointPositionPayload,) + + encoder = RuntimeCommandFrameEncoder( + _QposProvider(torch.zeros(BATCH_SIZE, ROBOT_DOF)), + transports=(WrongPayloadCoverage(),), + ) + + with pytest.raises(TypeError, match="does not declare exact payload type"): + encoder.encode(_dummy_frame()) + + def test_buffered_sink_rejects_off_grid_frame_before_buffering() -> None: clock = EnvironmentStepClock(STEP_DT) sink = BufferedGymCommandSink( @@ -1891,6 +2032,7 @@ def from_template( *, timeout_steps: int, failure_policy: str, + runner_cfg: object, workflow_id: str, branch_paths: dict[str, tuple[object, ...]], ) -> _FakeParallelRuntime: @@ -1904,6 +2046,7 @@ def from_template( "safety_validator": supplied_safety_validator, "timeout_steps": timeout_steps, "failure_policy": failure_policy, + "runner_cfg": runner_cfg, "workflow_id": workflow_id, "branch_paths": branch_paths, } @@ -1931,6 +2074,7 @@ def from_template( assert captured["safety_validator"] is safety_validator assert captured["timeout_steps"] == 17 assert captured["failure_policy"] == "fail_fast" + assert captured["runner_cfg"] is not None assert captured["workflow_id"].endswith(":parallel_analysis") assert captured["branch_paths"] == { "branch_0": segment.source_path, diff --git a/tests/gym/envs/expert_program/test_catalog.py b/tests/gym/envs/expert_program/test_catalog.py index 1d6c07fa..380e35c6 100644 --- a/tests/gym/envs/expert_program/test_catalog.py +++ b/tests/gym/envs/expert_program/test_catalog.py @@ -18,7 +18,9 @@ from __future__ import annotations -from dataclasses import dataclass +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, replace +from threading import Event, Lock from typing import ClassVar import pytest @@ -37,6 +39,7 @@ from embodichain.lab.sim.skills import ( PLACE_ON_AFFORDANCE_CAPABILITY, BoundSemanticCall, + ControlPartEndpoint, HandOver, HandOverPoseProvider, HandOverPoseTargets, @@ -48,8 +51,21 @@ SceneManifest, SceneObjectRef, SemanticRelationTarget, + RegisteredSemanticCall, + SemanticCallDescriptor, + SkillPolicyPreset, builtin_semantic_call_catalog, ) +from embodichain.lab.sim.atomic_actions.tracking import ( + InFlightTrackingPolicy, + TimedTerminalAcceptance, + TrackingMetricCfg, + TrackingPolicy, +) +from embodichain.lab.sim.skills.effects import EffectMonitorRef +from embodichain.lab.sim.skills.parallel_runtime import ( + ParallelCommandSafetyValidator, +) from embodichain_tasks.multi_segments.cube_pick_place import ( CUBE_ROBOT_PROFILE_ID, CUBE_SCENE_REGISTRY_ID, @@ -140,6 +156,14 @@ def __init__(self) -> None: self.height = 0.5 +@dataclass(frozen=True, slots=True) +class _NestedMutableCatalogRelationGrounder(_CatalogRelationGrounder): + """Invalid frozen provider retaining one mutable nested configuration.""" + + capability: ClassVar[str] = "test.catalog_relation.mutable_nested" + offsets: list[float] + + class _PrivateSlotHandOverPoseProvider(HandOverPoseProvider): """Invalid provider whose state is hidden behind a mangled slot name.""" @@ -193,6 +217,95 @@ def resolve( raise AssertionError("Opaque providers must never reach runtime.") +class _AcceptParallelSafety: + """Stateless safety sentinel returned by the registration-owned factory.""" + + def validate(self, *, branch_frames: object, merged_frame: object) -> None: + """Accept the provider-free test command without observing simulation.""" + del branch_frames, merged_frame + + +@dataclass(frozen=True, slots=True) +class _CatalogParallelSafetyFactory: + """Frozen declaration covering the built-in transport exactly.""" + + validator_id: ClassVar[str] = "test.catalog_parallel_safety" + revision: ClassVar[str] = "1" + supported_transport_ids: ClassVar[frozenset[str]] = frozenset( + {"robot.joint_position"} + ) + margin: float = 0.02 + + def create( + self, + *, + simulation: object, + robot: object, + ) -> ParallelCommandSafetyValidator: + """Return one independent protocol-compatible safety gate.""" + del simulation, robot + return _AcceptParallelSafety() + + +class _SerializedParallelSafetyFactory: + """Instrument concurrent create calls without carrying instance state.""" + + validator_id: ClassVar[str] = "test.serialized_parallel_safety" + revision: ClassVar[str] = "1" + supported_transport_ids: ClassVar[frozenset[str]] = frozenset( + {"robot.joint_position"} + ) + _state_lock: ClassVar[Lock] = Lock() + _first_entered: ClassVar[Event] = Event() + _second_entered: ClassVar[Event] = Event() + _release_first: ClassVar[Event] = Event() + _calls: ClassVar[int] = 0 + _active: ClassVar[int] = 0 + _max_active: ClassVar[int] = 0 + + @classmethod + def reset(cls) -> None: + """Reset class-owned concurrency instrumentation for one test.""" + cls._first_entered = Event() + cls._second_entered = Event() + cls._release_first = Event() + cls._calls = 0 + cls._active = 0 + cls._max_active = 0 + + def create( + self, + *, + simulation: object, + robot: object, + ) -> ParallelCommandSafetyValidator: + """Block the first call so a second call can attempt registration entry.""" + del simulation, robot + with self._state_lock: + call_index = self._calls + type(self)._calls += 1 + type(self)._active += 1 + type(self)._max_active = max(self._max_active, self._active) + if call_index == 0: + self._first_entered.set() + if not self._release_first.wait(timeout=2.0): + raise TimeoutError("Timed out waiting to release first safety create.") + else: + self._second_entered.set() + with self._state_lock: + type(self)._active -= 1 + return _AcceptParallelSafety() + + +@dataclass(frozen=True, slots=True) +class _CatalogCustomTrackingMetric(TrackingMetricCfg): + """Metric with no built-in exact evaluator registration.""" + + metric_id: ClassVar[str] = "test.catalog_metric" + revision: ClassVar[str] = "1" + channel_id: ClassVar[str] = "joint.position" + + def _program_payload( *, scene_registry: str = CUBE_SCENE_REGISTRY_ID, @@ -299,6 +412,9 @@ def _place_relation_catalog( relation_grounder_keys=grounder_keys, articulation_operation_targets={}, settle_preset_ids=base.settle_preset_ids, + endpoint_adapter_declarations=base.endpoint_adapter_declarations, + runtime_transport_declarations=base.runtime_transport_declarations, + parallel_safety_declaration=base.parallel_safety_declaration, fingerprint="0" * 64, _required_skills={}, ) @@ -326,6 +442,50 @@ def _place_relation_payload() -> dict[str, object]: } +def _parallel_pick_payload() -> dict[str, object]: + """Return one schema-v2 parallel program rooted at an exact config path.""" + return { + "schema_version": 2, + "program_id": "catalog_parallel_pick", + "integration": { + "robot_profile": CUBE_ROBOT_PROFILE_ID, + "scene_registry": CUBE_SCENE_REGISTRY_ID, + "runtime_preset": "safe", + }, + "targets": {}, + "program": { + "kind": "parallel", + "branches": [ + { + "kind": "invoke", + "call": {"kind": "pick", "object": "cube"}, + }, + { + "kind": "invoke", + "call": {"kind": "pick", "object": "cube"}, + }, + ], + "barrier": { + "kind": "barrier", + "name": "catalog_join", + "timeout_steps": 40, + "failure_policy": "fail_fast", + }, + }, + } + + +def _registration_with_preset( + preset: SkillPolicyPreset, +) -> SimulationExpertProgramRegistration: + """Replace the Cube task's sole preset for registration validation tests.""" + binding = create_cube_robot_profile_binding() + return SimulationExpertProgramRegistration( + scene_binding=create_cube_scene_binding(grasp_samples=32), + robot_profile_binding=replace(binding, presets=(preset,)), + ) + + def test_catalog_decodes_compiles_and_links_without_simulation() -> None: """All external references are linked before a simulation is available.""" registration = _registration() @@ -339,6 +499,174 @@ def test_catalog_decodes_compiles_and_links_without_simulation() -> None: assert tuple(compiled.iter_segments())[0].calls[0].call.semantic_id == "pick" +def test_catalog_declares_builtin_endpoint_and_ordered_transport_contracts() -> None: + """The standard provider-free catalog contains its exact built-in wiring.""" + catalog = _registration().catalog + + adapter = catalog.endpoint_adapter_declarations[ControlPartEndpoint] + + assert adapter.adapter_id == "control_part" + assert adapter.runtime_transport_ids == frozenset({"robot.joint_position"}) + assert tuple( + value.transport_id for value in catalog.runtime_transport_declarations + ) == ("robot.joint_position",) + + +def test_parallel_preflight_requires_registered_safety_factory_at_exact_path() -> None: + """Parallel programs cannot defer physical-safety wiring to live startup.""" + registration = _registration() + program = decode_expert_program( + _parallel_pick_payload(), + validation_context=registration.catalog, + ) + + with pytest.raises(ExpertProgramValidationError) as error: + registration.catalog.preflight(program) + + assert error.value.code == "parallel_safety_factory_not_registered" + assert error.value.path == ("program",) + + +def test_parallel_preflight_accepts_exact_registration_owned_safety_factory() -> None: + """A factory declaration covers preflight and creates a fresh live gate.""" + registration = SimulationExpertProgramRegistration( + scene_binding=create_cube_scene_binding(grasp_samples=32), + robot_profile_binding=create_cube_robot_profile_binding(), + parallel_safety_factory=_CatalogParallelSafetyFactory(), + ) + program = decode_expert_program( + _parallel_pick_payload(), + validation_context=registration.catalog, + ) + + compiled = registration.catalog.preflight(program) + validator = registration.create_parallel_safety_validator( + simulation=object(), + robot=object(), + ) + + assert tuple(compiled.iter_segments())[0].parallel_block is not None + assert isinstance(validator, ParallelCommandSafetyValidator) + + +def test_parallel_safety_factory_must_return_a_validator() -> None: + """A malformed registration-owned factory fails before runtime dispatch.""" + + class InvalidParallelSafetyFactory: + validator_id: ClassVar[str] = "test.invalid_parallel_safety" + revision: ClassVar[str] = "1" + supported_transport_ids: ClassVar[frozenset[str]] = frozenset( + {"robot.joint_position"} + ) + + def create(self, *, simulation: object, robot: object) -> object: + del simulation, robot + return object() + + registration = SimulationExpertProgramRegistration( + scene_binding=create_cube_scene_binding(grasp_samples=32), + robot_profile_binding=create_cube_robot_profile_binding(), + parallel_safety_factory=InvalidParallelSafetyFactory(), + ) + + with pytest.raises(TypeError, match="must return a ParallelCommandSafetyValidator"): + registration.create_parallel_safety_validator( + simulation=object(), + robot=object(), + ) + + +def test_parallel_safety_creation_and_history_are_one_registration_lock_scope() -> None: + """Concurrent assemblies cannot enter one registration factory together.""" + factory_type = _SerializedParallelSafetyFactory + factory_type.reset() + registration = SimulationExpertProgramRegistration( + scene_binding=create_cube_scene_binding(grasp_samples=32), + robot_profile_binding=create_cube_robot_profile_binding(), + parallel_safety_factory=factory_type(), + ) + + def create_validator() -> ParallelCommandSafetyValidator | None: + return registration.create_parallel_safety_validator( + simulation=object(), + robot=object(), + ) + + with ThreadPoolExecutor(max_workers=2) as executor: + first = executor.submit(create_validator) + assert factory_type._first_entered.wait(timeout=1.0) + second = executor.submit(create_validator) + assert not factory_type._second_entered.wait(timeout=0.05) + factory_type._release_first.set() + assert isinstance(first.result(timeout=1.0), ParallelCommandSafetyValidator) + assert isinstance(second.result(timeout=1.0), ParallelCommandSafetyValidator) + + assert factory_type._calls == 2 + assert factory_type._max_active == 1 + + +def test_standard_registration_rejects_registered_semantic_descriptors() -> None: + """Executable lowerer extensions are outside the standard factory contract.""" + catalog = builtin_semantic_call_catalog() + target = catalog.descriptors["pick"].target_descriptor + assert target is not None and target.binding_contract is not None + custom = SemanticCallDescriptor( + call_id="test.catalog_call", + spec_type=RegisteredSemanticCall, + skill_id=target.skill_id, + binding_contract=target.binding_contract, + target_descriptor=target, + ) + + with pytest.raises(ValueError, match="Registered semantic call"): + SimulationExpertProgramRegistration( + scene_binding=create_cube_scene_binding(grasp_samples=32), + robot_profile_binding=create_cube_robot_profile_binding(), + call_catalog=catalog.with_descriptor(custom), + ) + + +def test_standard_registration_rejects_nonbuiltin_effect_monitor() -> None: + """Custom effect-monitor factories cannot be injected after registration.""" + base = create_cube_robot_profile_binding().presets[0] + preset = SkillPolicyPreset( + "safe", + action_option_templates=base.action_option_templates, + motion_policy=base.motion_policy, + tracking_policy=base.tracking_policy, + recovery_policy=base.recovery_policy, + runner_cfg=base.runner_cfg, + effect_monitors={"pick": EffectMonitorRef("test.monitor", "1")}, + ) + + with pytest.raises(ValueError, match="non-built-in effect monitor"): + _registration_with_preset(preset) + + +def test_standard_registration_rejects_tracking_metric_without_builtin_evaluator() -> ( + None +): + """Metric evaluator availability is proven before simulation startup.""" + base = create_cube_robot_profile_binding().presets[0] + preset = SkillPolicyPreset( + "safe", + action_option_templates=base.action_option_templates, + motion_policy=base.motion_policy, + tracking_policy=TrackingPolicy( + in_flight=InFlightTrackingPolicy( + metrics=(_CatalogCustomTrackingMetric(),), + ), + terminal=TimedTerminalAcceptance(), + ), + recovery_policy=base.recovery_policy, + runner_cfg=base.runner_cfg, + effect_monitors=base.effect_monitors, + ) + + with pytest.raises(ValueError, match="no exact built-in evaluator"): + _registration_with_preset(preset) + + @pytest.mark.parametrize("validation_stage", ("decode", "preflight")) def test_catalog_rejects_unknown_named_articulation_target_at_exact_path( validation_stage: str, @@ -596,6 +924,16 @@ def test_registration_rejects_stateful_non_dataclass_providers( ) +def test_registration_rejects_nested_mutable_relation_grounder_state() -> None: + """Catalog providers reuse the standard recursive immutability boundary.""" + with pytest.raises(TypeError, match="deeply immutable"): + SimulationExpertProgramRegistration( + scene_binding=create_cube_scene_binding(grasp_samples=32), + robot_profile_binding=create_cube_robot_profile_binding(), + relation_grounders=(_NestedMutableCatalogRelationGrounder(offsets=[0.1]),), + ) + + def test_nested_declaration_drift_is_detected_before_live_build() -> None: """Mutable nested config cannot silently change a registered binding.""" registration = _registration() diff --git a/tests/gym/envs/expert_program/test_extensions.py b/tests/gym/envs/expert_program/test_extensions.py new file mode 100644 index 00000000..e51f8ef8 --- /dev/null +++ b/tests/gym/envs/expert_program/test_extensions.py @@ -0,0 +1,542 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Tests for exact standard-runtime Expert Program extension declarations.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import ClassVar + +import pytest +import torch + +from embodichain.lab.gym.envs.expert_program import ( + RobotResourceBinding, + SimulationExpertProgramRegistration, + SimulationRobotSkillProfileBinding, + SimulationSceneBinding, +) +from embodichain.lab.gym.envs.expert_program.bridge import ( + JointPositionGymTransportEncoder, +) +from embodichain.lab.gym.envs.expert_program.extensions import ( + RuntimeTransportDeclaration, + build_standard_extension_declarations, + validate_immutable_extension_declaration, +) +from embodichain.lab.sim.atomic_actions import PlanningContext +from embodichain.lab.sim.atomic_actions.bindings import RuntimeEndpointTarget +from embodichain.lab.sim.atomic_actions.runtime_commands import ( + EndpointCommand, + RuntimeCommandPayload, +) +from embodichain.lab.sim.skills import ( + ControlPartEndpoint, + ControlPartEndpointAdapter, + EndpointResolution, + ResourceEndpoint, + ResourceEndpointAdapter, + RobotResource, + RobotSkillProfile, +) +from embodichain.lab.sim.types import EnvAction + + +@dataclass(frozen=True, slots=True, kw_only=True) +class _MobileEndpoint(ResourceEndpoint): + """Custom endpoint declaration used by the catalog-only tests.""" + + controller: str = "base" + + +@dataclass(frozen=True, slots=True, kw_only=True) +class _ToolEndpoint(ResourceEndpoint): + """Second exact endpoint type used to prove transport ordering.""" + + controller: str = "tool" + + +@dataclass(frozen=True, slots=True) +class _MobileTarget(RuntimeEndpointTarget): + """Immutable custom runtime destination.""" + + TRANSPORT_ID: ClassVar[str] = "test.mobile" + controller: str + + @property + def transport_id(self) -> str: + return self.TRANSPORT_ID + + @property + def target_id(self) -> str: + return self.controller + + +@dataclass(frozen=True, slots=True) +class _ToolTarget(RuntimeEndpointTarget): + """Immutable destination owned by the second transport.""" + + TRANSPORT_ID: ClassVar[str] = "test.tool" + controller: str + + @property + def transport_id(self) -> str: + return self.TRANSPORT_ID + + @property + def target_id(self) -> str: + return self.controller + + +@dataclass(frozen=True, slots=True, eq=False) +class _MobilePayload(RuntimeCommandPayload): + """Minimal typed payload declaration for the mobile transport.""" + + TRANSPORT_ID: ClassVar[str] = _MobileTarget.TRANSPORT_ID + values: torch.Tensor + + @property + def batch_size(self) -> int: + return int(self.values.shape[0]) + + @property + def device(self) -> torch.device: + return self.values.device + + @property + def transport_id(self) -> str: + return self.TRANSPORT_ID + + def snapshot(self) -> _MobilePayload: + return _MobilePayload(self.values.clone()) + + +@dataclass(frozen=True, slots=True, eq=False) +class _ToolPayload(RuntimeCommandPayload): + """Minimal typed payload declaration for the tool transport.""" + + TRANSPORT_ID: ClassVar[str] = _ToolTarget.TRANSPORT_ID + values: torch.Tensor + + @property + def batch_size(self) -> int: + return int(self.values.shape[0]) + + @property + def device(self) -> torch.device: + return self.values.device + + @property + def transport_id(self) -> str: + return self.TRANSPORT_ID + + def snapshot(self) -> _ToolPayload: + return _ToolPayload(self.values.clone()) + + +class _MobileAdapter(ResourceEndpointAdapter): + """Stateless custom adapter with only standard-factory provider routes.""" + + adapter_id: ClassVar[str] = "test.mobile" + endpoint_type: ClassVar[type[ResourceEndpoint]] = _MobileEndpoint + runtime_transport_ids: ClassVar[frozenset[str]] = frozenset( + {_MobileTarget.TRANSPORT_ID} + ) + runtime_target_types: ClassVar[tuple[type[RuntimeEndpointTarget], ...]] = ( + _MobileTarget, + ) + tracking_feedback_source_keys: ClassVar[frozenset[tuple[str, str]]] = frozenset() + tracking_projector_keys: ClassVar[frozenset[tuple[str, str]]] = frozenset() + effect_evidence_source_keys: ClassVar[frozenset[tuple[str, str]]] = frozenset() + + def resolve( + self, endpoint: ResourceEndpoint, *, engine: object + ) -> EndpointResolution: + del endpoint, engine + return EndpointResolution( + runtime_target=_MobileTarget("base"), + claim_tokens=frozenset({"test.mobile:base"}), + ) + + +class _ToolAdapter(ResourceEndpointAdapter): + """Second stateless adapter used by ordering tests.""" + + adapter_id: ClassVar[str] = "test.tool" + endpoint_type: ClassVar[type[ResourceEndpoint]] = _ToolEndpoint + runtime_transport_ids: ClassVar[frozenset[str]] = frozenset( + {_ToolTarget.TRANSPORT_ID} + ) + runtime_target_types: ClassVar[tuple[type[RuntimeEndpointTarget], ...]] = ( + _ToolTarget, + ) + tracking_feedback_source_keys: ClassVar[frozenset[tuple[str, str]]] = frozenset() + tracking_projector_keys: ClassVar[frozenset[tuple[str, str]]] = frozenset() + effect_evidence_source_keys: ClassVar[frozenset[tuple[str, str]]] = frozenset() + + def resolve( + self, endpoint: ResourceEndpoint, *, engine: object + ) -> EndpointResolution: + del endpoint, engine + return EndpointResolution( + runtime_target=_ToolTarget("tool"), + claim_tokens=frozenset({"test.tool:tool"}), + ) + + +class _MobileTransport: + """Stateless action composition transport for the mobile target.""" + + transport_id: ClassVar[str] = _MobileTarget.TRANSPORT_ID + target_types: ClassVar[tuple[type[RuntimeEndpointTarget], ...]] = (_MobileTarget,) + payload_types: ClassVar[tuple[type[RuntimeCommandPayload], ...]] = (_MobilePayload,) + + def encode( + self, + command: EndpointCommand, + *, + base_action: EnvAction, + active_mask: torch.Tensor, + ) -> EnvAction: + del command, active_mask + return base_action + + def hold( + self, + targets: tuple[RuntimeEndpointTarget, ...], + *, + base_action: EnvAction, + context: PlanningContext, + ) -> EnvAction: + del targets, context + return base_action + + +class _ToolTransport(_MobileTransport): + """Second stateless action composition transport.""" + + transport_id: ClassVar[str] = _ToolTarget.TRANSPORT_ID + target_types: ClassVar[tuple[type[RuntimeEndpointTarget], ...]] = (_ToolTarget,) + payload_types: ClassVar[tuple[type[RuntimeCommandPayload], ...]] = (_ToolPayload,) + + +class _SafetyValidator: + """Protocol-compatible no-op validator used only for factory typing.""" + + def validate(self, *, branch_frames: object, merged_frame: object) -> None: + del branch_frames, merged_frame + + +class _MobileSafetyFactory: + """Stateless exact safety-factory declaration.""" + + validator_id: ClassVar[str] = "test.mobile_safety" + revision: ClassVar[str] = "1" + supported_transport_ids: ClassVar[frozenset[str]] = frozenset( + {_MobileTarget.TRANSPORT_ID} + ) + + def create(self, *, simulation: object, robot: object) -> _SafetyValidator: + del simulation, robot + return _SafetyValidator() + + +def _custom_profile(*, include_tool: bool = False) -> RobotSkillProfile: + """Return a pure provider-free profile with exact custom endpoint types.""" + endpoints: dict[str, ResourceEndpoint] = { + "motion": _MobileEndpoint(capabilities=frozenset()) + } + if include_tool: + endpoints["tool"] = _ToolEndpoint(capabilities=frozenset()) + resource = RobotResource(resource_id="custom", endpoints=endpoints) + return RobotSkillProfile(profile_id="custom", resources={"custom": resource}) + + +def test_custom_endpoint_transport_and_safety_declarations_are_exact() -> None: + """A complete custom extension set produces an immutable provider-free catalog.""" + declarations = build_standard_extension_declarations( + profile=_custom_profile(), + endpoint_adapters=(_MobileAdapter(),), + runtime_transports=(_MobileTransport(),), + parallel_safety_factory=_MobileSafetyFactory(), + ) + + assert declarations.endpoint_adapters[_MobileEndpoint].adapter_id == "test.mobile" + assert tuple(value.transport_id for value in declarations.runtime_transports) == ( + "test.mobile", + ) + assert declarations.parallel_safety is not None + assert declarations.parallel_safety.supported_transport_ids == frozenset( + {"test.mobile"} + ) + + +def test_parallel_safety_transport_coverage_must_match_registration() -> None: + """A safety factory must cover the exact installed transport set.""" + + class MismatchedSafetyFactory(_MobileSafetyFactory): + supported_transport_ids: ClassVar[frozenset[str]] = frozenset({"test.other"}) + + with pytest.raises(ValueError, match="must support exactly"): + build_standard_extension_declarations( + profile=_custom_profile(), + endpoint_adapters=(_MobileAdapter(),), + runtime_transports=(_MobileTransport(),), + parallel_safety_factory=MismatchedSafetyFactory(), + ) + + +@pytest.mark.parametrize("declaration_kind", ("target", "payload")) +def test_runtime_transport_types_require_direct_transport_id( + declaration_kind: str, +) -> None: + """Every registered runtime value type owns its transport ID directly.""" + + class MissingTarget(RuntimeEndpointTarget): + @property + def transport_id(self) -> str: + return _MobileTarget.TRANSPORT_ID + + @property + def target_id(self) -> str: + return "missing" + + class MissingPayload(RuntimeCommandPayload): + @property + def batch_size(self) -> int: + return 1 + + @property + def device(self) -> torch.device: + return torch.device("cpu") + + @property + def transport_id(self) -> str: + return _MobileTarget.TRANSPORT_ID + + def snapshot(self) -> MissingPayload: + return MissingPayload() + + target_types = ( + (MissingTarget,) if declaration_kind == "target" else (_MobileTarget,) + ) + payload_types = ( + (MissingPayload,) if declaration_kind == "payload" else (_MobilePayload,) + ) + + with pytest.raises(TypeError, match="must declare an exact ClassVar TRANSPORT_ID"): + RuntimeTransportDeclaration( + transport_type=_MobileTransport, + transport_id=_MobileTarget.TRANSPORT_ID, + target_types=target_types, + payload_types=payload_types, + ) + + +@pytest.mark.parametrize("declaration_kind", ("target", "payload")) +def test_runtime_transport_types_cannot_inherit_transport_id( + declaration_kind: str, +) -> None: + """A subtype cannot silently inherit another runtime type's transport owner.""" + + class InheritedTarget(_MobileTarget): + pass + + class InheritedPayload(_MobilePayload): + pass + + target_types = ( + (InheritedTarget,) if declaration_kind == "target" else (_MobileTarget,) + ) + payload_types = ( + (InheritedPayload,) if declaration_kind == "payload" else (_MobilePayload,) + ) + + with pytest.raises(TypeError, match="inherited or instance-only"): + RuntimeTransportDeclaration( + transport_type=_MobileTransport, + transport_id=_MobileTarget.TRANSPORT_ID, + target_types=target_types, + payload_types=payload_types, + ) + + +@pytest.mark.parametrize("declaration_kind", ("target", "payload")) +def test_runtime_transport_type_transport_id_must_match_encoder( + declaration_kind: str, +) -> None: + """Static runtime value ownership must match the encoder transport exactly.""" + + class MismatchedTarget(_MobileTarget): + TRANSPORT_ID: ClassVar[str] = "test.mismatched" + + class MismatchedPayload(_MobilePayload): + TRANSPORT_ID: ClassVar[str] = "test.mismatched" + + target_types = ( + (MismatchedTarget,) if declaration_kind == "target" else (_MobileTarget,) + ) + payload_types = ( + (MismatchedPayload,) if declaration_kind == "payload" else (_MobilePayload,) + ) + + with pytest.raises(ValueError, match="not 'test.mobile'"): + RuntimeTransportDeclaration( + transport_type=_MobileTransport, + transport_id=_MobileTarget.TRANSPORT_ID, + target_types=target_types, + payload_types=payload_types, + ) + + +@pytest.mark.parametrize( + ("adapters", "transports", "message"), + ( + ((), (_MobileTransport(),), "missing"), + ((_MobileAdapter(),), (), "missing"), + ( + (_MobileAdapter(), _ToolAdapter()), + (_MobileTransport(), _ToolTransport()), + "unused", + ), + ), +) +def test_extension_coverage_rejects_missing_and_unused_declarations( + adapters: tuple[ResourceEndpointAdapter, ...], + transports: tuple[object, ...], + message: str, +) -> None: + """Every custom adapter and transport must be necessary and complete.""" + with pytest.raises(ValueError, match=message): + build_standard_extension_declarations( + profile=_custom_profile(), + endpoint_adapters=adapters, + runtime_transports=transports, # type: ignore[arg-type] + parallel_safety_factory=None, + ) + + +def test_builtin_adapter_and_transport_cannot_be_overridden() -> None: + """Standard built-ins retain exact ownership of their endpoint and transport.""" + profile = RobotSkillProfile( + profile_id="joint", + resources={ + "arm": RobotResource( + resource_id="arm", + endpoints={ + "motion": ControlPartEndpoint( + control_part="arm", + capabilities=frozenset(), + ) + }, + ) + }, + ) + + with pytest.raises(ValueError, match="override the built-in ControlPartEndpoint"): + build_standard_extension_declarations( + profile=profile, + endpoint_adapters=(ControlPartEndpointAdapter(),), + runtime_transports=(), + parallel_safety_factory=None, + ) + with pytest.raises(ValueError, match="override the built-in joint-position"): + build_standard_extension_declarations( + profile=profile, + endpoint_adapters=(), + runtime_transports=(JointPositionGymTransportEncoder(),), + parallel_safety_factory=None, + ) + + +def test_nonbuiltin_provider_route_is_rejected_by_standard_registration() -> None: + """Provider declarations cannot name a live registry absent from the factory.""" + + class UnsupportedProviderAdapter(_MobileAdapter): + adapter_id: ClassVar[str] = "test.unsupported_provider" + tracking_feedback_source_keys: ClassVar[frozenset[tuple[str, str]]] = frozenset( + {("test.feedback", "1")} + ) + + with pytest.raises(ValueError, match="does not install"): + build_standard_extension_declarations( + profile=_custom_profile(), + endpoint_adapters=(UnsupportedProviderAdapter(),), + runtime_transports=(_MobileTransport(),), + parallel_safety_factory=None, + ) + + +@pytest.mark.parametrize( + "mutable_leaf", + ( + [0.1], + {"gain": 0.1}, + {0.1}, + bytearray(b"gain"), + torch.tensor((0.1,)), + ), + ids=("list", "dict", "set", "bytearray", "tensor"), +) +def test_extension_declarations_reject_nested_mutable_state( + mutable_leaf: object, +) -> None: + """Frozen wrappers cannot retain mutable state used by a live extension.""" + + @dataclass(frozen=True, slots=True) + class NestedDeclaration: + config: tuple[object, ...] + + with pytest.raises(TypeError, match="deeply immutable"): + validate_immutable_extension_declaration( + NestedDeclaration((mutable_leaf,)), + field_name="runtime_transports", + ) + + +def test_runtime_transport_tuple_order_changes_registration_fingerprint() -> None: + """Transport composition order is semantic registration data.""" + profile_binding = SimulationRobotSkillProfileBinding( + profile_id="custom", + resources=( + RobotResourceBinding( + resource_id="custom", + endpoints={ + "motion": _MobileEndpoint(capabilities=frozenset()), + "tool": _ToolEndpoint(capabilities=frozenset()), + }, + ), + ), + ) + common = { + "scene_binding": SimulationSceneBinding(registry_id="custom_scene"), + "robot_profile_binding": profile_binding, + "endpoint_adapters": (_MobileAdapter(), _ToolAdapter()), + } + forward = SimulationExpertProgramRegistration( + **common, + runtime_transports=(_MobileTransport(), _ToolTransport()), + ) + reversed_registration = SimulationExpertProgramRegistration( + **common, + runtime_transports=(_ToolTransport(), _MobileTransport()), + ) + + assert forward.fingerprint != reversed_registration.fingerprint + + +__all__: list[str] = [] diff --git a/tests/gym/envs/expert_program/test_simulation_environment.py b/tests/gym/envs/expert_program/test_simulation_environment.py index bc9b5568..5dfcb35b 100644 --- a/tests/gym/envs/expert_program/test_simulation_environment.py +++ b/tests/gym/envs/expert_program/test_simulation_environment.py @@ -20,11 +20,11 @@ import ast from collections.abc import Mapping, Sequence -from dataclasses import dataclass, fields, is_dataclass +from dataclasses import dataclass, fields, is_dataclass, replace import inspect import json import textwrap -from types import MethodType, SimpleNamespace +from types import MappingProxyType, MethodType, SimpleNamespace from typing import Any, ClassVar from unittest.mock import MagicMock @@ -46,6 +46,7 @@ ExpertProgramRuntimeAssembly, HandOverCfg, InvokeCfg, + IntegrationFingerprintMismatch, RobotResourceBinding, SharedTickSceneProvider, SimulationExpertProgramRegistration, @@ -79,6 +80,13 @@ TrackingPolicy, ) from embodichain.lab.sim.atomic_actions.runner import ExecutionRunnerCfg +from embodichain.lab.sim.atomic_actions.tracking import ( + JOINT_POSITION_CHANNEL, + EndpointTrackingChannelBinding, + EndpointTrackingFeedbackAddress, + TrackingFeedbackSourceRef, + TrackingProjectorRef, +) from embodichain.lab.sim.atomic_actions.bindings import JointPositionTarget from embodichain.lab.sim.atomic_actions.bindings import RuntimeEndpointTarget from embodichain.lab.sim.atomic_actions.control import ControlPartCommandProfile @@ -86,6 +94,7 @@ EndpointCommand, JointPositionPayload, RuntimeCommandFrame, + RuntimeCommandPayload, ) from embodichain.lab.sim.planners import MotionGenerator from embodichain.lab.sim.skills import ( @@ -119,6 +128,7 @@ EffectEvidenceSourceRef, HeldObjectRelation, HeldObjectStateExpectation, + JOINT_STATE_EFFECT_CHANNEL, ) from embodichain.lab.sim.skills.evidence import ( BinaryEffectEvidenceQuery, @@ -719,12 +729,13 @@ class _MobileEndpoint(ResourceEndpoint): class _MobileTarget(RuntimeEndpointTarget): """Runtime destination for the test mobile controller.""" + TRANSPORT_ID: ClassVar[str] = "test.mobile_velocity" controller_id: str @property def transport_id(self) -> str: """Return the matching test Gym transport ID.""" - return "test.mobile_velocity" + return self.TRANSPORT_ID @property def target_id(self) -> str: @@ -732,11 +743,52 @@ def target_id(self) -> str: return self.controller_id +@dataclass(frozen=True, slots=True) +class _UndeclaredMobileTarget(RuntimeEndpointTarget): + """Live target intentionally absent from the adapter declaration.""" + + TRANSPORT_ID: ClassVar[str] = _MobileTarget.TRANSPORT_ID + controller_id: str + + @property + def transport_id(self) -> str: + return self.TRANSPORT_ID + + @property + def target_id(self) -> str: + return self.controller_id + + +@dataclass(frozen=True, slots=True) +class _LyingTransportMobileTarget(RuntimeEndpointTarget): + """Declare one transport statically but expose another on the live value.""" + + TRANSPORT_ID: ClassVar[str] = _MobileTarget.TRANSPORT_ID + controller_id: str + + @property + def transport_id(self) -> str: + return "test.unregistered_live_transport" + + @property + def target_id(self) -> str: + return self.controller_id + + class _MobileEndpointAdapter(ResourceEndpointAdapter): """Resolve a mobile endpoint without consulting robot control parts.""" adapter_id: ClassVar[str] = "test.mobile_velocity" endpoint_type: ClassVar[type[ResourceEndpoint]] = _MobileEndpoint + runtime_transport_ids: ClassVar[frozenset[str]] = frozenset( + {_MobileTarget.TRANSPORT_ID} + ) + runtime_target_types: ClassVar[tuple[type[RuntimeEndpointTarget], ...]] = ( + _MobileTarget, + ) + tracking_feedback_source_keys: ClassVar[frozenset[tuple[str, str]]] = frozenset() + tracking_projector_keys: ClassVar[frozenset[tuple[str, str]]] = frozenset() + effect_evidence_source_keys: ClassVar[frozenset[tuple[str, str]]] = frozenset() def resolve( self, @@ -754,13 +806,162 @@ def resolve( ) -class _MobileTransportEncoder: - """Minimal Gym encoder registered for the custom mobile target.""" +class _LyingMobileEndpointAdapter(_MobileEndpointAdapter): + """Declare one target type but resolve a different live target type.""" + + adapter_id: ClassVar[str] = "test.lying_mobile_velocity" + + def resolve( + self, + endpoint: ResourceEndpoint, + *, + engine: Any, + ) -> EndpointResolution: + del engine + if not isinstance(endpoint, _MobileEndpoint): + raise TypeError("_LyingMobileEndpointAdapter requires _MobileEndpoint.") + return EndpointResolution( + runtime_target=_UndeclaredMobileTarget(endpoint.controller_id), + claim_tokens=frozenset({f"controller:{endpoint.controller_id}"}), + ) + + +class _LyingTransportMobileEndpointAdapter(_MobileEndpointAdapter): + """Resolve a target whose live transport contradicts its static owner.""" + + adapter_id: ClassVar[str] = "test.lying_mobile_transport" + runtime_target_types: ClassVar[tuple[type[RuntimeEndpointTarget], ...]] = ( + _LyingTransportMobileTarget, + ) + + def resolve( + self, + endpoint: ResourceEndpoint, + *, + engine: Any, + ) -> EndpointResolution: + del engine + if not isinstance(endpoint, _MobileEndpoint): + raise TypeError("_LyingTransportMobileEndpointAdapter requires mobile.") + return EndpointResolution( + runtime_target=_LyingTransportMobileTarget(endpoint.controller_id), + claim_tokens=frozenset({f"controller:{endpoint.controller_id}"}), + ) + + +class _LyingAdapterIdMobileEndpointAdapter(_MobileEndpointAdapter): + """Expose a different live adapter ID than the class declaration.""" + + adapter_id: ClassVar[str] = "test.declared_mobile_adapter" + + def __getattribute__(self, name: str) -> Any: + if name == "adapter_id": + return "test.live_mobile_adapter" + return super().__getattribute__(name) + + +class _LyingFeedbackMobileEndpointAdapter(_MobileEndpointAdapter): + """Emit fingerprinted tracking routes absent from the declaration.""" + + adapter_id: ClassVar[str] = "test.lying_mobile_feedback" + + def resolve( + self, + endpoint: ResourceEndpoint, + *, + engine: Any, + ) -> EndpointResolution: + del engine + if not isinstance(endpoint, _MobileEndpoint): + raise TypeError("_LyingFeedbackMobileEndpointAdapter requires mobile.") + target = _MobileTarget(endpoint.controller_id) + tracking = EndpointTrackingChannelBinding( + JOINT_POSITION_CHANNEL, + TrackingFeedbackSourceRef( + "planning_context.robot", + "1", + EndpointTrackingFeedbackAddress(target, JOINT_POSITION_CHANNEL), + ), + TrackingProjectorRef("joint_position_payload", "1"), + ) + return EndpointResolution( + runtime_target=target, + tracking_channels={JOINT_POSITION_CHANNEL: tracking}, + claim_tokens=frozenset({f"controller:{endpoint.controller_id}"}), + ) + + +class _LyingProjectorMobileEndpointAdapter(_LyingFeedbackMobileEndpointAdapter): + """Declare only the live feedback route while hiding its projector route.""" + + adapter_id: ClassVar[str] = "test.lying_mobile_projector" + tracking_feedback_source_keys: ClassVar[frozenset[tuple[str, str]]] = frozenset( + {("planning_context.robot", "1")} + ) + + +class _LyingEvidenceMobileEndpointAdapter(_MobileEndpointAdapter): + """Emit effect evidence absent from the adapter declaration.""" + + adapter_id: ClassVar[str] = "test.lying_mobile_evidence" + + def resolve( + self, + endpoint: ResourceEndpoint, + *, + engine: Any, + ) -> EndpointResolution: + del engine + if not isinstance(endpoint, _MobileEndpoint): + raise TypeError("_LyingEvidenceMobileEndpointAdapter requires mobile.") + return EndpointResolution( + runtime_target=_MobileTarget(endpoint.controller_id), + effect_sources={ + JOINT_STATE_EFFECT_CHANNEL: EffectEvidenceSourceRef( + CONTROL_PART_EVIDENCE_PROVIDER_ID, + CONTROL_PART_EVIDENCE_PROVIDER_REVISION, + ControlPartEvidenceAddress( + endpoint.controller_id, + JOINT_STATE_EFFECT_CHANNEL, + ), + ) + }, + claim_tokens=frozenset({f"controller:{endpoint.controller_id}"}), + ) + + +@dataclass(frozen=True, slots=True, eq=False) +class _MobilePayload(RuntimeCommandPayload): + """Minimal payload declaration for the custom transport contract.""" + + TRANSPORT_ID: ClassVar[str] = _MobileTarget.TRANSPORT_ID + values: torch.Tensor + + def __post_init__(self) -> None: + object.__setattr__(self, "values", self.values.clone()) + + @property + def batch_size(self) -> int: + return int(self.values.shape[0]) + + @property + def device(self) -> torch.device: + return self.values.device @property def transport_id(self) -> str: - """Return the custom mobile transport ID.""" - return "test.mobile_velocity" + return self.TRANSPORT_ID + + def snapshot(self) -> _MobilePayload: + return _MobilePayload(self.values) + + +class _MobileTransportEncoder: + """Minimal Gym encoder registered for the custom mobile target.""" + + transport_id: ClassVar[str] = _MobileTarget.TRANSPORT_ID + target_types: ClassVar[tuple[type[RuntimeEndpointTarget], ...]] = (_MobileTarget,) + payload_types: ClassVar[tuple[type[RuntimeCommandPayload], ...]] = (_MobilePayload,) def encode( self, @@ -769,9 +970,12 @@ def encode( base_action: Any, active_mask: torch.Tensor, ) -> Any: - """Preserve the base action in this assembly-only test transport.""" - del command, active_mask - return base_action.clone() + """Write the custom payload into one test controller channel.""" + if type(command.payload) is not _MobilePayload: + raise TypeError("_MobileTransportEncoder requires _MobilePayload.") + action = base_action.clone() + action[active_mask, 0] = command.payload.values[active_mask] + return action def hold( self, @@ -785,6 +989,14 @@ def hold( return base_action.clone() +class _LyingTargetMobileTransportEncoder(_MobileTransportEncoder): + """Declare the statically owned type whose live transport property lies.""" + + target_types: ClassVar[tuple[type[RuntimeEndpointTarget], ...]] = ( + _LyingTransportMobileTarget, + ) + + class _MobileRobot: """Full-state robot fixture with no control-parts or joint-ID surface.""" @@ -828,6 +1040,60 @@ def get_rigid_object(self, uid: str) -> _RigidObject | None: return self.rigid_objects.get(uid) +class _RegisteredParallelSafety: + """Fresh test gate produced only by its registration-owned factory.""" + + def validate( + self, + *, + branch_frames: Mapping[str, RuntimeCommandFrame], + merged_frame: RuntimeCommandFrame, + ) -> None: + del branch_frames, merged_frame + + +class _RegisteredParallelSafetyFactory: + """Stateless declarative factory for a live joint-transport gate.""" + + validator_id: ClassVar[str] = "test.registered_parallel_safety" + revision: ClassVar[str] = "1" + supported_transport_ids: ClassVar[frozenset[str]] = frozenset( + {JointPositionTarget.TRANSPORT_ID} + ) + + def create(self, *, simulation: object, robot: object) -> _RegisteredParallelSafety: + assert getattr(simulation, "get_robot")(getattr(robot, "uid")) is robot + return _RegisteredParallelSafety() + + +class _ReusedParallelSafetyFactory(_RegisteredParallelSafetyFactory): + """Invalid stateless factory that reuses one live validator singleton.""" + + validator_id: ClassVar[str] = "test.reused_parallel_safety" + _validator: ClassVar[_RegisteredParallelSafety] = _RegisteredParallelSafety() + + def create(self, *, simulation: object, robot: object) -> _RegisteredParallelSafety: + del simulation, robot + return self._validator + + +class _AlternatingReusedParallelSafetyFactory(_RegisteredParallelSafetyFactory): + """Invalid factory that hides A/B/A reuse behind alternating instances.""" + + validator_id: ClassVar[str] = "test.alternating_parallel_safety" + _validators: ClassVar[tuple[_RegisteredParallelSafety, ...]] = ( + _RegisteredParallelSafety(), + _RegisteredParallelSafety(), + ) + _next_index: ClassVar[int] = 0 + + def create(self, *, simulation: object, robot: object) -> _RegisteredParallelSafety: + del simulation, robot + validator = self._validators[self._next_index % len(self._validators)] + type(self)._next_index += 1 + return validator + + def _profile_binding() -> SimulationRobotSkillProfileBinding: """Build one motion-only profile with an intentionally wrong cadence.""" return SimulationRobotSkillProfileBinding( @@ -868,6 +1134,26 @@ def _profile_binding() -> SimulationRobotSkillProfileBinding: ) +def _mobile_profile_binding() -> SimulationRobotSkillProfileBinding: + """Build one pure custom-endpoint profile without a joint transport.""" + return SimulationRobotSkillProfileBinding( + profile_id="mobile_profile", + resources=( + RobotResourceBinding( + resource_id="mobile_base", + endpoints={ + "motion": _MobileEndpoint( + controller_id="base_velocity", + capabilities=frozenset({"motion.base.velocity"}), + ) + }, + ), + ), + presets=(SkillPolicyPreset("runtime", action_option_templates={}),), + default_preset="runtime", + ) + + def _handover_profile_binding() -> SimulationRobotSkillProfileBinding: """Declare two disjoint manipulators and one selected pose provider ID.""" motion_capabilities = frozenset( @@ -1007,6 +1293,31 @@ def _factory() -> tuple[SimulationExpertProgramFactory, _Robot]: ) +def _mobile_factory() -> tuple[ + SimulationExpertProgramFactory, + SimulationExpertProgramRegistration, +]: + """Create one pure-custom standard factory and its exact registration.""" + robot = _MobileRobot() + simulation = _Simulation(robot) # type: ignore[arg-type] + registration = SimulationExpertProgramRegistration( + scene_binding=SimulationSceneBinding(registry_id="mobile_scene"), + robot_profile_binding=_mobile_profile_binding(), + endpoint_adapters=(_MobileEndpointAdapter(),), + runtime_transports=(_MobileTransportEncoder(),), + ) + return ( + SimulationExpertProgramFactory( + simulation, # type: ignore[arg-type] + robot, # type: ignore[arg-type] + registration, + step_dt=_STEP_DT, + motion_generator_factory=lambda: _motion_generator(robot), # type: ignore[arg-type] + ), + registration, + ) + + def _evidence_profile_binding() -> SimulationRobotSkillProfileBinding: """Declare one manipulation resource with exact open/grasp semantics.""" motion_capabilities = frozenset( @@ -1154,12 +1465,7 @@ def _evidence_adapter_runtime() -> tuple[ step_dt=_STEP_DT, motion_generator_factory=lambda: _motion_generator(robot), ) - adapter = factory.create_adapter( - runner_cfg=ExecutionRunnerCfg( - minimum_cycle_time=0.0, - hold_on_completion=False, - ) - ) + adapter = factory.create_adapter() assembly = adapter.assemble_runtime(_evidence_integration()) pick_action = assembly.engine.actions["pick_up"] place_action = assembly.engine.actions["place"] @@ -1728,6 +2034,244 @@ def test_simulation_factory_returns_exact_environment_adapter() -> None: assert factory.segment_policy_port is not None +@pytest.mark.parametrize( + "override", + ( + {"call_catalog": object()}, + {"endpoint_adapters": {}}, + {"registered_lowerers": (object(),)}, + {"relation_grounders": (object(),)}, + {"handover_pose_providers": (object(),)}, + {"effect_monitor_registry": object()}, + {"runtime_transports": (object(),)}, + {"runner_cfg": ExecutionRunnerCfg()}, + {"post_policy_port": object()}, + {"validator_port": object()}, + {"parallel_safety_validator": object()}, + ), +) +def test_standard_registration_rejects_runtime_side_channel_overrides( + override: dict[str, object], +) -> None: + """The exact registration is the standard path's only extension owner.""" + factory, _ = _factory() + + with pytest.raises(ValueError, match="external overrides are forbidden"): + ExpertProgramEnvironmentAdapter( + factory, + step_dt=_STEP_DT, + registration=factory.expert_program_registration, + **override, + ) + + +def test_registration_owning_factory_rejects_catalog_only_adapter() -> None: + """A standard factory cannot be rewrapped through the advanced catalog seam.""" + factory, _ = _factory() + + with pytest.raises(ValueError, match="catalog-only"): + ExpertProgramEnvironmentAdapter( + factory, + step_dt=_STEP_DT, + integration_catalog=factory.expert_program_registration.catalog, + ) + + +def test_standard_registration_rejects_integration_catalog_override() -> None: + """Even the owner's catalog cannot be resupplied beside exact registration.""" + factory, _ = _factory() + registration = factory.expert_program_registration + + with pytest.raises(ValueError, match="cannot override"): + ExpertProgramEnvironmentAdapter( + factory, + step_dt=_STEP_DT, + registration=registration, + integration_catalog=registration.catalog, + ) + + +def test_registration_owning_factory_rejects_equivalent_registration_object() -> None: + """Equal IDs and fingerprint cannot substitute for the factory-owned object.""" + factory, _ = _factory() + owned = factory.expert_program_registration + equivalent = SimulationExpertProgramRegistration( + scene_binding=SimulationSceneBinding(registry_id="scene"), + robot_profile_binding=_profile_binding(), + ) + assert equivalent is not owned + assert equivalent.fingerprint == owned.fingerprint + + with pytest.raises(ValueError, match="exact object owned by the factory"): + ExpertProgramEnvironmentAdapter( + factory, + step_dt=_STEP_DT, + registration=equivalent, + ) + + +def test_adapter_rejects_factory_registration_ownership_drift() -> None: + """A factory cannot replace its registration after adapter construction.""" + factory, _ = _factory() + adapter = factory.create_adapter() + equivalent = SimulationExpertProgramRegistration( + scene_binding=SimulationSceneBinding(registry_id="scene"), + robot_profile_binding=_profile_binding(), + ) + factory._registration = equivalent + + with pytest.raises(IntegrationFingerprintMismatch, match="ownership changed"): + adapter.assemble_runtime( + ExpertProgramIntegrationCfg( + robot_profile="robot_profile", + scene_registry="scene", + runtime_preset="safe", + ) + ) + + +def test_adapter_rejects_engine_bound_to_equivalent_profile_object() -> None: + """The engine must bind the exact profile object validated by the adapter.""" + factory, _ = _factory() + original_create_engine = factory.create_atomic_action_engine + + def create_with_different_profile( + owner: SimulationExpertProgramFactory, + profile: Any, + ) -> Any: + replacement = owner.create_robot_skill_profile() + assert replacement is not profile + return original_create_engine(replacement) + + factory.create_atomic_action_engine = MethodType( + create_with_different_profile, + factory, + ) + + with pytest.raises( + IntegrationFingerprintMismatch, + match="different robot profile object", + ): + factory.create_adapter().assemble_runtime( + ExpertProgramIntegrationCfg( + robot_profile="robot_profile", + scene_registry="scene", + runtime_preset="safe", + ) + ) + + +def test_standard_factory_uses_preset_runner_and_fresh_registered_safety() -> None: + """Live assembly consumes preset policy and creates no shared safety gate.""" + robot = _Robot() + simulation = _Simulation(robot) + registration = SimulationExpertProgramRegistration( + scene_binding=SimulationSceneBinding(registry_id="scene"), + robot_profile_binding=_profile_binding(), + parallel_safety_factory=_RegisteredParallelSafetyFactory(), + ) + factory = SimulationExpertProgramFactory( + simulation, # type: ignore[arg-type] + robot, # type: ignore[arg-type] + registration, + step_dt=_STEP_DT, + motion_generator_factory=lambda: _motion_generator(robot), + ) + adapter = factory.create_adapter() + integration = ExpertProgramIntegrationCfg( + robot_profile="robot_profile", + scene_registry="scene", + runtime_preset="safe", + ) + + first = adapter.assemble_runtime(integration) + second = adapter.assemble_runtime(integration) + + assert first.runner_cfg.command_timeout == pytest.approx(0.37) + assert first.runner_cfg.safe_stop_timeout == pytest.approx(0.61) + assert first.runner_cfg.minimum_cycle_time == pytest.approx(0.04) + assert first.runner_cfg.hold_on_completion is False + assert type(first.parallel_safety_validator) is _RegisteredParallelSafety + assert type(second.parallel_safety_validator) is _RegisteredParallelSafety + assert first.parallel_safety_validator is not second.parallel_safety_validator + + +def test_standard_factory_rejects_reused_live_safety_validator() -> None: + """A declarative factory cannot leak one validator across runtime assemblies.""" + robot = _Robot() + simulation = _Simulation(robot) + registration = SimulationExpertProgramRegistration( + scene_binding=SimulationSceneBinding(registry_id="scene"), + robot_profile_binding=_profile_binding(), + parallel_safety_factory=_ReusedParallelSafetyFactory(), + ) + factory = SimulationExpertProgramFactory( + simulation, # type: ignore[arg-type] + robot, # type: ignore[arg-type] + registration, + step_dt=_STEP_DT, + motion_generator_factory=lambda: _motion_generator(robot), + ) + adapter = factory.create_adapter() + integration = ExpertProgramIntegrationCfg( + robot_profile="robot_profile", + scene_registry="scene", + runtime_preset="safe", + ) + adapter.assemble_runtime(integration) + + with pytest.raises(ValueError, match="fresh validator"): + adapter.assemble_runtime(integration) + + +def test_registration_rejects_a_b_a_safety_reuse_across_factories() -> None: + """Freshness history belongs to the registration rather than one factory.""" + robot = _Robot() + simulation = _Simulation(robot) + _AlternatingReusedParallelSafetyFactory._next_index = 0 + registration = SimulationExpertProgramRegistration( + scene_binding=SimulationSceneBinding(registry_id="scene"), + robot_profile_binding=_profile_binding(), + parallel_safety_factory=_AlternatingReusedParallelSafetyFactory(), + ) + factories = tuple( + SimulationExpertProgramFactory( + simulation, # type: ignore[arg-type] + robot, # type: ignore[arg-type] + registration, + step_dt=_STEP_DT, + motion_generator_factory=lambda: _motion_generator(robot), + ) + for _ in range(3) + ) + integration = ExpertProgramIntegrationCfg( + robot_profile="robot_profile", + scene_registry="scene", + runtime_preset="safe", + ) + + factories[0].create_adapter().assemble_runtime(integration) + factories[1].create_adapter().assemble_runtime(integration) + + with pytest.raises(ValueError, match="fresh validator"): + factories[2].create_adapter().assemble_runtime(integration) + + +def test_standard_simulation_helper_has_no_live_extension_override_parameters() -> None: + """Task code can select only its immutable registration on the standard path.""" + parameters = inspect.signature(create_simulation_expert_program_adapter).parameters + + assert { + "endpoint_adapters", + "runtime_transports", + "contact_observer", + "constraint_observer", + "force_observer", + "wrench_observer", + "parallel_safety_validator", + }.isdisjoint(parameters) + + def test_simulation_helper_consumes_registered_semantic_grounding_extensions() -> None: """Both registration-owned grounding seams reach the compiler unchanged.""" robot = _Robot() @@ -1800,21 +2344,11 @@ def test_simulation_helper_assembles_mobile_endpoint_and_transport_without_joint """The one-line factory path supports a custom non-joint controller.""" robot = _MobileRobot() simulation = _Simulation(robot) # type: ignore[arg-type] - profile_binding = SimulationRobotSkillProfileBinding( - profile_id="mobile_profile", - resources=( - RobotResourceBinding( - resource_id="mobile_base", - endpoints={ - "motion": _MobileEndpoint( - controller_id="base_velocity", - capabilities=frozenset({"motion.base.velocity"}), - ) - }, - ), - ), - presets=(SkillPolicyPreset("runtime", action_option_templates={}),), - default_preset="runtime", + registration = SimulationExpertProgramRegistration( + scene_binding=SimulationSceneBinding(registry_id="mobile_scene"), + robot_profile_binding=_mobile_profile_binding(), + endpoint_adapters=(_MobileEndpointAdapter(),), + runtime_transports=(_MobileTransportEncoder(),), ) environment = SimpleNamespace( sim=simulation, @@ -1824,13 +2358,8 @@ def test_simulation_helper_assembles_mobile_endpoint_and_transport_without_joint adapter = create_simulation_expert_program_adapter( environment, # type: ignore[arg-type] - registration=SimulationExpertProgramRegistration( - scene_binding=SimulationSceneBinding(registry_id="mobile_scene"), - robot_profile_binding=profile_binding, - ), + registration=registration, motion_generator_factory=lambda: _motion_generator(robot), # type: ignore[arg-type] - endpoint_adapters={_MobileEndpoint: _MobileEndpointAdapter()}, - runtime_transports=(_MobileTransportEncoder(),), ) assembly = adapter.assemble_runtime( ExpertProgramIntegrationCfg( @@ -1842,11 +2371,250 @@ def test_simulation_helper_assembles_mobile_endpoint_and_transport_without_joint endpoint = assembly.robot_profile.resources["mobile_base"].endpoints["motion"] assert isinstance(endpoint, _MobileEndpoint) - assert "test.mobile_velocity" in assembly.command_encoder.transport_ids + assert assembly.command_encoder.transport_ids == (_MobileTarget.TRANSPORT_ID,) + assert assembly.command_encoder.is_frozen + with pytest.raises(RuntimeError, match="registration is frozen"): + assembly.command_encoder.register_transport( + _MobileTransportEncoder(), + replace=True, + ) assert assembly.engine.skill_profile is not None resolved = assembly.engine.skill_profile.resources["mobile_base"] assert isinstance(resolved.endpoints["motion"].runtime_target, _MobileTarget) assert resolved.claim.claim_tokens == frozenset({"controller:base_velocity"}) + context = assembly.observation_provider.observe( + TaskState.empty(_BATCH_SIZE, robot.device) + ) + values = torch.linspace(0.1, 0.2, _BATCH_SIZE) + action = assembly.command_encoder.encode( + RuntimeCommandFrame( + commands=( + EndpointCommand( + _MobileTarget("base_velocity"), + _MobilePayload(values), + ), + ), + active_mask=torch.ones(_BATCH_SIZE, dtype=torch.bool), + env_ids=context.env_ids, + hold_duration=torch.full((_BATCH_SIZE,), _STEP_DT), + ) + ) + torch.testing.assert_close(action[:, 0], values) + + +@pytest.mark.parametrize( + "drift", + ("missing_resource", "extra_resource", "missing_endpoint", "extra_endpoint"), +) +def test_catalog_rejects_live_resource_and_endpoint_coverage_drift( + drift: str, +) -> None: + """Live bound topology must cover the registered profile exactly.""" + factory, registration = _mobile_factory() + assembly = factory.create_adapter().assemble_runtime( + ExpertProgramIntegrationCfg( + robot_profile="mobile_profile", + scene_registry="mobile_scene", + runtime_preset="runtime", + ) + ) + bound_profile = assembly.engine.skill_profile + assert bound_profile is not None + resources = dict(bound_profile.resources) + resource = resources["mobile_base"] + if drift == "missing_resource": + resources.pop("mobile_base") + elif drift == "extra_resource": + resources["extra"] = replace( + resource, + resource_id="extra", + claim=replace( + resource.claim, + leaf_resource_ids=frozenset({"extra"}), + ), + ) + elif drift == "missing_endpoint": + resources["mobile_base"] = replace( + resource, + endpoints={}, + claim=replace( + resource.claim, + joint_ids=(), + claim_tokens=frozenset(), + ), + ) + else: + endpoints = dict(resource.endpoints) + endpoints["extra"] = endpoints["motion"] + resources["mobile_base"] = replace(resource, endpoints=endpoints) + bound_profile._resources = MappingProxyType(resources) + + with pytest.raises(IntegrationFingerprintMismatch, match="IDs differ"): + registration.catalog.validate_bound_endpoint_extensions(bound_profile) + + +@pytest.mark.parametrize( + ("route", "message"), + (("tracking", "tracking address"), ("evidence", "effect-evidence address")), +) +def test_catalog_rejects_control_part_live_route_address_drift( + route: str, + message: str, +) -> None: + """Built-in route IDs cannot hide a different target or evidence address.""" + factory, _ = _factory() + registration = factory.expert_program_registration + assembly = factory.create_adapter().assemble_runtime( + ExpertProgramIntegrationCfg( + robot_profile="robot_profile", + scene_registry="scene", + runtime_preset="safe", + ) + ) + bound_profile = assembly.engine.skill_profile + assert bound_profile is not None + resources = dict(bound_profile.resources) + resource = resources["manipulator"] + endpoints = dict(resource.endpoints) + endpoint = endpoints["motion"] + if route == "tracking": + tracking = endpoint.tracking_channels[JOINT_POSITION_CHANNEL] + wrong_target = JointPositionTarget( + "different_arm", + endpoint.runtime_target.joint_ids, + ) + wrong_tracking = EndpointTrackingChannelBinding( + JOINT_POSITION_CHANNEL, + TrackingFeedbackSourceRef( + tracking.source.provider_id, + tracking.source.revision, + EndpointTrackingFeedbackAddress( + wrong_target, + JOINT_POSITION_CHANNEL, + ), + ), + tracking.projector, + ) + endpoints["motion"] = replace( + endpoint, + tracking_channels={JOINT_POSITION_CHANNEL: wrong_tracking}, + ) + else: + effect_sources = dict(endpoint.effect_sources) + channel = next(iter(effect_sources)) + source = effect_sources[channel] + effect_sources[channel] = EffectEvidenceSourceRef( + source.provider_id, + source.revision, + ControlPartEvidenceAddress("different_arm", channel), + ) + endpoints["motion"] = replace( + endpoint, + effect_sources=effect_sources, + ) + resources["manipulator"] = replace(resource, endpoints=endpoints) + bound_profile._resources = MappingProxyType(resources) + + with pytest.raises(IntegrationFingerprintMismatch, match=message): + registration.catalog.validate_bound_endpoint_extensions(bound_profile) + + +def test_standard_factory_rejects_adapter_live_target_declaration_drift() -> None: + """A lying adapter cannot emit a target absent from its catalog declaration.""" + robot = _MobileRobot() + simulation = _Simulation(robot) # type: ignore[arg-type] + registration = SimulationExpertProgramRegistration( + scene_binding=SimulationSceneBinding(registry_id="mobile_scene"), + robot_profile_binding=_mobile_profile_binding(), + endpoint_adapters=(_LyingMobileEndpointAdapter(),), + runtime_transports=(_MobileTransportEncoder(),), + ) + factory = SimulationExpertProgramFactory( + simulation, # type: ignore[arg-type] + robot, # type: ignore[arg-type] + registration, + step_dt=_STEP_DT, + motion_generator_factory=lambda: _motion_generator(robot), # type: ignore[arg-type] + ) + + with pytest.raises( + IntegrationFingerprintMismatch, + match="undeclared exact runtime target type", + ): + factory.create_adapter().assemble_runtime( + ExpertProgramIntegrationCfg( + robot_profile="mobile_profile", + scene_registry="mobile_scene", + runtime_preset="runtime", + ) + ) + + +def test_standard_factory_rejects_target_live_transport_declaration_drift() -> None: + """A target instance cannot contradict its statically registered transport.""" + robot = _MobileRobot() + simulation = _Simulation(robot) # type: ignore[arg-type] + registration = SimulationExpertProgramRegistration( + scene_binding=SimulationSceneBinding(registry_id="mobile_scene"), + robot_profile_binding=_mobile_profile_binding(), + endpoint_adapters=(_LyingTransportMobileEndpointAdapter(),), + runtime_transports=(_LyingTargetMobileTransportEncoder(),), + ) + factory = SimulationExpertProgramFactory( + simulation, # type: ignore[arg-type] + robot, # type: ignore[arg-type] + registration, + step_dt=_STEP_DT, + motion_generator_factory=lambda: _motion_generator(robot), # type: ignore[arg-type] + ) + + with pytest.raises(IntegrationFingerprintMismatch, match="live transport"): + factory.create_adapter().assemble_runtime( + ExpertProgramIntegrationCfg( + robot_profile="mobile_profile", + scene_registry="mobile_scene", + runtime_preset="runtime", + ) + ) + + +@pytest.mark.parametrize( + ("endpoint_adapter", "message"), + ( + (_LyingAdapterIdMobileEndpointAdapter(), "adapter ID"), + (_LyingFeedbackMobileEndpointAdapter(), "tracking-feedback routes"), + (_LyingEvidenceMobileEndpointAdapter(), "effect-evidence routes"), + ), +) +def test_standard_factory_rejects_adapter_live_route_declaration_drift( + endpoint_adapter: ResourceEndpointAdapter, + message: str, +) -> None: + """Every live adapter identity and provider route must match its fingerprint.""" + robot = _MobileRobot() + simulation = _Simulation(robot) # type: ignore[arg-type] + registration = SimulationExpertProgramRegistration( + scene_binding=SimulationSceneBinding(registry_id="mobile_scene"), + robot_profile_binding=_mobile_profile_binding(), + endpoint_adapters=(endpoint_adapter,), + runtime_transports=(_MobileTransportEncoder(),), + ) + factory = SimulationExpertProgramFactory( + simulation, # type: ignore[arg-type] + robot, # type: ignore[arg-type] + registration, + step_dt=_STEP_DT, + motion_generator_factory=lambda: _motion_generator(robot), # type: ignore[arg-type] + ) + + with pytest.raises(IntegrationFingerprintMismatch, match=message): + factory.create_adapter().assemble_runtime( + ExpertProgramIntegrationCfg( + robot_profile="mobile_profile", + scene_registry="mobile_scene", + runtime_preset="runtime", + ) + ) def test_pick_place_effects_require_accepted_hand_state_and_live_pose() -> None: diff --git a/tests/sim/skills/test_parallel_runtime.py b/tests/sim/skills/test_parallel_runtime.py index 6d53cb1b..ec21d891 100644 --- a/tests/sim/skills/test_parallel_runtime.py +++ b/tests/sim/skills/test_parallel_runtime.py @@ -28,6 +28,7 @@ ArticulationJointState, CommandAcknowledgement, EndpointCommand, + ExecutionRunnerCfg, JointPositionPayload, JointPositionTarget, PlanningContext, @@ -82,6 +83,7 @@ def __init__( self.hold_targets: list[tuple[str, ...]] = [] self.hold_fingerprints: list[tuple[object, ...]] = [] self.operations: list[str] = [] + self.timeouts: list[tuple[str, float]] = [] self.holds = 0 self.cancels = 0 @@ -91,7 +93,7 @@ def send( *, timeout: float, ) -> CommandAcknowledgement: - del timeout + self.timeouts.append(("send", timeout)) if self.raise_send: raise RuntimeError("send exploded") self.operations.append("send") @@ -107,7 +109,8 @@ def hold( *, timeout: float, ) -> CommandAcknowledgement: - del context, timeout + del context + self.timeouts.append(("hold", timeout)) self.operations.append("hold") self.holds += 1 self.hold_targets.append( @@ -126,7 +129,8 @@ def cancel( *, timeout: float, ) -> CommandAcknowledgement: - del targets, timeout + del targets + self.timeouts.append(("cancel", timeout)) self.operations.append("cancel") self.cancels += 1 if self.reject_cancel: @@ -606,6 +610,144 @@ def test_completion_hold_waits_for_clock_after_accepted_command() -> None: assert outbound.operations == ["send", "hold"] +def test_parallel_runtime_uses_runner_transport_timeouts() -> None: + """Merged sends and safe stops share the selected preset runner policy.""" + outbound = _OutboundSink() + clock = _Clock() + runtime = ParallelSkillRuntime( + ( + _branch("left", 0, (_running_step(frame=_frame(0, (1.0, 1.0))),)), + _branch("right", 1, (_running_step(frame=_frame(1, (2.0, 2.0))),)), + ), + outbound, + clock, + ParallelTimingPolicy(0.1), + _AcceptSafety(), + timeout_steps=5, + runner_cfg=ExecutionRunnerCfg( + command_timeout=0.25, + safe_stop_timeout=0.75, + hold_on_completion=False, + ), + ) + + runtime.start() + runtime.step() + runtime.cancel("operator stop") + + assert outbound.timeouts == [ + ("send", pytest.approx(0.25)), + ("cancel", pytest.approx(0.75)), + ("hold", pytest.approx(0.75)), + ] + + +def test_parallel_failure_safe_holds_when_completion_hold_is_disabled() -> None: + """Failure policy always cancels and holds independently of success policy.""" + outbound = _OutboundSink() + runtime = ParallelSkillRuntime( + ( + _branch("left", 0, (_running_step(frame=_frame(0, (1.0, 1.0))),)), + _branch("right", 1, (_running_step(frame=_frame(1, (2.0, 2.0))),)), + ), + outbound, + _Clock(), + ParallelTimingPolicy(0.1), + _RejectSafety(), + timeout_steps=5, + runner_cfg=ExecutionRunnerCfg(hold_on_completion=False), + ) + + runtime.start() + result = runtime.step() + + assert result.status is SkillStatus.FAILED + assert outbound.operations == ["cancel", "hold"] + + +def test_parallel_completion_respects_disabled_completion_hold() -> None: + """Successful completion does not synthesize a hold when policy disables it.""" + left = _branch( + "left", + 0, + ( + _running_step(frame=_frame(0, (1.0, 1.0))), + _completed_step(), + ), + emit_terminal_hold=False, + ) + right = _branch( + "right", + 1, + ( + _running_step(frame=_frame(1, (2.0, 2.0))), + _completed_step(), + ), + emit_terminal_hold=False, + ) + outbound = _OutboundSink() + clock = _Clock() + runtime = ParallelSkillRuntime( + (left, right), + outbound, + clock, + ParallelTimingPolicy(0.1), + _AcceptSafety(), + timeout_steps=5, + runner_cfg=ExecutionRunnerCfg(hold_on_completion=False), + ) + + runtime.start() + runtime.step() + clock.time = 0.1 + result = runtime.step() + + assert result.status is SkillStatus.COMPLETED + assert outbound.operations == ["send"] + + +def test_parallel_minimum_cycle_time_limits_coordinator_cadence() -> None: + """Coordinator dispatches no faster than the preset's minimum cycle time.""" + left = _branch( + "left", + 0, + ( + _running_step(frame=_frame(0, (1.0, 1.0))), + _running_step(frame=_frame(0, (3.0, 3.0))), + ), + ) + right = _branch( + "right", + 1, + ( + _running_step(frame=_frame(1, (2.0, 2.0))), + _running_step(frame=_frame(1, (4.0, 4.0))), + ), + ) + outbound = _OutboundSink() + clock = _Clock() + runtime = ParallelSkillRuntime( + (left, right), + outbound, + clock, + ParallelTimingPolicy(0.1), + _AcceptSafety(), + timeout_steps=5, + runner_cfg=ExecutionRunnerCfg(minimum_cycle_time=0.25), + ) + + runtime.start() + first = runtime.step() + clock.time = 0.1 + waiting = runtime.step() + clock.time = 0.25 + runtime.step() + + assert first.wait_duration == pytest.approx(0.25) + assert waiting.wait_duration == pytest.approx(0.15) + assert len(outbound.frames) == 2 + + def test_parallel_runtime_fail_fast_is_row_local() -> None: left = _branch( "left",