Skip to content

Adding elements to the core component

Dominik Drexler edited this page Sep 2, 2021 · 8 revisions

Introduction

Each state feature is a rooted directed acyclic graph where the leaves are primitive elements and the inner nodes are more complex elements. Besides many description logic elements, there are also some custom elements that have shown to be useful in practice. However, different use cases can require adding new elements to the core component. Next, we guide you through the process of adding a new element to the library.

Example

In this example, we are going to add the binary equality operator. Interestingly, the equality operator should accept different types for their children. Currently, there are four basic types. Those are Concept, Role, Numerical, and Boolean. The difference between them is that their evaluation result has a different type:

// src/core/elements/element.h: 
template<typename T>
class Element {
public:
    T evaluate(const State& state) const = 0;
};

A Boolean derives from Element<bool> and hence, returns a bool. Furthermore, a Numerical derives from Element<int>, a Concept derives from Element<std::vector<int>>, and a Role derives from Element<std::vector<std::pair<int, int>>>. For any two types that the equality operator should be able to compare, there must be a comparison operator for objects of those return types. For simplicity, we assume that both operands have the same type and that the respective comparison operators exist.

Step 1: Implementing the Functionality

The functionality of each element is implemented in the subdirectory src/core/elements in the nested namespace element. For the equality operator, we add a class EqualBoolean the following content to src/core/elements/boolean/equality.h:

// src/core/elements/boolean/equality.h
#include "../boolean.h"

namespace dlplan::core::element { 

template<typename T>
class EqualBoolean : public Boolean {
private:
    const T m_operand_left;
    const T m_operand_right;

public:
    EqualBoolean(const VocabularyInfo& vocabulary, T operand_left, T operand_right) 
        : Boolean(vocabulary, /* (1) */), m_operand_left(/* (2) */), m_operand_right(/* (3) */)

    bool evaluate(const State& state) override { /* (4) */ }

    int compute_complexity() const override { /* (5) */ }

    std::string compute_repr() const override { /* (6) */ }
};

}

EqualBoolean publicly derives from Boolean. Hence, EqualBoolean returns a bool in its evaluation function. EqualBoolean stores its operands in the private section which must be assigned in the initializer list of the constructor in (2) and (3). Next, we are going to fill in the empty parts (1) - (6) in detail.

(1): The unique identifier

Every element has a name that is unique among all elements that can be constructed. We use the naming convention "<prefix>_<name>" with all lowercase where the prefix b stands for Boolean, n for Numerical, c for Concept, and r for Role. Therefore, we use the name "b_equal" for EqualBoolean.

EqualBoolean(const VocabularyInfo& vocabulary, T operand_left, T operand_right) 
        : Boolean(vocabulary, "b_equal"), m_operand_left(/* (3) */), m_operand_right(/* (4) */)

(2) + (3): The operands

To obtain a canonical representation with respect to commutativity, we should sort the operands lexicographically according to their stringlike representation.

EqualBoolean(const VocabularyInfo& vocabulary, T operand_left, T operand_right) 
        : Boolean(vocabulary, "b_equal"), 
          m_operand_left(operand_left->compute_repr() < operand_right->compute_repr() ? operand_left : operand_right), 
          m_operand_right(operand_left->compute_repr() < operand_right->compute_repr() ? operand_right : operand_left)

(4): The evaluation function

To avoid unnecessary memory allocation in each evaluation, every element has the attribute m_result with preallocated memory where the result must be written to. This is redundant in the case of stack-allocated objects such as bool or int, but heap-allocated objects such as std::vector<int> profit from this. Making use of the overloaded type-specific equality operators the evaluation code becomes:

bool evaluate(const State& state) const override {
    return (m_operand_left->evaluate(state) == m_operand_right->evaluate(state));
}

(5): The complexity function

The complexity is defined as the number of nodes in the tree version of the rooted directed acyclic graph.

int compute_complexity() const override { 
    return m_operand_left->compute_complexity() + m_operand_right->compute_complexity() + 1;
}

