Escape Hatches

ASPAlchemy supports most of clingo, but not all of it, and sooner or later you may need something it doesn’t do: a disjunctive head, a #project directive, a solver option, or a call into Python during grounding. The list of what isn’t supported, and why, is in What We Don’t Support. This page is about how to get at those things anyway.

Using one of these doesn’t mean giving up on the rest of the library. Atoms derived by raw ASP text still come back as typed Python objects, clingo’s messages still point at your Python, and solving works just the same.

raw_asp(): verbatim clingo

raw_asp(text, predicates=()) adds a block of ASP text to the program, exactly as written. You can call it on the program (which writes to the default segment) or on any named segment. Here it’s used for a disjunctive head, which ASPAlchemy doesn’t support directly, and the answer sets still come back as typed atoms:

from aspalchemy import ASPProgram, Field, Predicate

class Left(Predicate):
    pass

class Right(Predicate):
    pass

duo = ASPProgram()
duo.raw_asp("left ; right.", predicates=[Left, Right])

The text appears in the render as written, along with #show directives for the predicates you declared:

>>> print(duo.render())
% Generated by aspalchemy ...
left ; right.

#show.
#show left/0.
#show right/0.
>>> sorted(sorted(a.render() for a in m.atoms()) for m in duo.solve())
[['left'], ['right']]

The catch is that ASPAlchemy doesn’t check raw text as you write it, so a typo inside it only shows up when clingo grounds the program. You do still get help there: clingo’s messages about raw text point back to the line of Python that called raw_asp(). See Clingo’s messages.

The predicates= seatbelt

ASPAlchemy can’t see inside raw text, so predicates= is how you tell it which predicates the text uses. Every predicate in the text has to be declared somewhere, and this is checked when the program is grounded. Declaring a class does two things: it adds a #show directive so the predicate appears in models, and it tells ASPAlchemy which class to read those atoms back into.

If the text derives classically negated atoms, you need to declare -P as well as P. Declaring just P is enough to read negated atoms back, but only shows the positive ones, so the negated atoms would be missing from your models:

class Status(Predicate):
    node: Field[str]

belt = ASPProgram()
belt.raw_asp('status("a"). -status("b").', predicates=[Status, -Status])
>>> print(belt.render())
% Generated by aspalchemy ...
status("a"). -status("b").

#show.
#show -status/1.
#show status/1.
>>> model = belt.solve().first()
>>> sorted(a.render() for a in model.atoms(Status))
['-status("b")', 'status("a")']

If you forget to declare a predicate, grounding fails with an error naming it:

>>> mystery = ASPProgram()
>>> mystery.raw_asp("hidden_gem(7).")
>>> mystery.solve().first()
Traceback (most recent call last):
  ...
ValueError: The grounded program contains predicates never declared to aspalchemy: hidden_gem/1. raw_asp blocks must declare every predicate occurring in their text via predicates=[...]; ...

