Embedding Python into ASP

As well as providing an API for calling the Clingo solver from Python, Clingo also supports embedding Python function calls within an ASP program. Clorm builds on these Clingo features by providing an extended interface for calling Python from an ASP program.

Specifying Type Cast Signatures

When calling (external) Python functions from within an ASP program the Python function is passed inputs encoded as objects of type Clingo.Symbol. The Clingo system then expects a return value of either a single Clingo.Symbol object or a list of Clingo.Symbol objects. It is the responsibility of the programmer to perform any necessary data type conversions.

For convenience Clingo does provide some exceptions to this basic procedure. These exceptions are to do with the output values where Clingo is able to make some assumptions. In particular:

  • Python integer values will be automatically converted to a clingo.Symbol object using the clingo.Number() function. Similarly Python string values will be converted using the clingo.String() function.

  • Python tuples will automatically be converted using the clingo.Function() function, with an empty string as the name parameter, which is how Clingo internally represents tuples.

While this automatic data conversion behaviour of the Clingo API can be convenient it is however a somewhat ad-hoc approach. In the first place while there is some automatic conversion of the outputs of the Python function, however, there is no automatic conversions for the inputs to the Python function. Secondly it cannot deal with arbitrary outputs. For example, it is not possible to specify that a Python string should be interpreted as a constant object rather than a string object. Also complex terms other than a tuples cannot be handled automatically.

To address these problems Clorm provides a more principled approach that allows for the specification of a type cast signature that defines how to automatically convert between the expected input and output types.

We explain Clorm’s type casting features by way of a simple example. In particular, consider a Python date_range function that is given two dates (encoded in YYYY-MM-DD string format) and returns an enumerated list of dates within this range. This can be called from Clingo by prefixing the function with the @ symbol:

date(@date_range("2019-01-01", 2019-01-05")).

The corresponding Python object needs to take the two input clingo.String symbols turn them into dates, compute the dates in the range, and return the enumerated list of outputs converted back into clingo.String symbols.

from clingo import String
from datetime import datetime, timedelta

def date_range(start, end):
    pystart = datetime.strptime(start.string, "%Y-%m-%d").date()
    pyend = datetime.strptime(end.string, "%Y-%m-%d").date()

    inc = timedelta(days=1)
    tmp = []
    while pystart < pyend:
        tmp.append(pystart.strftime("%Y%m%d"))
        pystart += inc
    return list(enumerate(tmp))

This function will be called by Clingo during the ASP program grounding process that will generate the list of ground facts:

date((0,"2019-01-01")). date((1,"2019-01-02")).
date((2,"2019-01-03")). date((3,"2019-01-04")).

Here the string objects that encode the two dates are first converted into Python date objects. Then when the appropriate dates are calculated they are translated back into strings and enumerated into list of pairs with the first element of each pair being the enumeration index and the second element being the date encoded string. Note: that the above Python code does takes advantage of the automatic clingo API type conversions.

Clorm provides a way to simplify this data translation through the use of a decorator that attaches a type cast signature to the function. Firstly the conversion to and from date objects can be removed from the function and instead declared as part of a DateField, thus simplifying the function but also making the code more re-usable.

from clorm import StringField
from datetime import datetime, timedelta

class DateField(StringField):
    pytocl = lambda dt: dt.strftime("%Y-%m-%d")
    cltopy = lambda s: datetime.datetime.strptime(s,"%Y-%m-%d").date()

The DateField sub-classes a StringField and provides the conversion between string objects and dates.

@make_function_asp_callable
def date_range(start : DateField, end : DateField) -> [(IntegerField, DateField)]:
    inc = timedelta(days=1)
    tmp = []
    while start < end:
        tmp.append(start)
        start += inc
    return list(enumerate(tmp))

This decorator supports the specification of the type cast signature as part of the function’s annotations (a Python 3 feature) to provide a neater specification. Note, the signature could equally be passed as decorator function arguments:

@make_function_asp_callable(DateField, DateField, [(IntegerField, DateField)])
def date_range:
    ...

The important point is that the type cast signature provides a mechanism to specify arbitrary data conversions both for the input and output data; including conversions generated from very complex terms specified as Clorm ComplexTerm sub-classes. Consequently, the programmer does not have to explicitly write the type conversion code and even existing functions can be decorated to be used as callable ASP functions.

Another point to note is that the Clorm specification is also able to use the simplified tuple syntax from the Clingo API to specify the enumerated pairs. In fact this code can be viewed as a short-hand for an explicit declaration of a ComplexTerm tuple and internally Clorm does generate a signature equivalent to the following:

class EnumDate(ComplexTerm):
    idx = IntegerField()
    dt = DateField()
    class Meta: is_tuple=True

@make_function_asp_callable
def date_range(start : DateField, end : DateField) -> [EnumDate.Field]:
   ...

There are two decorator functions that Clorm provides:

  • make_function_asp_callable: Wraps a normal function. Every function parameter is converted to and from the clingo equivalent.

  • make_method_asp_callable: Wraps a member function. The first paramater is the object’s self parameter so is passed through and only the remaining parameters are converted to their clingo equivalents.

In summary, the Clorm type cast signature has two distinct advantages over the built in Clingo API for handling external functions. Firstly, it provides a principled approach for specifying arbitrarily complex type conversions, unlike the limited ad-hoc approach of the built-in Clingo API. Secondly, by making this type conversion specification explicit it is clear what conversions will be performed and therefore makes for clearer and more re-usable code.

Specifying a Grounding Context

From Clingo 5.4 onwards, the Clingo grounding function allows a context parameter to be specified. This parameter defines a context object for the methods that are called by ASP using the @-syntax.

While this context feature can be used in a number of different ways, one way is simply as a convenient namespace for encapsulating the external Python functions that are callable from within an ASP program. Clorm provides support for this use-case through the use of a context builder.

ContextBuilder allows arbitrary functions to be captured within a context and assigned a data conversion signature (where the data conversion signature is specified in the same way as the make_function_asp_callable and make_method_asp_callable functions). It also allows the function to be given a different name when called from within the context.

Also like make_function_asp_callable and make_method_asp_callable, the context builder’s register and register_name member functions can be called as decorators or as normal functions. However, unlike the standalone functions, a useful feature of the ContextBuilder member functions is that when called as a decorator they do not decorate the original functions but instead return the original function and only decorate the function when called from within the context.

Consider the decorated date_range function defined earlier. One issue with this function is that it can only be called from within an ASP program (unless you use clingo.Symbol inputs and outputs). However, a function that generates an enumerated date range is fairly useful in and of itself so it might be desireable to be called from other Python functions.

The ContextBuilder can be used to solve this problem.

from clorm import ContextBuilder

cb=ContextBuilder()

# decorator that registers the function with the context builder
@cb.register(DateField, DateField, [(IntegerField, DateField)])
def date_range(start, end):
    inc = timedelta(days=1)
    tmp = []
    while start < end:
        tmp.append(start)
        start += inc
    return list(enumerate(tmp))

# Use the function as normal to calculate a date range
sd=datetime.date(2010,1,5)
ed=datetime.date(2010,1,8)
dr=date_range(sd,ed)

ctx=cb.make_context()

# Use the decorated version from within the context
cl_dr = ctx.date_range(clingo.String("2010-01-05"),clingo.String("2010-01-08"))

The above example shows how the original date_range function is untouched but instead the context version is wrapped using the data conversion signature. The created context can then be passed as an argument during the grounding phase.

import clingo

ctrl=clingo.Control()

# Define an ASP program and import it into the control object
prgstr="""date(@date_range("2010-10-10", "2010-10-13")."""

with ctrl.builder() as b:
   clingo.parse_program(prgstr, lambda s: b.add(s))

# Ground using the context defined earlier
ctrl.ground([("base",[])],context=ctx)

# Solve
ctrl.solve()

The program defined in the string uses the date_range function defined by the earlier context and when solved will produce the expected answer set:

date((0,"2010-10-10")). date((1,"2010-10-11")). date((2,"2010-10-12")).

Of course multiple functions can be registered with a ContextBuilder and it can also be used as a form of code re-use to define multiple versions of a function with different signatures.

def add(a,b): a+b

# Register two versions using the same function - one to add numbers and one
# to concat strings. Note: first argument is the new function name, last
# argument is the function; the middle arguments define the signature.
cb.register_name("addi", IntegerField, IntegerField, IntegerField, add)
cb.register_name("adds", StringField, StringField, StringField, add)

ctx=cb.make_context()

n1=clingo.Number(1); n2=clingo.Number(2); n3=clingo.Number(3)
s1=clingo.String("ab"); s2=clingo.String("cd"); s3=clingo.String("abcd")

assert ctx.addi(n1,n2) == n3
assert ctx.adds(s1,s2) == s3