(6): The canonical representation function

The convention for the stringlike representation is "<prefix>_<name>(child1->compute_repr(),...,child2->compute_repr())".

std::string compute_repr() const override {
    std::stringstream ss;
    ss << m_name << "(" << m_operand_left->compute_repr() << "," << m_operand_right->compute_repr() << ")";
    return ss.str();
}

Step 2: Registering the Element to the Parser.

The parser has the job to distinguish good from bad inputs and to compose complex elements just from given textual descriptions. The parser first tokenizes the description such that it can construct an abstract syntax tree consisting of expressions. The parseable expressions are implemented in the subdirectory src/core/parser/expressions/ in the nested namespace parser. There is a one-to-one correspondence between expressions and elements in the namespace element. All expressions derive from the base class Expression. Since we want to parse an element::Boolean we must derive from the expression Boolean. This subtype Boolean requires our concrete class to have a method that allows parsing into an element::Boolean_Ptr that is a std::shared_ptr<element::Boolean>. We add the following content to src/core/parser/expressions/boolean/equality.h:

// src/core/parser/expressions/boolean/equality.h
#include "../boolean.h"
#include "../../../elements/boolean/equality.h"

namespace dlplan::core::parser { 

class EqualityBoolean : public Boolean {
protected:
    std::unique_ptr<element::Boolean> parse_boolean_impl(const VocabularyInfo& vocabulary, Caches& caches) const override { /* (1) */ }

public:
    EqualityBoolean(const std::string& name, std::vector<std::unique_ptr<Expression>>&& children)
        : Boolean(name, std::move(children)) { }
};

}

(1): The parsing function

In this function, we perform the actual parsing into the respective element.

std::unique_ptr<element::Boolean> parse_boolean_impl(const VocabularyInfo& vocabulary, Caches& caches) const override {
    // error checking
    if (m_children.size() != 2) {
        throw std::runtime_error("EqualBoolean::parse_boolean_impl: number of children ("s + std::to_string(m_children.size()) + " != 2.");
    }
    // try to parse into element::EqualBoolean with T=element::Boolean_Ptr
    element::Boolean_Ptr b_operand_left = m_children[0]->parse_boolean(vocabulary, cache);
    element::Boolean_Ptr b_operand_right = m_children[1]->parse_boolean(vocabulary, cache);
    if (b_operand_left && b_operand_right) {
        return std::make_unique<element::EqualBoolean<element::Boolean_Ptr>>(vocabulary_info, b_operand_left, b_operand_right);
    }
    // try to parse into element::EqualBoolean with T=element::Numerical_Ptr
    element::Numerical_Ptr n_operand_left = m_children[0]->parse_numerical(vocabulary, cache);
    element::Numerical_Ptr n_operand_right = m_children[1]->parse_numerical(vocabulary, cache);
    if (n_operand_left && n_operand_right) {
        return std::make_unique<element::EqualBoolean<element::Numerical_Ptr>>(vocabulary_info, n_operand_left, n_operand_right);
    }
    // more error checking
    throw std::runtime_error("EqualBoolean::parse_boolean_impl: unable to construct child elements.");
}

Next, we have to add the construction of EqualBoolean into the AST_Factory. To do so we have to add an EXPRESSION_TYPE and respective mapping from a string in the VocabularyInfo in src/core/vocabulary_info.h. Our convention is to use the name of the respective element <prefix>_<name> with all uppercase letters. In our case, we add B_EQUAL to the enumeration EXPRESSION_TYPE. Next, in src/core/vocabulary_info.cpp we add the pair {"b_equal", B_EQUAL} to map the name to its enum value. Last, we add the following case to the switch statement in AST_Factory::make_ast in src/core/parser/ast_factory.cpp:

case B_EQUAL: {
    return std::make_unique<parser::EqualBoolean>(name, std::move(children));
}

Step 3: Adding Factory Method for Construction.

See core.h and element_factory.h for many examples.