-
Notifications
You must be signed in to change notification settings - Fork 1
Adding PDDL Features
This page serves as a tutorial on extending PDDLSIM, as a contributor, to support new PDDL features, which is naturally a process involving many different parts of PDDLSIM. Very roughly, most new PDDL features in PDDLSIM end up touching:
- The
pddlsim.astmodule, to define how that feature will be stored in PDDLSIM's domain and problem representation - The
grammar.larkfile, to define the syntax for that feature - The
pddlsim.parsermodule, to define how the parser should create the AST items relevant to the feature - The
pddlsim.simulationmodule, to define the execution semantics of the feature
In this tutorial, we will be adding a new kind of condition to PDDLSIM: Exclusive-or, or xor conditions. We will show the necessary changes to the AST (including validation code), parser, grammar, and simulation code, as well as the addition of a new PDDL requirement (:exclusive-preconditions).
Before we continue to adding this feature to PDDLSIM, we must first know what this feature looks like. :exclusive-preconditions is a requirement that will be usable by domains, once specified, allowing them to use Exclusive Or (xor) in preconditions and goal conditions. As a connective, xor takes an arbitrary amount of arguments. xor is considered true when summing the truthiness of each of its arguments, we get 0 mod 2, and false otherwise. For two operands, this is the classic XOR from boolean algebra.
Like other connectives, the full syntax of xor is:
(xor <CONDITION>*)
where <CONDITION> is a condition in PDDL. Without any arguments ((xor)), we consider XOR to be false.
Looking in the pddlsim.ast module, all conditions are specified in the Condition[A: Argument] type alias:
type Condition[A: Argument] = (
AndCondition[A]
| OrCondition[A]
| NotCondition[A]
| EqualityCondition[A]
| Predicate[A]
)
"""Represents a condition in PDDL, over objects, or variables."""What we want to do, ultimately, is to extend that alias. Looking at other condition classes, let's name our condition XORCondition. It's going to have a similar form to and and or (as it takes an arbitrary amount of arguments), so lets place it next to them:
type Condition[A: Argument] = (
AndCondition[A]
| OrCondition[A]
| XORCondition[A]
| NotCondition[A]
| EqualityCondition[A]
| Predicate[A]
)
"""Represents a condition in PDDL, over objects, or variables."""Of course, now that we have done this, we need to provide an actual definition for XORCondition[A]. Let's look at AndCondition[A]'s code for inspiration, as we expect them to be fairly similar:
@dataclass(frozen=True)
class AndCondition[A: Argument]:
"""Represents a conjunction (`and`) of PDDL conditions."""
subconditions: list["Condition[A]"]
"""The subconditions being conjuncted."""
def _validate(
self,
parameters: Parameters,
objects: Mapping[Object, Type],
domain: "Domain",
) -> None:
for subcondition in self.subconditions:
subcondition._validate(parameters, objects, domain)
@override
def __repr__(self) -> str:
return f"(and {' '.join(map(repr, self.subconditions))})"Indeed, this is fairly simple! It just stores a list of subconditions. For and, all of them must be true for the condition to be considered true, but of course, in our case, things are a bit different. Regardless, this won't impact things much at the AST level, which is just about storing and validating data. Let's now make our XORCondition[A], placing it near the other condition type definitions:
@dataclass(frozen=True)
class XORCondition[A: Argument]:
"""Represents an exclusive disjunction (`xor`) of PDDL conditions."""
subconditions: list["Condition[A]"]
"""The subconditions being exclusively disjuncted."""
def _validate(
self,
parameters: Parameters,
objects: Mapping[Object, Type],
domain: "Domain",
) -> None:
for subcondition in self.subconditions:
subcondition._validate(parameters, objects, domain)
@override
def __repr__(self) -> str:
return f"(xor {' '.join(map(repr, self.subconditions))})"A simple name change, and some minor edits were all that we needed... Or were they? Looking at the definition of OrCondition[A] for example, we see a different story:
@dataclass(frozen=True)
class OrCondition[A: Argument](_Locationed):
"""Represents a disjunction (`or`) of PDDL conditions."""
subconditions: list["Condition[A]"]
"""The subconditions being disjuncted."""
def _validate(
self,
parameters: Parameters,
objects: Mapping[Object, Type],
domain: "Domain",
) -> None:
if (
Requirement.DISJUNCTIVE_PRECONDITIONS
not in domain.requirements_section
):
raise ValueError(
f"{self} used in condition, but `{Requirement.DISJUNCTIVE_PRECONDITIONS}` is not in {domain.requirements_section}" # noqa: E501
)
for subcondition in self.subconditions:
subcondition._validate(parameters, objects, domain)
@override
def _as_str_without_location(self) -> str:
return "disjunction"
@override
def __repr__(self) -> str:
return f"(or {' '.join(map(repr, self.subconditions))})"As one can see, there is an extra addition to the validation code, to make sure the :disjunctive-preconditions requirement is specified on usage of or-conditions. This makes sense, as these are indeed behind a requirement. Of course, such is the case for our XOR-conditions! We will add that to XORCondition[A] soon, but before that, notice another thing: OrCondition[A] actually inherits from _Locationed. This is as or-conditions are sometimes printed in validation errors (as they are behind a requirement), and so attaching to them information on where they came from in the file is useful for end-users. This is also the reason for the _as_str_without_location implementation for OrCondition[A], as well as how in the raise ValueError(...) part, {self} is used in the f-string: the result of that would look like disjunction (X:Y), as disjunction is the output of _as_str_without_location, and OrConditions __str__ implementation is inherited from _Locationed, and wraps it with location information (X would be the line number, and Y, the column number).
With all of this in mind, let's modify our original implementation:
@dataclass(frozen=True)
class XORCondition[A: Argument](_Locationed):
"""Represents an exclusive disjunction (`xor`) of PDDL conditions."""
subconditions: list["Condition[A]"]
"""The subconditions being exclusively disjuncted."""
def _validate(
self,
parameters: Parameters,
objects: Mapping[Object, Type],
domain: "Domain",
) -> None:
# New!
if (
Requirement.EXCLUSIVE_PRECONDITIONS
not in domain.requirements_section
):
raise ValueError(
f"{self} used in condition, but `{Requirement.EXCLUSIVE_PRECONDITIONS}` is not in {domain.requirements_section}" # noqa: E501
)
for subcondition in self.subconditions:
subcondition._validate(parameters, objects, domain)
# New!
@override
def _as_str_without_location(self) -> str:
return "exclusive disjunction"
@override
def __repr__(self) -> str:
return f"(xor {' '.join(map(repr, self.subconditions))})"Of course, we are referencing a Requirement.EXCLUSIVE_PRECONDITIONS requirement, but that requirement is not defined in the Requirement enumeration. Let's add it:
class Requirement(StrEnum):
"""Represents a PDDL requirement (with extensions)."""
STRIPS = ":strips"
"""Doesn't add any new features and is assumed."""
TYPING = ":typing"
"""Allows to specify types for objects, and impose types on variables."""
DISJUNCTIVE_PRECONDITIONS = ":disjunctive-preconditions"
"""Allows usage of `or` in conditions."""
NEGATIVE_PRECONDITIONS = ":negative-preconditions"
"""Allows usage of `not` in conditions."""
# New!
EXCLUSIVE_PRECONDITIONS = ":exclusive-preconditions"
"""Allows usage of `xor` in conditions."""
EQUALITY = ":equality"
"""Allows usage of `=` predicates."""
PROBABILISTIC_EFFECTS = ":probabilistic-effects"
"""Allows usage of probabilistic effects."""
FALLIBLE_ACTIONS = ":fallible-actions"
"""Allows specifying [action fallibilities](https://github.com/galk-research/pddlsim/wiki/Fallible-Actions)."""
REVEALABLES = ":revealables"
"""Allows problems to use [revealables](https://github.com/galk-research/pddlsim/wiki/Revealables)."""
MULTIPLE_GOALS = ":multiple-goals"
"""Allows problems specify [multiple goals](https://github.com/galk-research/pddlsim/wiki/Multiple-Goals)."""
@override
def __repr__(self) -> str:
return self.valueThis concludes our modification to the AST. We added a new kind of condition (XORCondition[A]), created validation code for it, and with that code, added a new requirement for domains: Requirement.EXCLUSIVE_PRECONDITIONS.
As a reminder, the grammar for PDDLSIM's parser is located in the grammar.lark file. Understanding Lark's syntax for grammar specification is beyond the scope of this tutorial, but this is a nice resource. For more general information on Lark, the library used for parsing in PDDLSIM, see this.
Note
Beyond this point, a basic working knowledge of Lark, EBNF, and parsing, is assumed.
Now, since we only want to modify the part in the grammar responsible for conditions, searching for condition would be our best bet, and indeed, the right choice. Conditions are defined in this area of the grammar file:
// We need the location of or-conditions in case `:disjunctive-preconditions` is not enabled
OR_KEYWORD: "or"
// We need the location of not-conditions in case `:negation-preconditions` is not enabled
NOT_KEYWORD: "not"
// We need the location of equality predicates in case `:equality` is not enabled
EQUALS_SIGN: "="
?condition{argument}: "(" "and" list_{condition{argument}} ")" -> and_condition
| "(" OR_KEYWORD list_{condition{argument}} ")" -> or_condition
| "(" NOT_KEYWORD condition{argument} ")" -> not_condition
| "(" EQUALS_SIGN argument argument ")" -> equality_condition
| predicate{argument}From this, it is pretty clear how to add our new kind of condition to the grammar, but just to clarify a few things: Since our condition is like =, not, and or conditions, we will need to define a XOR_KEYWORD above. Later on, we will see how doing things this way allows us to attach location information to our XOR-conditions, utilizing the inheritance from _Locationed for XORCondition[A]. The second thing to mention, is the arrows following each condition kind in the condition{argument} rule: these tell Lark to pass the outputs of these subrules to the transformer function named after the arrow (for example, for and-conditions, this would be and_condition). These rules are used to slowly build the AST from the parser's output from the outputs of other rules. With all of this in mind, let's add our XOR-conditions to the grammar:
// We need the location of or-conditions in case `:disjunctive-preconditions` is not enabled
OR_KEYWORD: "or"
// New!
// We need the location of xor-conditions in case `:exclusive-preconditions` is not enabled
XOR_KEYWORD: "xor
// We need the location of not-conditions in case `:negation-preconditions` is not enabled
NOT_KEYWORD: "not"
// We need the location of equality predicates in case `:equality` is not enabled
EQUALS_SIGN: "="
?condition{argument}: "(" "and" list_{condition{argument}} ")" -> and_condition
| "(" OR_KEYWORD list_{condition{argument}} ")" -> or_condition
// New!
| "(" XOR_KEYWORD list_{condition{argument}} ")" -> xor_condition
| "(" NOT_KEYWORD condition{argument} ")" -> not_condition
| "(" EQUALS_SIGN argument argument ")" -> equality_condition
| predicate{argument}After adding our conditions, let's also not forget to add our new requirement. Specifically, we will add it to the domain_requirement rule:
domain_requirement: ":strips" -> strips_requirement
| ":typing" -> typing_requirement
| ":disjunctive-preconditions" -> disjunctive_preconditions_requirement
// New!
| ":exclusive-preconditions" -> exclusive_preconditions_requirement
| ":negative-preconditions" -> negative_preconditions_requirement
| ":equality" -> equality_requirement
| ":probabilistic-effects" -> probabilistic_effects_requirementThis concludes our changes to the grammar, which thanks to its and Lark's declarativity, are fairly minor and self-contained. With these in mind, and remembering the names for our transformer functions, let's carry on to extending the parser module.
The AST builder is located in pddlsim.parser, under the name _PDDLTransformer, as is customary for Lark-based systems. Our changes will be entirely self-contained to it. As one can see from some of _PDDLTransformers code, it essentially consists of a large set of functions, some of which having names we have already spotted in grammar.lark. These functions essentially describe how to construct the output of each rule in the grammar, from the output of its children.
Therefore, we will need to add three functions to _PDDLTransformer: exclusive_preconditions_requirement, XOR_KEYWORD (remember that?), and xor_condition. Beginning with the requirement, which is the simplest, we have:
def exclusive_preconditions_requirement(self) -> Requirement:
return Requirement.EXCLUSIVE_PRECONDITIONSAs that rule only consists of a string, there are no parameters.
Moving on now, we have XOR_KEYWORD. The reason we defined it in all-uppercase, is that this makes it a "token", meaning that our input to this transformer function will be the token of the xor keyword, as found in the text. This is useful to us, as Lark's tokens contain location information. This is in fact the trick used in PDDLSIM to attach location information to different items: Either define them as tokens, like IDENTIFIER, or attach to them location information obtained from related tokens, like for disjunctive conditions. Now, like for all other <X>_KEYWORD functions, here is the definition for XOR_KEYWORD:
def XOR_KEYWORD(self, token: Token) -> Location: # noqa: N802
return FileLocation._from_token(token)The noqa comment here is to avoid issues from linters complaining that this function name does not follow PEP8.
Finally, let's implement xor_condition. Looking back to its definition in the grammar:
"(" XOR_KEYWORD list_{condition{argument}} ")"and given that XOR_KEYWORD returns a Location, we should expect to receive two parameters, of types Location and list[Condition[A]] respectively (where A: Argument). With that knowledge, here is xor_conditions implementation:
def xor_condition[A: Argument](
self, location: Location, operands: list[Condition[A]]
) -> XorCondition[A]:
return XorCondition(operands, location=location)The location keyword-argument was added to XorCondition's constructor as it inherits from _Locationed, which is a dataclass with a location field that must be given as a keyword argument. This avoids field-ordering issues with dataclass inheritance.
This concludes the changes necessary to the pddlsim.parser module. Now, it is time for home stretch: extending the simulation code.
Normally, changes to the simulation code would affect the pddlsim.simulation module, but if you look there, you won't find anything related to the explicit handling of conditions, preconditions and otherwise. In Simulation.apply_grounded_action, you will however find the part that validates the action's precondition holds:
if self.state.does_condition_hold(_ground_condition(action_definition.precondition, grounding)):
...
else:
raise ValueError(...)In other words, a call to SimulationState.does_condition_hold. Therefore, as one can guess, we will actually be modifying the pddlsim.state module, and specifically, the SimulationState.does_condition_hold method, which in fact would currently yield errors from static analysis tools, as the XorCondition[A] variant of Condition[A] is not handled. Let's handle this variant:
def does_condition_hold(self, condition: Condition[Object]) -> bool:
"""Check if the given grounded condition holds in the state."""
match condition:
case AndCondition(subconditions):
return all(
self.does_condition_hold(subcondition)
for subcondition in subconditions
)
case OrCondition(subconditions):
return any(
self.does_condition_hold(subcondition)
for subcondition in subconditions
)
# New!
case XorCondition(subconditions):
return sum(
self.does_condition_hold(subcondition)
for subcondition in subconditions
) & 1 == 1
case NotCondition(base_condition):
return not (self.does_condition_hold(base_condition))
case EqualityCondition(left_side, right_side):
return left_side == right_side
case Predicate():
return self._does_atom_hold(condition)Python booleans are subclasses of int, and so we can simply run sum on them, returning the number of true subconditions. We then just make sure the number of true subconditions is odd, with x & 1 == 1. With this implementation here, we conclude our tutorial. We have extended PDDLSIM with a new PDDL feature. Naturally, for different kinds of features, more extensive, or different work, may be needed, but we hope this tutorial helps make the overall process clearer. Finally, before even considering to implement new features: consider if they are really necessary, and flesh-out most of their details. Often, a feature will first be discussed, and only once consensus will be reached, will implementation begin. The main exception to this are implementing standard PDDL features (e.g., :existential-preconditions).