feat(llm-plan-executor): parse and validate externally supplied graphs

This commit is contained in:
Даниил Грабарь
2026-11-11 00:14:33 +10:00
parent 3128191e6d
commit 9ca08c01c2
10 changed files with 1591 additions and 0 deletions
@@ -0,0 +1,90 @@
# `iiwa_llm_plan_executor` interfaces
Graph contract version: **1** (it matches `iiwa_scene_context` contract 1).
The executor never calls Qwen3 and never rewrites the model graph.
## Graph response
The accepted response is exactly:
```json
{
"status": "ready",
"entry": "p1_find",
"plan": [
{
"id": "p1_find",
"skill": "detect",
"object": "cap",
"target": "",
"params": {},
"transitions": [
{"event": "found", "next": "p1_grab"},
{"event": "not_found", "next": "replan_required"},
{"event": "failed", "next": "replan_required"}
]
}
]
}
```
* `status` is `ready` or `blocked`. `blocked` is never executed.
* `plan` is a list of at most 256 nodes. Unknown keys, duplicate ids,
reserved terminal ids, missing `entry`, missing required events, unknown
events, unknown `next`, unreachable nodes and nodes that cannot reach a
terminal are rejected.
* Transitions may also be sent once at top level as
`{"from": "...", "event": "...", "to": "..."}`; they are merged into the
node by `parse_response`. Mixing both forms for the same event is rejected.
* Reserved terminals: `completed`, `replan_required`, `operator_required`.
`completed` is the verified end of the submitted graph; it is not a score of
the model against a Russian instruction.
* `params` is optional and currently only `dispense` accepts
`start_fraction`/`end_fraction` in `0..1`.
## Skills and aliases
Native: `detect`, `approach`, `lower`, `grab`, `lift`, `transfer`, `align`,
`release`, `retreat`, `capture`, `verify`, `stop`, `wait_scene_clear`,
`inspect_state`, `notify_operator`, `dispense`, `dock_tool`,
`handoff_screw`.
Compatibility aliases: `pick` (= `grab`), `move_to_storage`
(= `transfer` + axial `lower`, keeping the grasp), `inspect` (= `verify`).
`tighten` is not in this profile and is rejected before any motion.
The contract, outcomes and available ids come from
`iiwa_scene_context/catalog.py` and the live `scene_json`; a model cannot add
a skill or a contract.
## Admission rules
`parse_response(raw) -> PlanGraph` performs the strict JSON shape check.
`validate_graph(graph, scene_envelope, catalog, expected_scene_revision=...)`
returns `ValidationResult(ok, code, reason, details)` with codes:
| code | meaning |
| --- | --- |
| `ok` | admitted |
| `blocked` | the model returned `status=blocked`; no motion |
| `replan_required` | a fresh scene/plan is required before motion (missing tool, missing explicit transfer, unconfirmed ownership) |
| `rejected` | structural or safety violation |
| `stale_scene` | the graph was built for another `scene_revision` |
Important pre-movement rules:
* `grab(cap)` requires `vacuum` in `patron`. If the scene starts with the cup
docked, the graph must explicitly `grab(vacuum)`; otherwise admission is
`replan_required` and nothing moves.
* `release(vacuum)` without a target, or anywhere except its named socket and
after an explicit `transfer(vacuum, vacuum_socket)`, is `replan_required`.
The cup stays mounted. `release(vacuum, vacuum_socket)` and
`dock_tool(vacuum, vacuum_socket)` are accepted only at the reached socket.
* A graph ending in `work` with `vacuum` mounted is accepted; the tool stays in
`patron`.
* `grasp_failed` must go to `inspect_state` or `stop`; it can never become
`completed`. `collision` must reach `stop`/`notify_operator` or
`replan_required`/`operator_required`.
* Screws are handed to the virtual operator at a hole; the hole stays empty
and no insertion/tightening occurs.
* No task is inferred from `experiment_id`; only the initial scene is selected.
@@ -0,0 +1,23 @@
"""Graph validation and execution for externally supplied Qwen3 plans."""
from .schema import (
MAX_NODES,
TERMINALS,
PlanGraph,
PlanNode,
PlanSchemaError,
Transition,
parse_response,
)
from .validator import ValidationResult, validate_graph
__all__ = [
'MAX_NODES',
'TERMINALS',
'PlanGraph',
'PlanNode',
'PlanSchemaError',
'Transition',
'ValidationResult',
'parse_response',
'validate_graph',
]
@@ -0,0 +1,251 @@
"""Strict parsing of the externally supplied Qwen3 graph response."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, Mapping
MAX_NODES = 256
MAX_RESPONSE_BYTES = 200_000
ALLOWED_STATUSES = ('ready', 'blocked')
TERMINALS = ('completed', 'replan_required', 'operator_required')
TOP_LEVEL_KEYS = ('status', 'entry', 'plan', 'transitions')
NODE_KEYS = ('id', 'skill', 'object', 'target', 'transitions', 'params')
class PlanSchemaError(ValueError):
"""The response is not a well-formed plan graph."""
@dataclass(frozen=True)
class Transition:
event: str
next: str
def as_dict(self) -> dict[str, str]:
return {'event': self.event, 'next': self.next}
@dataclass(frozen=True)
class PlanNode:
id: str
skill: str
object: str = ''
target: str = ''
params: dict[str, Any] = field(default_factory=dict)
transitions: tuple[Transition, ...] = ()
def as_dict(self) -> dict[str, Any]:
payload: dict[str, Any] = {
'id': self.id,
'skill': self.skill,
'object': self.object,
'target': self.target,
'transitions': [entry.as_dict() for entry in self.transitions],
}
if self.params:
payload['params'] = dict(self.params)
return payload
@dataclass(frozen=True)
class PlanGraph:
status: str
entry: str
nodes: tuple[PlanNode, ...]
top_level_transitions: tuple[tuple[str, Transition], ...] = ()
def node(self, node_id: str) -> PlanNode:
for item in self.nodes:
if item.id == node_id:
return item
raise KeyError(node_id)
@property
def ids(self) -> tuple[str, ...]:
return tuple(item.id for item in self.nodes)
def as_dict(self) -> dict[str, Any]:
return {
'status': self.status,
'entry': self.entry,
'plan': [item.as_dict() for item in self.nodes],
}
def _string(value: Any, label: str, *, allow_empty: bool = True) -> str:
if not isinstance(value, str):
raise PlanSchemaError(f'{label} must be a string')
text = value.strip()
if not allow_empty and not text:
raise PlanSchemaError(f'{label} must not be empty')
return text
def _params(raw: Any, label: str) -> dict[str, Any]:
if raw is None:
return {}
if not isinstance(raw, Mapping):
raise PlanSchemaError(f'{label}.params must be an object')
result: dict[str, Any] = {}
for key, value in raw.items():
if not isinstance(key, str) or not key:
raise PlanSchemaError(f'{label}.params keys must be non-empty strings')
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
raise PlanSchemaError(
f'{label}.params.{key} must be a number or string'
)
result[key] = value
return result
def _transition(raw: Any, label: str) -> Transition:
if not isinstance(raw, Mapping):
raise PlanSchemaError(f'{label} must be an object')
extra = set(raw) - {'event', 'next'}
if extra:
raise PlanSchemaError(f'{label} has unknown keys {sorted(extra)}')
event = _string(raw.get('event'), f'{label}.event', allow_empty=False)
next_id = _string(raw.get('next'), f'{label}.next', allow_empty=False)
return Transition(event, next_id)
def _node(raw: Any, index: int) -> PlanNode:
label = f'plan[{index}]'
if not isinstance(raw, Mapping):
raise PlanSchemaError(f'{label} must be an object')
extra = set(raw) - set(NODE_KEYS)
if extra:
raise PlanSchemaError(f'{label} has unknown keys {sorted(extra)}')
node_id = _string(raw.get('id'), f'{label}.id', allow_empty=False)
skill = _string(raw.get('skill'), f'{label}.skill', allow_empty=False)
obj = _string(raw.get('object', ''), f'{label}.object')
target = _string(raw.get('target', ''), f'{label}.target')
transitions_raw = raw.get('transitions', [])
if not isinstance(transitions_raw, list):
raise PlanSchemaError(f'{label}.transitions must be a list')
transitions: list[Transition] = []
seen: set[str] = set()
for t_index, entry in enumerate(transitions_raw):
transition = _transition(entry, f'{label}.transitions[{t_index}]')
if transition.event in seen:
raise PlanSchemaError(
f'{label} has duplicate event {transition.event!r}'
)
seen.add(transition.event)
transitions.append(transition)
return PlanNode(
id=node_id,
skill=skill,
object=obj,
target=target,
params=_params(raw.get('params'), label),
transitions=tuple(transitions),
)
def _merge_top_level(
nodes: list[PlanNode],
raw: Any,
) -> tuple[list[PlanNode], tuple[tuple[str, Transition], ...]]:
if raw is None:
return nodes, ()
if not isinstance(raw, list):
raise PlanSchemaError('transitions must be a list')
by_id = {node.id: node for node in nodes}
merged: dict[str, list[Transition]] = {
node.id: list(node.transitions) for node in nodes
}
top_level: list[tuple[str, Transition]] = []
for index, entry in enumerate(raw):
label = f'transitions[{index}]'
if not isinstance(entry, Mapping):
raise PlanSchemaError(f'{label} must be an object')
extra = set(entry) - {'from', 'event', 'to', 'next'}
if extra:
raise PlanSchemaError(f'{label} has unknown keys {sorted(extra)}')
source = _string(entry.get('from'), f'{label}.from', allow_empty=False)
next_id = entry.get('to', entry.get('next'))
transition = Transition(
_string(entry.get('event'), f'{label}.event', allow_empty=False),
_string(next_id, f'{label}.to', allow_empty=False),
)
if source not in by_id:
raise PlanSchemaError(f'{label}.from references unknown node {source!r}')
existing = {item.event for item in merged[source]}
if transition.event in existing:
raise PlanSchemaError(
f'{label}: node {source!r} already has event '
f'{transition.event!r}'
)
merged[source].append(transition)
top_level.append((source, transition))
result = [
PlanNode(
id=node.id,
skill=node.skill,
object=node.object,
target=node.target,
params=node.params,
transitions=tuple(merged[node.id]),
)
for node in nodes
]
return result, tuple(top_level)
def parse_response(raw: str | Mapping[str, Any]) -> PlanGraph:
"""Parse a model response or raise :class:`PlanSchemaError`."""
if isinstance(raw, str):
if len(raw.encode('utf-8')) > MAX_RESPONSE_BYTES:
raise PlanSchemaError(
f'response exceeds {MAX_RESPONSE_BYTES} bytes'
)
try:
payload = json.loads(raw)
except json.JSONDecodeError as error:
raise PlanSchemaError(f'response is not valid JSON: {error}') from error
elif isinstance(raw, Mapping):
payload = dict(raw)
else:
raise PlanSchemaError('response must be a JSON string or object')
if not isinstance(payload, Mapping):
raise PlanSchemaError('response must be a JSON object')
extra = set(payload) - set(TOP_LEVEL_KEYS)
if extra:
raise PlanSchemaError(f'response has unknown keys {sorted(extra)}')
status = _string(payload.get('status'), 'status', allow_empty=False)
if status not in ALLOWED_STATUSES:
raise PlanSchemaError(
f'status must be one of {list(ALLOWED_STATUSES)}, got {status!r}'
)
entry = _string(payload.get('entry', ''), 'entry')
plan_raw = payload.get('plan')
if not isinstance(plan_raw, list):
raise PlanSchemaError('plan must be a list')
if len(plan_raw) > MAX_NODES:
raise PlanSchemaError(
f'plan contains {len(plan_raw)} nodes; limit is {MAX_NODES}'
)
nodes: list[PlanNode] = []
seen_ids: set[str] = set()
for index, raw_node in enumerate(plan_raw):
node = _node(raw_node, index)
if node.id in seen_ids:
raise PlanSchemaError(f'duplicate node id {node.id!r}')
if node.id in TERMINALS:
raise PlanSchemaError(
f'node id {node.id!r} is a reserved terminal'
)
seen_ids.add(node.id)
nodes.append(node)
nodes, top_level = _merge_top_level(nodes, payload.get('transitions'))
if status == 'ready':
if not nodes:
raise PlanSchemaError('a ready graph must contain at least one node')
if not entry:
raise PlanSchemaError('a ready graph must have a non-empty entry')
elif nodes or entry:
raise PlanSchemaError('a blocked response must not contain an executable plan')
return PlanGraph(status, entry, tuple(nodes), top_level)
@@ -0,0 +1,591 @@
"""Validate a parsed graph against the live scene and the server catalog."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Iterable, Mapping
from iiwa_scene_context.catalog import ALLOWED_PARAMS, SkillCatalog
from .schema import TERMINALS, PlanGraph, PlanNode
SAFE_COLLISION_SKILLS = {'stop', 'notify_operator'}
SAFE_GRASP_FAILURE_SKILLS = {'inspect_state', 'stop'}
SAFE_FAILURE_TERMINALS = {'replan_required', 'operator_required'}
PROGRESS_EVENTS = {
'success', 'ok', 'found', 'clear', 'empty', 'holding', 'placed',
}
REQUIRES_OBJECT = {
'detect', 'approach', 'lower', 'grab', 'lift', 'transfer', 'align',
'release', 'retreat', 'verify', 'dispense', 'dock_tool',
'handoff_screw', 'pick', 'move_to_storage', 'inspect', 'inspect_state',
'capture',
}
@dataclass(frozen=True)
class ValidationResult:
ok: bool
code: str
reason: str
details: dict[str, Any] = field(default_factory=dict)
@property
def accepted(self) -> bool:
return self.ok
def as_dict(self) -> dict[str, Any]:
return {
'ok': self.ok,
'code': self.code,
'reason': self.reason,
'details': dict(self.details),
}
@dataclass(frozen=True)
class _State:
mounted: str | None = None
held: str | None = None
unknown: bool = False
transfer_target: str | None = None
@dataclass(frozen=True)
class _Facts:
components: dict[str, str]
tools: dict[str, str]
destinations: dict[str, str]
cameras: set[str]
service_objects: set[str]
initial: _State
available_skills: set[str]
envelope: Mapping[str, Any]
def _scene_payload(scene: Mapping[str, Any]) -> tuple[Mapping[str, Any], Mapping[str, Any]]:
if 'scene_json' in scene:
payload = scene['scene_json']
if not isinstance(payload, Mapping):
raise ValueError('scene_json must be an object')
return scene, payload
return {}, scene
def _facts(scene: Mapping[str, Any]) -> _Facts:
envelope, payload = _scene_payload(scene)
components = {
entry['id']: str((entry.get('attributes') or {}).get('kind', 'component'))
for entry in payload.get('components', [])
if isinstance(entry, Mapping) and isinstance(entry.get('id'), str)
}
tools = {
entry['id']: str(entry.get('socket', f"{entry['id']}_socket"))
for entry in payload.get('tools', [])
if isinstance(entry, Mapping) and isinstance(entry.get('id'), str)
}
destinations = {
entry['id']: str(entry.get('kind', 'destination'))
for entry in payload.get('destinations', [])
if isinstance(entry, Mapping) and isinstance(entry.get('id'), str)
}
cameras = set(payload.get('cameras', {}))
service_objects = set(payload.get('service_objects', {}))
initial_raw = payload.get('initial_state') or {}
initial = _State(
mounted=initial_raw.get('tool_mounted') or None,
held=initial_raw.get('held_object') or None,
)
available = set(payload.get('available_skills', []))
return _Facts(
components=components,
tools=tools,
destinations=destinations,
cameras=cameras,
service_objects=service_objects,
initial=initial,
available_skills=available,
envelope=envelope,
)
def _reject(reason: str, *, code: str = 'rejected', **details) -> ValidationResult:
return ValidationResult(False, code, reason, details)
def _replan(reason: str, **details) -> ValidationResult:
return ValidationResult(False, 'replan_required', reason, details)
def _node_or_terminal(graph: PlanGraph, node_id: str) -> PlanNode | None:
if node_id in TERMINALS:
return None
return graph.node(node_id)
def _structural_checks(graph: PlanGraph, facts: _Facts) -> ValidationResult | None:
ids = set(graph.ids)
for node in graph.nodes:
if not node.transitions:
return _reject(f'node {node.id!r} has no transitions')
if node.skill not in facts.available_skills:
return _reject(
f'node {node.id!r} uses unknown or unavailable skill '
f'{node.skill!r}'
)
if node.object:
known = (
node.object in facts.components
or node.object in facts.tools
or node.object in facts.cameras
or node.object in facts.service_objects
or node.object in facts.destinations
)
if not known:
return _reject(
f'node {node.id!r} references unknown object '
f'{node.object!r}'
)
if node.target:
known = (
node.target in facts.destinations
or node.target in facts.components
or node.target in facts.tools
)
if not known:
return _reject(
f'node {node.id!r} references unknown target '
f'{node.target!r}'
)
if node.params:
allowed_params = set(ALLOWED_PARAMS.get(node.skill, ()))
unknown_params = set(node.params) - allowed_params
if unknown_params:
return _reject(
f'node {node.id!r} has unsupported params '
f'{sorted(unknown_params)}'
)
if node.skill == 'dispense':
for key in ('start_fraction', 'end_fraction'):
if key in node.params:
value = node.params[key]
if (
isinstance(value, bool)
or not isinstance(value, (int, float))
or not 0.0 <= float(value) <= 1.0
):
return _reject(
f'node {node.id!r} param {key} must be '
'within 0..1'
)
start = float(node.params.get('start_fraction', 0.0))
end = float(node.params.get('end_fraction', 1.0))
if start > end:
return _reject(
f'node {node.id!r} dispense start_fraction exceeds '
'end_fraction'
)
allowed_events = set(facts.envelope.get('skill_contracts', {}).get(
node.skill, {},
).get('outcomes', {}))
if not allowed_events:
# The server catalog remains authoritative even if the envelope
# was produced by an older snapshot.
from iiwa_scene_context.catalog import SKILL_OUTCOMES
allowed_events = set(SKILL_OUTCOMES.get(node.skill, {}))
events = {transition.event for transition in node.transitions}
missing = allowed_events - events
extra = events - allowed_events
if missing:
return _reject(
f'node {node.id!r} misses required transitions {sorted(missing)}'
)
if extra:
return _reject(
f'node {node.id!r} has unknown events {sorted(extra)}'
)
for transition in node.transitions:
if transition.next not in ids and transition.next not in TERMINALS:
return _reject(
f'node {node.id!r} points to unknown node '
f'{transition.next!r}'
)
if transition.next == node.id:
# A self-loop that does not inspect state would spin forever.
if node.skill not in ('inspect_state', 'wait_scene_clear'):
return _reject(
f'node {node.id!r} has a direct self-loop'
)
if graph.entry not in ids:
return _reject(f'entry {graph.entry!r} is not a plan node')
reachable: set[str] = set()
stack = [graph.entry]
while stack:
current = stack.pop()
if current in reachable or current not in ids:
continue
reachable.add(current)
stack.extend(item.next for item in graph.node(current).transitions)
unreachable = ids - reachable
if unreachable:
return _reject(f'unreachable nodes {sorted(unreachable)}')
reverse: dict[str, set[str]] = {node_id: set() for node_id in ids}
for node in graph.nodes:
for transition in node.transitions:
if transition.next in ids:
reverse[transition.next].add(node.id)
can_finish = set(TERMINALS)
changed = True
while changed:
changed = False
for node_id in ids - can_finish:
if any(item in can_finish for item in _targets(graph.node(node_id))):
can_finish.add(node_id)
changed = True
dead = ids - can_finish
if dead:
return _reject(
f'nodes cannot reach a terminal: {sorted(dead)}'
)
for node in graph.nodes:
for transition in node.transitions:
target = _node_or_terminal(graph, transition.next)
if transition.event == 'collision':
if target is None and transition.next not in SAFE_FAILURE_TERMINALS:
return _reject(
f'collision in {node.id!r} must stop the operation'
)
if target is not None and target.skill not in SAFE_COLLISION_SKILLS:
return _reject(
f'collision in {node.id!r} must reach stop/notify, '
f'got {target.skill!r}'
)
if transition.event == 'grasp_failed':
if target is None and transition.next not in SAFE_FAILURE_TERMINALS:
return _reject(
f'grasp_failed in {node.id!r} must not continue blindly'
)
if target is not None and target.skill not in SAFE_GRASP_FAILURE_SKILLS:
return _reject(
f'grasp_failed in {node.id!r} must reach inspect_state '
f'or stop, got {target.skill!r}'
)
if transition.event in (
'not_found', 'failed', 'not_ok', 'timeout', 'unknown',
) and transition.next == 'completed':
return _reject(
f'{transition.event} in {node.id!r} cannot map to completed'
)
return None
def _targets(node: PlanNode) -> list[str]:
return [item.next for item in node.transitions]
def _precondition(
node: PlanNode,
state: _State,
facts: _Facts,
) -> ValidationResult | None:
skill = node.skill
obj = node.object
target = node.target
if node.id in ('',) or skill not in facts.available_skills:
return _reject(f'unknown skill {skill!r}')
if skill in REQUIRES_OBJECT and not obj:
return _reject(f'{skill} in {node.id!r} requires an object')
if state.unknown and skill not in (
'inspect_state', 'stop', 'notify_operator', 'wait_scene_clear',
):
return _replan(
f'{node.id!r} follows an unconfirmed grasp; inspect_state is required',
)
if skill in ('grab', 'pick'):
if obj in facts.tools:
if state.mounted is not None:
return _replan(
f'{node.id!r} cannot grab {obj!r}: tool '
f'{state.mounted!r} is already mounted'
)
if state.held is not None:
return _replan(
f'{node.id!r} cannot grab {obj!r} while holding '
f'{state.held!r}'
)
return None
if obj == 'cap':
if 'cap' not in facts.components:
return _reject(f'{node.id!r}: hatch is absent from this scene')
if state.mounted != 'vacuum':
return _replan(
f'{node.id!r} requires the vacuum tool in patron; '
f'mounted tool is {state.mounted!r}'
)
if state.held is not None:
return _replan(
f'{node.id!r} cannot grab the hatch while holding '
f'{state.held!r}'
)
return None
if obj in facts.components and facts.components[obj] == 'screw':
if state.mounted != 'magnetic_tool':
return _replan(
f'{node.id!r} requires magnetic_tool in patron; '
f'mounted tool is {state.mounted!r}'
)
if state.held is not None:
return _replan(
f'{node.id!r} cannot grab a screw while holding '
f'{state.held!r}'
)
return None
return _reject(f'{node.id!r}: object {obj!r} cannot be grasped')
if skill == 'release':
if obj in facts.tools:
socket = facts.tools[obj]
if state.mounted != obj:
return _replan(
f'{node.id!r}: {obj!r} is not mounted '
f'(mounted={state.mounted!r})'
)
if target != socket:
return _replan(
f'{node.id!r}: release({obj}) must name its socket '
f'{socket!r}'
)
if state.transfer_target != socket:
return _replan(
f'{node.id!r}: release({obj},{socket}) requires an explicit '
'preceding transfer to that socket'
)
return None
if obj == 'cap':
if state.held != 'cap':
return _replan(f'{node.id!r}: hatch is not held')
if facts.destinations.get(target) != 'seat':
return _replan(
f'{node.id!r}: hatch release requires hatch_seat, '
f'got {target!r}'
)
return None
if obj in facts.components and facts.components[obj] == 'screw':
if state.held != obj:
return _replan(f'{node.id!r}: {obj!r} is not held')
if facts.destinations.get(target) != 'hole':
return _replan(
f'{node.id!r}: screw handoff requires a hole target'
)
return None
return _reject(f'{node.id!r}: object {obj!r} cannot be released')
if skill in ('transfer', 'move_to_storage'):
if obj in facts.tools:
if state.mounted != obj:
return _replan(f'{node.id!r}: {obj!r} is not mounted')
if target not in ('work', facts.tools[obj]):
return _replan(
f'{node.id!r}: tool transfer must target work or its socket'
)
return None
if obj == 'cap':
if state.held != 'cap':
return _replan(f'{node.id!r}: hatch is not held')
if target != 'hatch_seat' and target != 'work':
return _replan(
f'{node.id!r}: hatch transfer must target hatch_seat or work'
)
return None
if obj in facts.components and facts.components[obj] == 'screw':
if state.held != obj:
return _replan(f'{node.id!r}: {obj!r} is not held')
if facts.destinations.get(target) != 'hole' and target != 'work':
return _replan(
f'{node.id!r}: screw transfer must target a hole or work'
)
return None
return _reject(f'{node.id!r}: object {obj!r} cannot be transferred')
if skill == 'align':
if state.held is None and state.mounted is None:
return _replan(f'{node.id!r}: align requires a held part or tool')
return None
if skill == 'dispense':
if state.mounted != 'sealant_tool':
return _replan(
f'{node.id!r}: dispense requires sealant_tool in patron'
)
side = obj or target
if facts.destinations.get(side) != 'contour':
return _reject(
f'{node.id!r}: dispense target {side!r} is not a sealant side'
)
return None
if skill == 'dock_tool':
socket = facts.tools.get(obj)
if socket is None:
return _reject(f'{node.id!r}: {obj!r} is not a present tool')
if state.mounted != obj:
return _replan(
f'{node.id!r}: {obj!r} is not mounted '
f'(mounted={state.mounted!r})'
)
if target != socket:
return _replan(
f'{node.id!r}: dock_tool must target socket {socket!r}'
)
if state.transfer_target != socket:
return _replan(
f'{node.id!r}: dock_tool requires an explicit preceding '
'transfer to that socket'
)
return None
if skill == 'handoff_screw':
if obj not in facts.components or facts.components[obj] != 'screw':
return _reject(f'{node.id!r}: {obj!r} is not a present screw')
if state.held != obj:
return _replan(f'{node.id!r}: {obj!r} is not held')
if state.mounted != 'magnetic_tool':
return _replan(
f'{node.id!r}: handoff_screw requires magnetic_tool'
)
if facts.destinations.get(target) != 'hole':
return _replan(
f'{node.id!r}: handoff_screw target must be a hole'
)
return None
if skill in ('capture',):
if obj not in facts.cameras:
return _reject(f'{node.id!r}: unknown camera {obj!r}')
return None
def _apply_event(
node: PlanNode,
event: str,
state: _State,
facts: _Facts,
) -> _State:
skill = node.skill
obj = node.object
if skill == 'inspect_state' and event in ('empty', 'holding', 'placed', 'unknown'):
if event == 'empty':
return _State(state.mounted, None, False, state.transfer_target)
if event == 'holding':
return _State(state.mounted, obj or None, False, state.transfer_target)
if event == 'placed':
return _State(state.mounted, None, False, state.transfer_target)
return _State(state.mounted, state.held, True, state.transfer_target)
if event == 'grasp_failed':
return _State(state.mounted, state.held, True, state.transfer_target)
if event not in PROGRESS_EVENTS:
return state
if skill in ('grab', 'pick'):
if obj in facts.tools:
return _State(obj, state.held, False, state.transfer_target)
if obj == 'cap':
return _State(state.mounted, 'cap', False, state.transfer_target)
if obj and facts.components.get(obj) == 'screw':
return _State(state.mounted, obj, False, state.transfer_target)
return state
if skill == 'release':
if obj in facts.tools:
return _State(None, state.held, False, state.transfer_target)
if obj:
return _State(state.mounted, None, False, state.transfer_target)
return state
if skill == 'dock_tool':
return _State(None, state.held, False, state.transfer_target)
if skill == 'handoff_screw':
return _State(state.mounted, None, False, state.transfer_target)
if skill == 'transfer':
return _State(
state.mounted, state.held, False, node.target or state.transfer_target,
)
return state
def _ownership_checks(graph: PlanGraph, facts: _Facts) -> ValidationResult | None:
initial = facts.initial
seen: set[tuple[str, _State]] = set()
queue: list[tuple[str, _State]] = [(graph.entry, initial)]
visited_states: dict[str, set[_State]] = {node_id: set() for node_id in graph.ids}
while queue:
node_id, state = queue.pop()
if (node_id, state) in seen:
continue
seen.add((node_id, state))
node = graph.node(node_id)
failure = _precondition(node, state, facts)
if failure is not None:
failure.details.update({
'node_id': node.id,
'skill': node.skill,
'mounted_tool': state.mounted,
'held_object': state.held,
})
return failure
visited_states[node.id].add(state)
for transition in node.transitions:
if transition.next not in graph.ids:
continue
next_state = _apply_event(node, transition.event, state, facts)
if (transition.next, next_state) not in seen:
queue.append((transition.next, next_state))
return None
def validate_graph(
graph: PlanGraph,
scene: Mapping[str, Any],
catalog: SkillCatalog,
*,
expected_scene_revision: int | None = None,
) -> ValidationResult:
"""Return an admission decision; never mutates the graph or the scene."""
if graph.status == 'blocked':
return ValidationResult(
False, 'blocked', 'the model reported status=blocked; no motion',
{'status': graph.status},
)
if graph.status != 'ready':
return _reject(f'unsupported graph status {graph.status!r}')
if len(graph.nodes) > 256:
return _reject('graph exceeds the 256-node limit')
facts = _facts(scene)
if expected_scene_revision is not None:
revision = facts.envelope.get('scene_revision')
if revision is not None and int(revision) != int(expected_scene_revision):
return _reject(
f'graph was built for scene_revision {expected_scene_revision}, '
f'current revision is {revision}',
code='stale_scene',
)
structural = _structural_checks(graph, facts)
if structural is not None:
return structural
ownership = _ownership_checks(graph, facts)
if ownership is not None:
return ownership
return ValidationResult(
True,
'ok',
'graph accepted',
{
'nodes': len(graph.nodes),
'entry': graph.entry,
'scene_revision': facts.envelope.get('scene_revision'),
'available_skills': len(facts.available_skills),
},
)
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
<name>iiwa_llm_plan_executor</name>
<version>2026.11.10</version>
<description>Validate and execute externally supplied Qwen3 plan graphs in Webots</description>
<maintainer email="grabardm@ml-dev.ru">daniel</maintainer>
<license>Apache-2.0</license>
<buildtool_depend>ament_python</buildtool_depend>
<exec_depend>ament_index_python</exec_depend>
<exec_depend>geometry_msgs</exec_depend>
<exec_depend>iiwa_experement</exec_depend>
<exec_depend>iiwa_msgs</exec_depend>
<exec_depend>iiwa_scene_context</exec_depend>
<exec_depend>launch</exec_depend>
<exec_depend>launch_ros</exec_depend>
<exec_depend>moveit_msgs</exec_depend>
<exec_depend>moveit_py</exec_depend>
<exec_depend>python3-numpy</exec_depend>
<exec_depend>python3-scipy</exec_depend>
<exec_depend>python3-yaml</exec_depend>
<exec_depend>rclpy</exec_depend>
<exec_depend>sensor_msgs</exec_depend>
<exec_depend>std_msgs</exec_depend>
<exec_depend>std_srvs</exec_depend>
<exec_depend>tf2_ros</exec_depend>
<exec_depend>webots_ros2_msgs</exec_depend>
<test_depend>python3-pytest</test_depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>
@@ -0,0 +1 @@
iiwa_llm_plan_executor
+4
View File
@@ -0,0 +1,4 @@
[develop]
script_dir=$base/lib/iiwa_llm_plan_executor
[install]
install_scripts=$base/lib/iiwa_llm_plan_executor
+30
View File
@@ -0,0 +1,30 @@
from glob import glob
from setuptools import find_packages, setup
package_name = 'iiwa_llm_plan_executor'
setup(
name=package_name,
version='2026.11.10',
packages=find_packages(exclude=['test']),
data_files=[
('share/ament_index/resource_index/packages', ['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
('share/' + package_name + '/config', glob('config/*.yaml')),
('share/' + package_name + '/launch', glob('launch/*.py')),
('share/' + package_name + '/doc', glob('doc/*.md') + glob('docs/*.md')),
],
install_requires=['setuptools', 'PyYAML'],
tests_require=['pytest'],
zip_safe=True,
maintainer='daniel',
maintainer_email='grabardm@ml-dev.ru',
description='Validate and execute externally supplied Qwen3 plan graphs in Webots',
license='Apache-2.0',
entry_points={
'console_scripts': [
'llm_plan_executor = iiwa_llm_plan_executor.node:main',
],
},
)
@@ -0,0 +1,153 @@
"""Parsing tests for the externally supplied plan response."""
from __future__ import annotations
import json
import pytest
from iiwa_llm_plan_executor.schema import (
MAX_NODES,
PlanSchemaError,
parse_response,
)
def sample_graph():
return {
'status': 'ready',
'entry': 'p1_find',
'plan': [
{
'id': 'p1_find',
'skill': 'detect',
'object': 'cap',
'target': '',
'transitions': [
{'event': 'found', 'next': 'p1_grab'},
{'event': 'not_found', 'next': 'replan_required'},
{'event': 'failed', 'next': 'replan_required'},
],
},
{
'id': 'p1_grab',
'skill': 'grab',
'object': 'vacuum',
'target': '',
'transitions': [
{'event': 'success', 'next': 'completed'},
{'event': 'grasp_failed', 'next': 'operator_required'},
{'event': 'human_in_scene', 'next': 'operator_required'},
{'event': 'collision', 'next': 'operator_required'},
{'event': 'failed', 'next': 'replan_required'},
],
},
],
}
def test_shown_status_entry_plan_transitions_format_is_parsed():
graph = parse_response(json.dumps(sample_graph()))
assert graph.status == 'ready'
assert graph.entry == 'p1_find'
assert graph.ids == ('p1_find', 'p1_grab')
assert graph.node('p1_find').transitions[0].event == 'found'
assert graph.node('p1_grab').object == 'vacuum'
def test_top_level_transitions_are_merged_into_nodes():
payload = sample_graph()
payload['plan'][0].pop('transitions')
payload['transitions'] = [
{'from': 'p1_find', 'event': 'found', 'to': 'p1_grab'},
{'from': 'p1_find', 'event': 'not_found', 'to': 'replan_required'},
{'from': 'p1_find', 'event': 'failed', 'to': 'replan_required'},
]
graph = parse_response(payload)
events = {item.event for item in graph.node('p1_find').transitions}
assert events == {'found', 'not_found', 'failed'}
def test_blocked_response_has_no_plan():
graph = parse_response({'status': 'blocked', 'entry': '', 'plan': []})
assert graph.status == 'blocked'
assert graph.nodes == ()
def test_blocked_response_with_nodes_is_rejected():
with pytest.raises(PlanSchemaError):
parse_response({
'status': 'blocked',
'entry': 'a',
'plan': sample_graph()['plan'],
})
@pytest.mark.parametrize('payload', [
{'status': 'ready', 'entry': 'a', 'plan': []},
{'status': 'ready', 'entry': '', 'plan': sample_graph()['plan']},
{'status': 'unknown', 'entry': 'a', 'plan': sample_graph()['plan']},
{'status': 'ready', 'entry': 'a', 'plan': sample_graph()['plan'],
'extra': 1},
])
def test_invalid_top_level_shapes_are_rejected(payload):
with pytest.raises(PlanSchemaError):
parse_response(payload)
def test_duplicate_ids_are_rejected():
payload = sample_graph()
payload['plan'][1]['id'] = 'p1_find'
with pytest.raises(PlanSchemaError, match='duplicate node id'):
parse_response(payload)
def test_unknown_node_keys_and_transition_events_are_rejected():
payload = sample_graph()
payload['plan'][0]['pose'] = {'x': 1}
with pytest.raises(PlanSchemaError, match='unknown keys'):
parse_response(payload)
payload = sample_graph()
payload['plan'][0]['transitions'].append(
{'event': 'found', 'next': 'p1_grab'},
)
with pytest.raises(PlanSchemaError, match='duplicate event'):
parse_response(payload)
def test_reserved_terminal_ids_and_oversized_plans_are_rejected():
payload = sample_graph()
payload['plan'][0]['id'] = 'completed'
with pytest.raises(PlanSchemaError, match='reserved terminal'):
parse_response(payload)
nodes = []
for index in range(MAX_NODES + 1):
nodes.append({
'id': f'n{index}',
'skill': 'stop',
'object': 'robot',
'target': '',
'transitions': [
{'event': 'success', 'next': 'completed'},
{'event': 'failed', 'next': 'replan_required'},
],
})
with pytest.raises(PlanSchemaError, match='limit'):
parse_response({'status': 'ready', 'entry': 'n0', 'plan': nodes})
def test_params_are_strict_scalars():
payload = sample_graph()
payload['plan'][0]['params'] = {'start_fraction': 0.2, 'end_fraction': 1.0}
graph = parse_response(payload)
assert graph.node('p1_find').params['start_fraction'] == 0.2
payload['plan'][0]['params'] = {'bad': {'nested': 1}}
with pytest.raises(PlanSchemaError):
parse_response(payload)
def test_invalid_json_is_reported():
with pytest.raises(PlanSchemaError, match='not valid JSON'):
parse_response('{not json')
@@ -0,0 +1,412 @@
"""Graph admission: structure, safe branches, ownership and scene revision."""
from __future__ import annotations
from pathlib import Path
import pytest
from iiwa_experement.scenario import load_scenario
from iiwa_llm_plan_executor.schema import PlanSchemaError, parse_response
from iiwa_llm_plan_executor.validator import validate_graph
from iiwa_scene_context.catalog import DEFAULT_CATALOG
from iiwa_scene_context.snapshot import build_envelope, build_scene
PACKAGE = Path(__file__).resolve().parents[1]
SCENES = PACKAGE.parent / 'iiwa_experement' / 'config' / 'scenes.yaml'
SAFE_DEFAULTS = {
'collision': 'operator_required',
'grasp_failed': 'operator_required',
'human_in_scene': 'operator_required',
'not_found': 'replan_required',
'failed': 'replan_required',
'not_ok': 'operator_required',
'timeout': 'operator_required',
'unknown': 'operator_required',
}
PROGRESS = {'success', 'ok', 'found', 'clear', 'empty', 'holding', 'placed'}
def spec_for(experiment_id: int = 1):
return load_scenario(experiment_id, SCENES)
def scene_for(experiment_id: int = 1, *, mounted=None, held=None,
cap_state=None, revision=1):
spec = spec_for(experiment_id)
tools = {tool.name: {'state': 'docked'} for tool in spec.tools}
if mounted:
tools[mounted]['state'] = 'mounted'
scene = build_scene(
spec,
{
'version': 1,
'mounted_tool': mounted,
'held_object': held,
'ready': True,
'cap': {'state': cap_state or spec.cap_state},
'tools': tools,
'screws': {},
'holes': {},
},
DEFAULT_CATALOG,
)
return build_envelope(
scene,
scene_revision=revision,
state_version=1,
stamp=10.0,
experiment_id=experiment_id,
)
def node(node_id, skill, obj='', target='', *, next_on=None, params=None,
outcomes=None):
events = outcomes or DEFAULT_CATALOG.outcomes(skill)
next_on = next_on or {}
transitions = []
for event in events:
if event in next_on:
destination = next_on[event]
elif event in PROGRESS:
destination = 'completed'
else:
destination = SAFE_DEFAULTS.get(event, 'operator_required')
transitions.append({'event': event, 'next': destination})
entry = {
'id': node_id,
'skill': skill,
'object': obj,
'target': target,
'transitions': transitions,
}
if params is not None:
entry['params'] = params
return entry
def graph(entry_id, *nodes):
return parse_response({
'status': 'ready',
'entry': entry_id,
'plan': list(nodes),
})
def expect(graph_value, scene, *, expected_revision=None):
return validate_graph(
graph_value,
scene,
DEFAULT_CATALOG,
expected_scene_revision=expected_revision,
)
def test_blocked_status_never_reaches_execution():
result = expect(
parse_response({'status': 'blocked', 'entry': '', 'plan': []}),
scene_for(),
)
assert result.ok is False
assert result.code == 'blocked'
def test_cap_grab_without_explicit_vacuum_is_replan_required_before_motion():
value = graph(
'grab_cap',
node('grab_cap', 'grab', 'cap', next_on={'success': 'completed'}),
)
result = expect(value, scene_for(1))
assert result.ok is False
assert result.code == 'replan_required'
assert 'vacuum' in result.reason
def test_take_vacuum_and_leave_it_in_patron_is_accepted():
value = graph(
'grab_vacuum',
node('grab_vacuum', 'grab', 'vacuum',
next_on={'success': 'transfer'}),
node('transfer', 'transfer', 'vacuum', 'work',
next_on={'success': 'completed'}),
)
result = expect(value, scene_for(1))
assert result.ok is True
def test_release_from_work_without_a_socket_is_rejected():
value = graph(
'grab_vacuum',
node('grab_vacuum', 'grab', 'vacuum',
next_on={'success': 'transfer'}),
node('transfer', 'transfer', 'vacuum', 'work',
next_on={'success': 'release'}),
node('release', 'release', 'vacuum', '',
next_on={'success': 'completed'}),
)
result = expect(value, scene_for(1))
assert result.ok is False
assert result.code == 'replan_required'
def test_release_at_socket_requires_a_preceding_explicit_transfer():
value = graph(
'grab_vacuum',
node('grab_vacuum', 'grab', 'vacuum',
next_on={'success': 'transfer'}),
node('transfer', 'transfer', 'vacuum', 'work',
next_on={'success': 'release'}),
node('release', 'release', 'vacuum', 'vacuum_socket',
next_on={'success': 'completed'}),
)
result = expect(value, scene_for(1))
assert result.code == 'replan_required'
assert 'transfer' in result.reason
def test_explicit_transfer_then_dock_or_release_returns_the_tool():
dock = graph(
'grab_vacuum',
node('grab_vacuum', 'grab', 'vacuum',
next_on={'success': 'transfer'}),
node('transfer', 'transfer', 'vacuum', 'vacuum_socket',
next_on={'success': 'dock'}),
node('dock', 'dock_tool', 'vacuum', 'vacuum_socket',
next_on={'success': 'completed'}),
)
assert expect(dock, scene_for(1)).ok is True
release = graph(
'grab_vacuum',
node('grab_vacuum', 'grab', 'vacuum',
next_on={'success': 'transfer'}),
node('transfer', 'transfer', 'vacuum', 'vacuum_socket',
next_on={'success': 'release'}),
node('release', 'release', 'vacuum', 'vacuum_socket',
next_on={'success': 'completed'}),
)
assert expect(release, scene_for(1)).ok is True
def test_dock_tool_without_the_explicit_transfer_is_rejected():
value = graph(
'grab_vacuum',
node('grab_vacuum', 'grab', 'vacuum',
next_on={'success': 'dock'}),
node('dock', 'dock_tool', 'vacuum', 'vacuum_socket',
next_on={'success': 'completed'}),
)
assert expect(value, scene_for(1)).code == 'replan_required'
def test_collision_must_not_continue_the_operation():
value = graph(
'approach',
node('approach', 'approach', 'cap',
next_on={'success': 'completed', 'collision': 'completed'}),
)
result = expect(value, scene_for(1))
assert result.ok is False
assert 'collision' in result.reason
def test_grasp_failed_cannot_map_to_success():
value = graph(
'grab_vacuum',
node('grab_vacuum', 'grab', 'vacuum',
next_on={'success': 'completed', 'grasp_failed': 'completed'}),
)
result = expect(value, scene_for(1))
assert result.ok is False
assert 'grasp_failed' in result.reason
def test_structural_errors_are_rejected():
scene = scene_for(1)
unknown_skill = {
'status': 'ready', 'entry': 'a',
'plan': [{
'id': 'a', 'skill': 'approval', 'object': 'cap', 'target': '',
'transitions': [
{'event': 'success', 'next': 'completed'},
{'event': 'failed', 'next': 'replan_required'},
],
}],
}
assert expect(parse_response(unknown_skill), scene).ok is False
assert expect(
graph('a', node('a', 'detect', 'ghost')), scene,
).ok is False
assert expect(
graph('a', node('a', 'detect', 'cap', 'ghost')), scene,
).ok is False
assert expect(
graph('missing', node('a', 'detect', 'cap')), scene,
).ok is False
unreachable = graph(
'a',
node('a', 'detect', 'cap'),
node('b', 'detect', 'cap'),
)
assert expect(unreachable, scene).ok is False
no_events = {
'status': 'ready', 'entry': 'a',
'plan': [{
'id': 'a', 'skill': 'detect', 'object': 'cap', 'target': '',
'transitions': [],
}],
}
assert expect(parse_response(no_events), scene).ok is False
missing_event = {
'status': 'ready', 'entry': 'a',
'plan': [{
'id': 'a', 'skill': 'detect', 'object': 'cap', 'target': '',
'transitions': [{'event': 'found', 'next': 'completed'}],
}],
}
assert expect(parse_response(missing_event), scene).ok is False
bad_next = {
'status': 'ready', 'entry': 'a',
'plan': [node('a', 'detect', 'cap',
next_on={'found': 'ghost'})],
}
assert expect(parse_response(bad_next), scene).ok is False
def test_unknown_tighten_is_rejected_before_motion():
payload = {
'status': 'ready', 'entry': 'a',
'plan': [{
'id': 'a', 'skill': 'tighten', 'object': 'hole_1', 'target': '',
'transitions': [
{'event': 'success', 'next': 'completed'},
{'event': 'failed', 'next': 'replan_required'},
],
}],
}
result = expect(parse_response(payload), scene_for(1))
assert result.ok is False
assert 'unavailable skill' in result.reason
def test_cycles_are_bounded_and_must_keep_a_recovery_branch():
dead_cycle = graph(
'a',
node('a', 'wait_scene_clear', 'workspace',
next_on={'clear': 'b', 'timeout': 'b', 'failed': 'b'}),
node('b', 'wait_scene_clear', 'workspace',
next_on={'clear': 'a', 'timeout': 'a', 'failed': 'a'}),
)
assert expect(dead_cycle, scene_for(1)).ok is False
recovering_cycle = graph(
'a',
node('a', 'wait_scene_clear', 'workspace',
next_on={'clear': 'b'}),
node('b', 'wait_scene_clear', 'workspace',
next_on={'clear': 'a'}),
)
assert expect(recovering_cycle, scene_for(1)).ok is True
def test_wrong_scene_revision_is_rejected():
value = graph('a', node('a', 'detect', 'cap'))
result = expect(value, scene_for(1, revision=3), expected_revision=2)
assert result.ok is False
assert result.code == 'stale_scene'
def test_aliases_for_pick_move_to_storage_and_inspect_are_supported():
value = graph(
'grab_vacuum',
node('grab_vacuum', 'grab', 'vacuum',
next_on={'success': 'pick_cap'}),
node('pick_cap', 'pick', 'cap', next_on={'success': 'transfer'}),
node('transfer', 'transfer', 'cap', 'hatch_seat',
next_on={'success': 'release'}),
node('release', 'release', 'cap', 'hatch_seat',
next_on={'success': 'inspect'}),
node('inspect', 'inspect', 'cap', 'hatch_seat',
next_on={'ok': 'completed'}),
)
assert expect(value, scene_for(1)).ok is True
bad = graph('move', node(
'move', 'move_to_storage', 'body', 'work',
next_on={'success': 'completed'},
))
assert expect(bad, scene_for(1)).ok is False
def test_screw_handoff_is_to_the_operator_and_leaves_the_hole_empty():
value = graph(
'grab_magnet',
node('grab_magnet', 'grab', 'magnetic_tool',
next_on={'success': 'pick_screw'}),
node('pick_screw', 'pick', 'screw_1',
next_on={'success': 'transfer'}),
node('transfer', 'transfer', 'screw_1', 'hole_1',
next_on={'success': 'handoff'}),
node('handoff', 'handoff_screw', 'screw_1', 'hole_1',
next_on={'success': 'completed'}),
)
result = expect(value, scene_for(20))
assert result.ok is True
def test_dispense_requires_the_sealant_tool():
without = graph(
'dispense', node('dispense', 'dispense', 'side_1',
next_on={'success': 'completed'}),
)
assert expect(without, scene_for(20)).code == 'replan_required'
with_tool = graph(
'grab_sealant',
node('grab_sealant', 'grab', 'sealant_tool',
next_on={'success': 'dispense'}),
node('dispense', 'dispense', 'side_1',
next_on={'success': 'completed'}),
)
assert expect(with_tool, scene_for(20)).ok is True
def test_params_limit_dispense_fractions():
value = graph(
'grab_sealant',
node('grab_sealant', 'grab', 'sealant_tool',
next_on={'success': 'dispense'}),
node('dispense', 'dispense', 'side_1',
next_on={'success': 'completed'},
params={'start_fraction': 0.0, 'end_fraction': 1.0}),
)
assert expect(value, scene_for(20)).ok is True
payload = {
'status': 'ready', 'entry': 'dispense',
'plan': [node('dispense', 'dispense', 'side_1',
next_on={'success': 'completed'},
params={'unknown': 1.0})],
}
result = expect(parse_response(payload), scene_for(20))
assert result.ok is False
assert 'params' in result.reason
def test_unconfirmed_grasp_requires_inspect_state():
value = graph(
'grab_vacuum',
node('grab_vacuum', 'grab', 'vacuum',
next_on={'success': 'transfer', 'grasp_failed': 'stop'}),
node('stop', 'stop', 'robot',
next_on={'success': 'transfer'}),
node('transfer', 'transfer', 'vacuum', 'work',
next_on={'success': 'completed'}),
)
result = expect(value, scene_for(1))
assert result.ok is False
assert 'inspect_state' in result.reason
def test_parse_errors_are_raised_not_smuggled_into_a_graph():
with pytest.raises(PlanSchemaError):
parse_response('[]')