feat(llm-plan-executor): execute Grant skills with verified phases

This commit is contained in:
Даниил Грабарь
2026-11-11 00:20:29 +10:00
parent 30c355ba6a
commit 2519514b02
4 changed files with 1456 additions and 0 deletions
@@ -0,0 +1,477 @@
"""Skill phases, tool ownership and object handling on a fake backend."""
from __future__ import annotations
import copy
from pathlib import Path
import pytest
from iiwa_experement.geometry import load_geometry, world_target
from iiwa_experement.object_state import transform_json
from iiwa_experement.scenario import load_scenario
from iiwa_llm_plan_executor.moveit_backend import MotionOutcome
from iiwa_llm_plan_executor.schema import PlanNode
from iiwa_llm_plan_executor.skill_context import SkillContext
from iiwa_llm_plan_executor.skills import SkillRegistry
PACKAGE = Path(__file__).resolve().parents[1]
ASSETS = PACKAGE.parent / 'iiwa_experement' / 'config'
SCENES = ASSETS / 'scenes.yaml'
GEOMETRY = load_geometry(ASSETS / 'geometry.yaml')
class FakeMotion:
def __init__(self, *, success=True):
self.calls = []
self.stop_calls = []
self.success = success
def plan_and_execute(self, goal, phase, held_object):
self.calls.append((goal, phase, held_object))
return MotionOutcome(
self.success, 'ok' if self.success else 'motion_limit',
'ok' if self.success else 'rejected by policy',
)
def stop(self, reason='stop'):
self.stop_calls.append(reason)
class FakeSupervisor:
def __init__(self, spec, geometry):
self.spec = spec
self.geometry = geometry
self.commands = []
self.state = {
'version': 1,
'mounted_tool': None,
'held_object': None,
'cap': {'state': spec.cap_state, 'pose': None},
'tools': {},
'screws': {},
'holes': {},
'objects': {},
'tcp': None,
}
if spec.cap_state != 'absent':
self.state['cap']['pose'] = transform_json(
world_target(spec, geometry, 'cap')
)
for tool in spec.tools:
self.state['tools'][tool.name] = {
'state': 'docked',
'pose': transform_json(world_target(spec, geometry, f'tool:{tool.name}')),
}
self.state['objects'][f'grant_{tool.name}'] = (
self.state['tools'][tool.name]['pose']
)
for screw in spec.screws:
self.state['screws'][screw.name] = {
'state': 'tray',
'pose': transform_json(
world_target(spec, geometry, f'screw:{screw.name}')
),
'hole': None,
}
self.state['objects'][f'grant_screw_{int(screw.name.split("_")[1]):02d}'] = (
self.state['screws'][screw.name]['pose']
)
self.state['objects']['grant_cap'] = self.state['cap']['pose']
def latest(self):
return copy.deepcopy(self.state)
def _advance(self):
self.state['version'] += 1
return copy.deepcopy(self.state)
def command(self, op, object_id='', target=None, target_name=''):
self.commands.append((op, object_id, target_name))
if op == 'attach_tool':
if self.state['mounted_tool'] is not None:
raise RuntimeError('tool already mounted')
if object_id not in self.state['tools']:
raise RuntimeError('unknown tool')
if self.state['tools'][object_id]['state'] != 'docked':
raise RuntimeError('tool is not docked')
self.state['mounted_tool'] = object_id
self.state['tools'][object_id]['state'] = 'mounted'
elif op == 'dock_tool':
if self.state['mounted_tool'] != object_id:
raise RuntimeError('tool is not mounted')
self.state['mounted_tool'] = None
self.state['tools'][object_id]['state'] = 'docked'
self.state['tools'][object_id]['pose'] = transform_json(
world_target(self.spec, self.geometry, f'tool:{object_id}')
)
elif op == 'grab_cap':
if self.state['mounted_tool'] != 'vacuum':
raise RuntimeError('vacuum is not mounted')
if self.state['held_object'] is not None:
raise RuntimeError('hand is not free')
self.state['held_object'] = 'cap'
self.state['cap']['state'] = 'held'
elif op == 'place_cap':
if self.state['held_object'] != 'cap':
raise RuntimeError('hatch is not held')
self.state['held_object'] = None
self.state['cap']['state'] = 'installed'
self.state['cap']['pose'] = transform_json(target)
elif op == 'grab_screw':
if self.state['mounted_tool'] != 'magnetic_tool':
raise RuntimeError('magnet is not mounted')
if self.state['screws'][object_id]['state'] != 'tray':
raise RuntimeError('screw is unavailable')
self.state['held_object'] = object_id
self.state['screws'][object_id]['state'] = 'held'
elif op == 'handoff_screw':
if self.state['held_object'] != object_id:
raise RuntimeError('screw is not held')
if self.state['mounted_tool'] != 'magnetic_tool':
raise RuntimeError('magnet is not mounted')
self.state['held_object'] = None
self.state['screws'][object_id]['state'] = 'delivered'
self.state['screws'][object_id]['hole'] = target_name
self.state['screws'][object_id]['pose'] = transform_json(target)
self.state['holes'].setdefault(target_name, {'state': 'empty', 'screw': None})
self.state['holes'][target_name]['screw'] = object_id
elif op == 'inspect':
pass
else:
raise RuntimeError(f'unknown op {op}')
return self._advance()
class FakeScene:
def __init__(self):
self.events = []
def attach_tool(self, tool):
self.events.append(('attach_tool', tool))
def dock_tool(self, tool):
self.events.append(('dock_tool', tool))
def attach_cap(self):
self.events.append(('attach_cap',))
def attach_screw(self, screw):
self.events.append(('attach_screw', screw))
def place_cap(self, seat):
self.events.append(('place_cap', seat))
def handoff_screw(self, screw):
self.events.append(('handoff_screw', screw))
def make_registry(experiment_id=20, *, motion=None, adapter=None):
spec = load_scenario(experiment_id, SCENES)
supervisor = FakeSupervisor(spec, GEOMETRY)
motion = motion or FakeMotion()
registry = SkillRegistry(motion, supervisor, scene_adapter=adapter or FakeScene())
context = SkillContext(spec=spec, geometry=GEOMETRY, scene_revision=1)
context.scene_adapter = registry.scene_adapter
context.frame_provider = lambda name: 5.0
return registry, context, motion, supervisor
def node(node_id, skill, obj='', target='', params=None):
return PlanNode(
id=node_id, skill=skill, object=obj, target=target,
params=params or {},
)
def phases(motion):
return [phase for _, phase, _ in motion.calls]
def test_approach_goes_above_the_target_and_saves_the_height():
registry, context, motion, _ = make_registry()
result = registry.execute(node('a', 'approach', 'cap'), context)
assert result.event == 'success'
assert phases(motion) == ['approach']
safe = motion.calls[0][0]
assert context.approach_pose is not None
assert context.phase == 'approach'
assert safe.xyz[2] > context.contact_pose.xyz[2] if context.contact_pose else True
repeated = registry.execute(node('a', 'approach', 'cap'), context)
assert repeated.event == 'success'
assert phases(motion) == ['approach']
def test_grab_cap_runs_missing_phases_and_lifts_to_saved_height():
registry, context, motion, supervisor = make_registry()
context.mounted_tool = 'vacuum'
supervisor.state['mounted_tool'] = 'vacuum'
supervisor.state['tools']['vacuum']['state'] = 'mounted'
result = registry.execute(node('g', 'grab', 'cap'), context)
assert result.event == 'success'
assert phases(motion) == ['approach', 'lower', 'retreat']
assert supervisor.commands == [('grab_cap', 'cap', '')]
assert context.held_object == 'cap'
assert context.phase == 'lifted'
assert ('attach_cap',) in registry.scene_adapter.events
def test_explicit_phases_are_not_repeated_by_grab():
registry, context, motion, supervisor = make_registry()
context.mounted_tool = 'vacuum'
supervisor.state['mounted_tool'] = 'vacuum'
assert registry.execute(node('a', 'approach', 'cap'), context).event == 'success'
assert registry.execute(node('l', 'lower', 'cap'), context).event == 'success'
assert registry.execute(node('g', 'grab', 'cap'), context).event == 'success'
assert phases(motion) == ['approach', 'lower', 'retreat']
assert registry.execute(node('up', 'lift', 'cap'), context).event == 'success'
assert phases(motion) == ['approach', 'lower', 'retreat']
def test_grab_cap_without_vacuum_does_not_move():
registry, context, motion, _ = make_registry()
result = registry.execute(node('g', 'grab', 'cap'), context)
assert result.event == 'failed'
assert motion.calls == []
def test_tool_grab_docks_at_the_socket_and_lifts_out():
registry, context, motion, supervisor = make_registry()
result = registry.execute(node('g', 'grab', 'vacuum'), context)
assert result.event == 'success'
assert phases(motion) == ['approach', 'lower', 'retreat']
assert supervisor.commands == [('attach_tool', 'vacuum', '')]
assert context.mounted_tool == 'vacuum'
assert ('attach_tool', 'vacuum') in registry.scene_adapter.events
def test_bare_release_of_the_suction_cup_does_not_move_or_detach():
registry, context, motion, supervisor = make_registry()
context.mounted_tool = 'vacuum'
supervisor.state['mounted_tool'] = 'vacuum'
result = registry.execute(node('r', 'release', 'vacuum', ''), context)
assert result.event == 'failed'
assert motion.calls == []
assert supervisor.commands == []
assert context.mounted_tool == 'vacuum'
def test_release_at_socket_requires_the_explicit_transfer():
registry, context, motion, supervisor = make_registry()
context.mounted_tool = 'vacuum'
supervisor.state['mounted_tool'] = 'vacuum'
result = registry.execute(
node('r', 'release', 'vacuum', 'vacuum_socket'), context,
)
assert result.event == 'failed'
assert motion.calls == []
assert context.mounted_tool == 'vacuum'
def test_explicit_transfer_then_dock_keeps_tool_in_patron_until_dock():
registry, context, motion, supervisor = make_registry()
assert registry.execute(node('g', 'grab', 'vacuum'), context).event == 'success'
transfer = registry.execute(
node('t', 'transfer', 'vacuum', 'work'), context,
)
assert transfer.event == 'success'
assert context.mounted_tool == 'vacuum'
assert phases(motion)[-1] == 'transfer'
to_socket = registry.execute(
node('t2', 'transfer', 'vacuum', 'vacuum_socket'), context,
)
assert to_socket.event == 'success'
dock = registry.execute(
node('d', 'dock_tool', 'vacuum', 'vacuum_socket'), context,
)
assert dock.event == 'success'
assert context.mounted_tool is None
assert supervisor.commands[-1][0] == 'dock_tool'
assert ('dock_tool', 'vacuum') in registry.scene_adapter.events
assert phases(motion)[-2:] == ['lower', 'retreat']
def test_cap_transfer_align_and_release_place_the_hatch():
registry, context, motion, supervisor = make_registry()
context.mounted_tool = 'vacuum'
context.held_object = 'cap'
supervisor.state['mounted_tool'] = 'vacuum'
supervisor.state['held_object'] = 'cap'
supervisor.state['cap']['state'] = 'held'
assert registry.execute(
node('t', 'transfer', 'cap', 'hatch_seat'), context,
).event == 'success'
assert registry.execute(node('a', 'align', 'cap', 'hatch_seat'), context).event == 'success'
before = len(motion.calls)
result = registry.execute(node('r', 'release', 'cap', 'hatch_seat'), context)
assert result.event == 'success'
assert phases(motion)[before:] == ['lower', 'retreat']
assert context.held_object is None
assert supervisor.state['cap']['state'] == 'installed'
assert ('place_cap',) in [tuple(event[:1]) for event in registry.scene_adapter.events]
assert supervisor.commands[-1][0] == 'place_cap'
def test_release_lifts_axially_before_any_lateral_motion():
registry, context, motion, _ = make_registry()
context.mounted_tool = 'vacuum'
context.held_object = 'cap'
context.transfer_target = 'hatch_seat'
seat = world_target(context.spec, context.geometry, 'cap_seat')
from iiwa_experement.geometry import tcp_goal
contact = tcp_goal(seat, context.geometry.cap_tcp_to_node)
from iiwa_llm_plan_executor.skills import _above
safe = _above(contact, context.geometry.approach_gap)
context.mark_approach(safe, ('cap', 'hatch_seat'))
context.mark_contact(contact)
registry.supervisor.state['mounted_tool'] = 'vacuum'
registry.supervisor.state['held_object'] = 'cap'
registry.supervisor.state['cap']['state'] = 'held'
result = registry.execute(node('r', 'release', 'cap', 'hatch_seat'), context)
assert result.event == 'success'
assert phases(motion)[-1] == 'retreat'
assert motion.calls[-1][1] == 'retreat'
assert motion.calls[-1][0].xyz[2] > contact.xyz[2]
def test_dispense_moves_along_the_segment_without_changing_ownership():
registry, context, motion, _ = make_registry()
context.mounted_tool = 'sealant_tool'
result = registry.execute(
node('d', 'dispense', 'side_1', params={
'start_fraction': 0.0, 'end_fraction': 1.0,
}),
context,
)
assert result.event == 'success'
assert phases(motion) == ['approach', 'lower', 'dispense', 'retreat']
assert context.mounted_tool == 'sealant_tool'
assert context.held_object is None
def test_screw_handoff_delivers_to_the_operator_and_keeps_the_hole_empty():
registry, context, motion, supervisor = make_registry()
context.mounted_tool = 'magnetic_tool'
context.held_object = 'screw_1'
supervisor.state['mounted_tool'] = 'magnetic_tool'
supervisor.state['held_object'] = 'screw_1'
supervisor.state['screws']['screw_1']['state'] = 'held'
assert registry.execute(
node('t', 'transfer', 'screw_1', 'hole_1'), context,
).event == 'success'
before = len(motion.calls)
result = registry.execute(
node('h', 'handoff_screw', 'screw_1', 'hole_1'), context,
)
assert result.event == 'success'
assert phases(motion)[before:] == ['lower', 'retreat']
assert supervisor.state['screws']['screw_1']['state'] == 'delivered'
assert supervisor.state['screws']['screw_1']['hole'] == 'hole_1'
assert supervisor.state['holes']['hole_1']['state'] == 'empty'
assert ('handoff_screw', 'screw_1') in registry.scene_adapter.events
assert result.details['installed'] is False
assert result.details['tightened'] is False
def test_detect_withdraws_axially_then_uses_fresh_frames():
registry, context, motion, _ = make_registry()
context.mark_approach(
GEOMETRY.safe_poses['work'], ('cap', ''),
)
context.mark_contact(GEOMETRY.safe_poses['work'])
result = registry.execute(node('d', 'detect', 'cap'), context)
assert result.event == 'found'
assert phases(motion)[0] == 'retreat'
assert phases(motion)[1] == 'transfer'
assert context.phase == 'free'
context.frame_provider = lambda name: None
assert registry.execute(node('d', 'detect', 'cap'), context).event == 'failed'
context.frame_provider = lambda name: 5.0
context.close_inspection_objects = frozenset({'cap'})
context.frame_provider = lambda name: 5.0 if name == 'top' else None
assert registry.execute(node('d', 'detect', 'cap'), context).event == 'failed'
def test_capture_requires_a_fresh_frame():
registry, context, _, _ = make_registry()
assert registry.execute(node('c', 'capture', 'top'), context).event == 'success'
context.frame_provider = lambda name: None
assert registry.execute(node('c', 'capture', 'top'), context).event == 'failed'
def test_inspect_state_distinguishes_empty_holding_placed_unknown():
registry, context, _, supervisor = make_registry()
supervisor.state['cap']['state'] = 'free'
assert registry.execute(node('i', 'inspect_state', 'cap'), context).event == 'empty'
supervisor.state['held_object'] = 'cap'
supervisor.state['cap']['state'] = 'held'
assert registry.execute(node('i', 'inspect_state', 'cap'), context).event == 'holding'
supervisor.state['held_object'] = None
supervisor.state['cap']['state'] = 'installed'
assert registry.execute(node('i', 'inspect_state', 'cap'), context).event == 'placed'
supervisor.state['cap']['state'] = 'bogus'
assert registry.execute(node('i', 'inspect_state', 'cap'), context).event == 'unknown'
def test_inspect_state_recovers_ownership_after_a_failed_grasp():
registry, context, _, supervisor = make_registry()
supervisor.state['held_object'] = None
supervisor.state['cap']['state'] = 'free'
context.ownership_unknown = True
result = registry.execute(node('i', 'inspect_state', 'cap'), context)
assert result.event == 'empty'
assert context.ownership_unknown is False
def test_verify_measures_the_placed_hatch():
registry, context, _, supervisor = make_registry()
supervisor.state['cap']['state'] = 'installed'
supervisor.state['cap']['pose'] = transform_json(
world_target(context.spec, context.geometry, 'cap_seat')
)
result = registry.execute(
node('v', 'verify', 'cap', 'hatch_seat'), context,
)
assert result.event == 'ok'
shifted = world_target(context.spec, context.geometry, 'cap_seat')
shifted = type(shifted)(
xyz=(shifted.xyz[0] + 0.02, shifted.xyz[1], shifted.xyz[2]),
rotation=shifted.rotation,
parent=shifted.parent,
child=shifted.child,
)
supervisor.state['cap']['pose'] = transform_json(shifted)
assert registry.execute(
node('v', 'verify', 'cap', 'hatch_seat'), context,
).event == 'not_ok'
def test_stop_preserves_ownership_and_wait_scene_clear_reports_timeout():
registry, context, motion, _ = make_registry()
context.held_object = 'cap'
assert registry.execute(node('s', 'stop', 'robot'), context).event == 'success'
assert motion.stop_calls == ['graph stop node']
assert context.held_object == 'cap'
context.scene_clear_provider = lambda: False
assert registry.execute(
node('w', 'wait_scene_clear', 'workspace'), context,
).event == 'timeout'
context.scene_clear_provider = lambda: True
assert registry.execute(
node('w', 'wait_scene_clear', 'workspace'), context,
).event == 'clear'
def test_unknown_skill_and_backend_failure_are_not_successes():
registry, context, motion, _ = make_registry(motion=FakeMotion(success=False))
assert registry.execute(node('x', 'tighten', 'hole_1'), context).event == 'failed'
assert registry.execute(node('a', 'approach', 'cap'), context).event == 'failed'