To keep a declared predicate out of your models, define its class with show=False, or hide() it on the program, as usual. This is also the fix if raw text produces atoms containing strings with escape characters (like \" or \n): ASPAlchemy can’t read those back, so hide() that class and the rest of the model reads normally.

Blocks are lexically self-contained

Each raw block has to stand on its own. If a block opens a %* block comment or a #script and doesn’t close it, it is refused:

>>> scratch = ASPProgram()
>>> scratch.raw_asp("%* this comment never closes")
Traceback (most recent call last):
  ...
ValueError: raw_asp() text opens a %* block comment that never closes: every raw block must be lexically self-contained. ...

Clingo itself would let a comment opened in one block close in a later one, but everything in between would quietly disappear, including statements you wrote with ASPAlchemy. For the same reason, a #script block has to start and end inside a single raw_asp() call.

Blocked directives

Four directives are refused inside raw blocks, and each error tells you what to use instead:

  • #const: use define_constant() instead. Constants you define that way are always in the program, so raw text can use them freely.
  • #program and #external: ASPAlchemy grounds the whole program in one go, so anything after a #program directive would silently never be grounded. These belong to clingo’s incremental solving, which isn’t supported yet. If you want to switch an atom on and off between solves, make it a choice and fix it with assumptions=.
  • #include: you’re writing Python, so read the file with Python and pass in its text.
>>> scratch.raw_asp("#const width = 9.")
Traceback (most recent call last):
  ...
ValueError: raw_asp() text contains #const — register it with define_constant() instead: ...

Calling Python during grounding

Clingo lets you call Python functions during grounding with @-terms, and you can use them in raw_asp() text. An @f(X) term calls a method on a context object that you pass in as ground(context=obj), so @double(N) calls obj.double(N). solve(), brave(), cautious() and optimize() take context= as well.

import clingo

class Doubler:
    def double(self, n: clingo.Symbol) -> clingo.Symbol:
        return clingo.Number(n.number * 2)

class Base(Predicate, show=False):
    n: Field[int]

class Doubled(Predicate):
    n: Field[int]
    twice: Field[int]

calls = ASPProgram()
calls.fact(Base(n=2), Base(n=21))
calls.raw_asp("doubled(N, @double(N)) :- base(N).", predicates=[Doubled])

@double(N) is called during grounding, once for each base fact:

>>> model = calls.ground(context=Doubler()).solve().first()
>>> sorted((d.n, d.twice) for d in model.atoms(Doubled))
[(2, 4), (21, 42)]

This is plain clingo, so there’s no typed layer here: your methods take and return clingo.Symbol values, and it’s up to you to keep them in step with the text. You can also put the Python directly in the ASP text with a #script block, as long as it fits inside one raw_asp() call.

One thing to watch out for: ground_text() and aspif(), and the statement profile, all ground the program again, using the same context. If your functions keep state and give different answers on later calls, those may not match your original grounding.

Solver options and the raw Control

grounded.control is the underlying clingo.Control, which gives you everything in the clingo API: configuration, externals, theory atoms, observers and so on. ASPAlchemy’s guarantees stop here, so change things between solves, never during one, and use ASPAlchemy’s own methods where they exist.

This is where most of clingo’s command-line options go. For -t 4, --seed or --configuration=jumpy, set the matching key on grounded.control.configuration after ground() and before you solve. Clingo’s documentation lists the options. --time-limit doesn’t need any of this: it’s timeout= on solve() and the other verbs. The clingo map has the rest.

Two directives need a solver option before they do anything: #project does nothing unless projection is turned on, and #heuristic does nothing unless the Domain heuristic is turned on. Without the option, the directive is quietly ignored:

from aspalchemy import Choice

class Pick(Predicate):
    x: Field[int]

class Audited(Predicate):
    pass

projected = ASPProgram()
picks = Choice(Pick(x=1))
picks.add(Pick(x=2))
projected.choose(picks)
projected.choose(Choice(Audited()))
projected.raw_asp("#project pick/1.")

grounded = projected.ground()
>>> len(list(grounded.solve()))  # audited doubles every pick set: #project is ignored
8
>>> grounded.control.configuration.solve.project = "project"     # type: ignore[union-attr]  # clingo types config knobs loosely
>>> len(list(grounded.solve()))  # one model per pick set
4
>>> grounded.control.configuration.solver.heuristic = "Domain"  # type: ignore[union-attr]  # the #heuristic knob

With projection on, models that only differ in audited count as one, so eight models become four.

Clingo symbol interop

If you work with the clingo API directly (externals on the raw Control, your own on_model callback, or another clingo library), you’ll need to convert between typed atoms and clingo.Symbol objects. convert_predicate_to_symbol and convert_symbol_to_predicate do that, including nested predicates and classical negation:

from aspalchemy import convert_predicate_to_symbol, convert_symbol_to_predicate

class Cell(Predicate):
    row: Field[int]
    col: Field[int]
>>> sym = convert_predicate_to_symbol(-Cell(row=1, col=2))   # a clingo.Symbol
>>> str(sym)
'-cell(1,2)'
>>> back = convert_symbol_to_predicate(sym, {("cell", 2): Cell})
>>> back == -Cell(row=1, col=2)
True
>>> assert isinstance(back, Cell)      # the converter returns Predicate; narrow to read fields typed
>>> back.negated, back.row
(True, 1)

Going from a symbol back to a predicate needs a mapping from (name, arity) to the class to use.

Incremental-ish solving today

If you want to ask lots of questions of one program, use ground() plus assumptions: ground once, then fix different atoms true or false for each solve. Clingo’s full incremental solving, with #program parts, is not supported yet.