Execution layer · eplansys · ROS 2
A planner that reasons about knowledge is only half of an autonomous system. The other half has to execute what it returns, and what it returns is not a sequence of actions but a policy, branching on what the robot turns out to observe. This page reports the execution layer built for that: the shape a policy takes on the wire, the behavior tree that runs it, and the epistemic state the tree consults while it does.
Implemented in eplansys, on top of PlanSys2. Technical report (PDF)
The mismatch
PlanSys2 executes a plan by rendering it as a behavior tree: one subtree per action, in order, each guarding its action with the PDDL conditions around it. Nothing about that is wrong. It is exactly right for a plan that commits to one future. Every assumption in it fails for a plan that does not.
A plan is a sequence. plansys2_msgs/Plan is a flat list of timed items, so item \(i\) is followed by item \(i+1\). A policy for a partially observable domain is a tree: after a sensing action, which action comes next depends on what was seen. Flattening it keeps one contingency and silently assumes it happens.
An action has one name. The grounder writes an action as a single token, pickup-A-hold_r2, while the executor splits (pickup r2 A) into a name and parameters and looks the name up in the PDDL domain to find the behavior tree that drives the hardware. These are two vocabularies, not two spellings of one, and an untranslated name matches nothing: the plan arrives, and the robot does not move.
A precondition is a fact. The problem expert holds what is true, and answers whether a PDDL condition holds. The corridor is clear lives there. r1 knows whether the corridor is clear is not a fact about the corridor at all; no set of predicates records it, and nothing in a classical stack can be asked.
The policy on the wire
Two interfaces stand between the planner and the executor and both take a flat plan: the solver plugin returns one, and the tree builder consumes one. Rather than fork either, the policy travels in additional fields of the existing message, so that the two components keep exchanging the same type.
| Field on PlanItem | What it carries |
|---|---|
| children | one continuation per possible outcome, by item index |
| outcomes | the observation selecting each, in the same order |
| sensing | whether the outcome must be observed at all |
| knowledge_requirements | the epistemic conditions the action needs |
| epistemic_action | the action's name in the planner's own vocabulary |
A classical planner sets none of them, and such a plan is read exactly the way PlanSys2 writes it. So every plan is a policy here, and the distinction that matters is whether the policy branches. That reading is plan-wide and not per item: once any node names a continuation, a node without one ends its branch instead of falling through to the next item, which would be a different plan entirely.
One subtlety cost real work. The executor keys its table of running actions by action expression and start time in milliseconds. Two branches running the same action at the same time would collide onto one entry and drive one action executor from two places, invisible until the day the second branch is taken, because branches are alternatives that never both run. Times are therefore assigned uniquely across the whole policy, each node strictly after its parent ends.
The behavior tree
Each policy node renders as the PlanSys2 action subtree, unchanged, the same nodes driving the same action performers against the same problem expert, wrapped in what a sequence cannot express.
| Node | PlanSys2 counterpart | What it adds |
|---|---|---|
| CheckKnowledge | CheckOverAllReq | conditions about knowledge, which no predicate records |
| ApplyEpistemicUpdate | ApplyAtEndEffect | the DEL product update, one level above the effects |
| EpistemicSwitch | none | runs the continuation planned for what was observed |
| CheckEpistemicGoal | the executor's goal check | asks whether the goal holds, and does not assume assuming a leaf implies it |
Two of these deserve their reasoning stated. An action changes what agents know by more than its own effects. A public announcement informs everyone who was listening, and an observation that finds nothing still rules worlds out, which is why the update is a distinct node and not part of applying an effect.
And the switch fails on an outcome the policy does not list, and does not default to a branch. Every branch was built for a different belief; running one anyway is a robot acting confidently on a belief nothing supports. The failure propagates to the executor, whose answer to a failed plan is to replan, from the state that actually resulted.
Every leaf of a policy was believed to satisfy the goal, but that belief was formed against the model at planning time. If execution diverged from it, a policy can run to completion in a branch that no longer establishes anything. The goal check runs once, after the tree, against the state that resulted.
The epistemic state
The tree needs something to ask. A lifecycle node holds a pointed Kripke model \(\mathcal{M} = (W, \{R_i\}, V, W^{*})\) and answers three questions, which are exactly the three that executing a policy raises.
| Service | Question |
|---|---|
| load_task | be the model of this grounded task |
| check_formula | does (K r1 (clear corridor)) hold now? |
| apply_action | this action ran: update, and say what was observed |
It advances by executed actions and not by watching the world, which is what makes it a belief state and not a log. When it disagrees with what the robot observed, the disagreement surfaces at apply_action as an outcome the model cannot account for, a reason to replan, not to overwrite the model quietly.
Which outcome occurred is a question about the world, not about the model. When the state designates a single world it already answers it. When it designates several it genuinely does not know, and the observation has to come from whoever did the sensing.
# A must come to know whether the coin lies tails. It starts not knowing. check_formula (Kw A tails) → holds=False # The box is shut, so looking is not yet possible. The guard says so. apply_action peek_A → refused: not applicable in the current epistemic state apply_action open_A → ok · 4 worlds, 2 designated # Now A can look. But the model designates two candidate worlds, so it # cannot know which outcome the world produced, and it declines to guess. apply_action peek_A → refused: 2 possible outcomes here and none was observed # The robot reports what it saw. The model narrows to one world. apply_action peek_A e-peek-pos → ok · outcome=e-peek-pos 6 worlds, 1 designated check_formula (Kw A tails) → holds=True # A looked check_formula (Kw B tails) → holds=False # B was not looking
Structure
Extending an executor usually means depending on it. That was worth avoiding: a package that needs plansys2_executor can only be built against this fork, while one that does not can be built and tested against a released distribution.
| Package | Contents | Needs the executor |
|---|---|---|
| plansys2_epistemic_msgs | the three service definitions | no |
| plansys2_epistemic_executor | policy, tree rendering, the four nodes, the state | no |
| plansys2_epistemic_bt_builder | the tree-builder plugin | yes |
The nodes reach the executor through a generic hook and not a special case: a parameter naming behavior tree node libraries to load before a tree is built, and the plan already published on the blackboard. Twenty-four lines in the executor, no new dependencies, and nothing in it that knows what an epistemic node is.
# the whole configuration, on top of the ordinary PlanSys2 params executor: ros__parameters: bt_builder_plugin: "EpistemicBTBuilder" bt_node_plugins: ["libplansys2_epistemic_bt_nodes.so"]
Verification
| Package | Tests | Failures |
|---|---|---|
| plansys2_epistemic_planner | 38 | 0 |
| plansys2_epistemic_executor | 39 | 0 |
| plansys2_epistemic_msgs | 5 | 0 |
| from a clean build of six packages | 82 | 0 |
Three of these are worth naming. Every rendered tree is handed to the real BehaviorTree.CPP parser, because a tree that will not parse is worthless however good it looks. The formula written by the planner is parsed back by the state and compared as a formula, not as a string. The two are separate processes with separate interning, and nothing but that agreement connects them. And the node library is loaded exactly as the executor loads it, so the extension mechanism is checked.
Not checked here: the four nodes ticking against a live state node under a full stack, and the builder inside a running executor. Both need the whole PlanSys2 system up, which this fork's sources cannot build on the distribution available locally; they are exercised in continuous integration. The live trace above was produced against the real state node, driven by hand.
The observation a sensing action produces is supplied on a port that a domain-specific tree binds to whatever its performer reports. Wiring that to real perception, so that the branch a robot takes is chosen by its sensors and not by a model that happens to designate one world, is the next step, and the one that turns this from an architecture into a demonstration.
See it run
Everything above is the architecture: the policy on the wire, the tree that runs it, the epistemic state it consults. The six-room survey is that layer executing a mission end to end, with the knowledge coming from a map the robot builds with its own laser while it drives.
An EPDDL mission over six rooms, a policy that branches on an observation not yet made, and a recorded run in which the branch is decided by what the occupancy grid turns out to say.