Defining Your First Classical Planning Problem

Classical planning problems are usually encoded in modeling language such as STRIPS, PDDL or Functional STRIPS. These languages provide a way to succinctly encode the problem dynamics, initial state and goal. Additionally, most of these languages are based, to some degree or another, in the standard concepts of first-order logics. We here will briefly describe these concepts and show how to use them in order to easily define classical planning problems in a programmatic fashion with Tarski.

Creating a Language for the Blocks World

In order to define any problem, we will first need to describe the language with which that problem will be defined. To do so, Tarski uses many-sorted first-order logical languages, which are standard first-order languages enriched with the notion of sorts or types. Any such language is defined by a number of predicate and function symbols, with their associated arities, plus a number of sorts. Let us start by creating a language object

[1]:
import tarski
lang = tarski.fstrips.language("blocksworld")
lang
[1]:
blocksworld: Tarski language with 1 sorts, 2 predicates, 0 functions and 0 constants

lang is a language named blocksworldwith only the default “object” sort and the equality operators \(=\) and \(\neq\). These too can be disabled, but by default Tarski will attach them to any language. Let us now create the predicates used in the standard definitions of the blocks world. Note that in most of these definitions, no types are used.

[2]:
handempty = lang.predicate('handempty')
on = lang.predicate('on', 'object', 'object')
ontable = lang.predicate('ontable', 'object')
clear = lang.predicate('clear', 'object')
holding = lang.predicate('holding', 'object')

That creates 5 different predicates. Predicate \(handempty\) is nullary (i.e. has arity 0); predicate \(on\) is binary, and applies to pairs of objects, and so on. In order to define some particular instance, we will need some constants (these are usually referred to as “object” in the PDDL language):

[3]:
b1, b2, b3 = [lang.constant(f'b{k}', 'object') for k in range(1, 4)]

Defining the Problem

We now have all the elements we need in order to define a particular problem. Let us do so:

[4]:
problem = tarski.fstrips.create_fstrips_problem(
    domain_name='blocksworld', problem_name='tutorial', language=lang)

Notice that the problem constructor takes the language that will be used to define it. Let’s start by defining an initial situation:

[5]:
init = tarski.model.create(lang)
init.add(clear(b1))
init.add(clear(b3))
init.add(on(b1, b2))
init.add(ontable(b2))
init.add(ontable(b3))
init.add(handempty())
problem.init = init

Note that we first created a model, and then we explicitly declared which atoms are true under that model. In the tradition of the closed-world assumption, the rest of atoms are considered to be false, and this uniquely determines a standard first-order interpretation. Note also that we make use of the flexibility of Python in order to allow an expression such as on(b1, b2) to actually denote an object of Python type Atom

We can quickly inspect the model we created:

[6]:
problem.init.as_atoms()

[6]:
[clear(b3), clear(b1), on(b1,b2), ontable(b2), ontable(b3), handempty()]

Let us now set a simple goal:

[7]:
problem.goal = on(b1, b2) & on(b2, b3) & clear(b1)

Notice how our goal is a simple conjunction of atoms, and how again we made use of Python’s hability to redefine the meaning of builtin operators such as &. The above expression would be equivalent to:

[8]:
from tarski.syntax import land
problem.goal = land(on(b1, b2), on(b2, b3), clear(b1))

The only thing that remains to define a complete blocks instance are, of course, the actions. As customary in action languages, we can make use of “action schemas” that appeal to first-order variables. Let us define a pick-up action schema that allows us to pick any block from the table:

[9]:
import tarski.fstrips as fs

x = lang.variable('x', 'object')
y = lang.variable('y', 'object')

pu = problem.action('pick-up', [x],
                    precondition=clear(x) & ontable(x) & handempty(),
                    effects=[fs.DelEffect(ontable(x)),
                             fs.DelEffect(clear(x)),
                             fs.DelEffect(handempty()),
                             fs.AddEffect(holding(x))])

In words, action schema pick-up can pick up any block \(x\) provided that the block is clear and on the table, and the robot hand is empty. The effect of doing so is encoded through standard STRIPS add- and delete- effects: \(x\) stops being clear and on the table, and the robot hand stops being empty and is now holding \(x\).

The rest of the standard actions (put-down, stack, unstack) would be defined in the same manner. We show them next for the sake of completeness:

[10]:
pd = problem.action('put-down', [x],
                   precondition=holding(x),
                   effects=[fs.AddEffect(ontable(x)),
                            fs.AddEffect(clear(x)),
                            fs.AddEffect(handempty()),
                            fs.DelEffect(holding(x))])

us = problem.action('unstack', [x, y],
                   precondition=on(x, y) & clear(x) & handempty(),
                   effects=[fs.DelEffect(on(x, y)),
                            fs.AddEffect(clear(y)),
                            fs.DelEffect(clear(x)),
                            fs.DelEffect(handempty()),
                            fs.AddEffect(holding(x))])

st = problem.action('stack', [x, y],
                   precondition=holding(x) & clear(y) & (x != y),
                   effects=[fs.AddEffect(on(x, y)),
                            fs.DelEffect(clear(y)),
                            fs.AddEffect(clear(x)),
                            fs.AddEffect(handempty()),
                            fs.DelEffect(holding(x))])