Skip to content

Adding PDDL Features

Yoav Grimland edited this page May 23, 2025 · 4 revisions

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.ast module, to define how that feature will be stored in PDDLSIM's domain and problem representation
  • The grammar.lark file, to define the syntax for that feature
  • The pddlsim.parser module, to define how the parser should create the AST items relevant to the feature
  • The pddlsim.simulation module, 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).

Step 0: What exactly is this feature?

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.

Step 1: Modifying the AST

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]       # New!
    | 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:

# New!
@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.value

This 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.

Step 2: Modifying the grammar

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_requirement

This 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.

Step 3: Modifying the parser (AST builder)

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_PRECONDITIONS

As 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.

Step 4: Modifying 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.

Step 5?: Modifying the ASP translation process

Unfortunately, we are still not done, as our feature modifies PDDLSIM's Conditions, and therefore interacts with the Get Grounded Actions operation, which, among other things, relies on translating an action's precondition into a set of ASP rules. Modifying the ASP translation process is quite technical, but usually not required. Unfortunately, such is not the case here.

Note

We assume you have fully read ASP Based Successor Generation, and have a good understanding of ASP and Clingo.

In the _asp module, we will only need to modify _add_condition_to_asp_part, as well as ASPPart (as we will be generating new kinds of ASP code). _add_condition_to_asp_part is quite a big function, but we only care about the match part of it:

match condition:
        case AndCondition(subconditions):
            ...
        case OrCondition(subconditions):
            ...
        case NotCondition(base_condition):
            ...
        case Predicate(name=name, assignment=assignment):
            ...
        case EqualityCondition(left_side=left_side, right_side=right_side):
            ...

Specifically, we will need to extend this match with a case for XORCondition. Before we do that however, let's first consider how we would actually implement XOR-conditions in the ASP rule lowering. For a set of subconditions, we want the rule to activate only when the number of subconditions that are true is odd. In other words, when the number of subrules that are true is odd. Luckily, we can express such an idea using ASP aggregates. Assuming we have subrules s1 to sn, and our rule is r, we can write the following:

r :- N { s1 ; ... ; sn } N, N % 2 == 1

where ... is of course appropriately replaced. Going over this, we require the #count of true subrules to be equal to N, where N must be of odd parity (second part). Why the roundabout construction? Unfortunately, without extensions, Clingo has no support for parity aggregates, so we have to approach things in this way. This is a bit slow, but the price we must pay as again, Clingo has no native support for what we are doing.

Let's now go about adding support for this kind of construction in ASPPart. The closest thing we currently have, is add_single_instantiation_constraint, which also uses aggregates. It looks like so:

def add_single_instantiation_constraint(
    self, literal: LiteralAST, conditions: Sequence[LiteralAST]
) -> None:
    self._add_fact(
        clingo.ast.Aggregate(
            self.next_location(),
            clingo.ast.Guard(
                clingo.ast.ComparisonOperator.Equal,
                clingo.ast.SymbolicTerm(
                    self.next_location(), clingo.Number(1)
                ),
            ),
            [
                clingo.ast.ConditionalLiteral(
                    self.next_location(), literal, conditions
                )
            ],
            clingo.ast.Guard(
                clingo.ast.ComparisonOperator.Equal,
                clingo.ast.SymbolicTerm(
                    self.next_location(), clingo.Number(1)
                ),
            ),
        )
    )

And corresponds to the aggregate 1 { l1; ... ; ln } 1, where l1 to ln are simply the literals in the aggregate, and ... is replaced accordingly. With this in mind, let's create the new method:

# New!
def add_xor_rule(
    self, head: LiteralAST, literals: Sequence[LiteralAST]
) -> None:
    number = self.create_variable("N")

    aggregate = LiteralAST(
        clingo.ast.Aggregate(
            self.next_location(),
            clingo.ast.Guard(
                clingo.ast.ComparisonOperator.Equal,
                number,
            ),
            literals,
            clingo.ast.Guard(
                clingo.ast.ComparisonOperator.Equal,
                number,
            ),
        )
    )

    one = clingo.ast.SymbolicTerm(
        self.next_location(), clingo.Number(1)
    )

    two = clingo.ast.SymbolicTerm(
        self.next_location(), clingo.Number(2)
    )

    remainder = clingo.ast.BinaryOperation(self.next_location(), clingo.ast.BinaryOperator.Modulo, number, two)

    parity_constraint = self._create_literal(
        clingo.ast.Comparison(
            remainder,
            [
                clingo.ast.Guard(
                    clingo.ast.ComparisonOperator.Equal, 
                    one,
                )
            ],
        ),
        True,
    )

    self.add_rule(
        head,
        [
            aggregate,
            parity_constraint
        ]
    )

Let's use this new method for our XORCondition match-case implementation now:

match condition:
        case AndCondition(subconditions):
            ...
        case OrCondition(subconditions):
            ...
        # New!
        case XORCondition(subconditions):
            subcondition_ids = (
                _add_condition_to_asp_part(
                    subcondition,
                    part,
                    rule_id_allocator,
                    variable_id_allocator,
                    object_id_allocator,
                    predicate_id_allocator,
                )
                for subcondition in subconditions
            )

            part.add_xor_rule(
                head,
                [
                    part.create_constant_literal(str(subcondition_id))
                    for subcondition_id in subcondition_ids
                ],
            )
        case NotCondition(base_condition):
            ...
        case Predicate(name=name, assignment=assignment):
            ...
        case EqualityCondition(left_side=left_side, right_side=right_side):
            ...

And finally, with this implementation here, we conclude our tutorial. We have extended PDDLSIM with a new PDDL feature. Naturally, for different kinds of features, possibly even 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).

Clone this wiki locally