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 cbddb478b..fc84034ba 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 fe5e5d9ca..06963c265 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 eee4660eb..6fa9f9df7 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 def756fc5..98040c412 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 0892f2284..9acb72234 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 2fc875d5d..8e6fd3656 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 1a048fd41..eef86d858 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 2661ac884..e645e0ba4 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 dcca9b241..9d6108af7 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 652df4702..b3a6fc664 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 c52e18b88..bc9b55680 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 4cdcd1227..ec8910bdc 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 8e5eb6d2e..fdac46a71 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 0aa252c9f..90b4a0924 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 8a32e3bfd..1423fb138 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 5400a54ad..1156786b3 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(