feat(llm-plan-executor): parse and validate externally supplied graphs
This commit is contained in:
@@ -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),
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
[develop]
|
||||
script_dir=$base/lib/iiwa_llm_plan_executor
|
||||
[install]
|
||||
install_scripts=$base/lib/iiwa_llm_plan_executor
|
||||
@@ -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('[]')
|
||||
Reference in New Issue
Block a user