ASPAlchemy

📖 You are reading the docs source on GitHub — the rendered site lives at jolyonb.github.io/aspalchemy.

Answer Set Programming (ASP) lets you state requirements, then let a solver figure out how to satisfy them. It’s a beautiful language. But it’s also niche, and that means that the tooling around it is immature. It runs on text files, often lovingly hand-crafted. Input data needs formatting. Output data needs to be transcribed to the appropriate format. The purpose of ASPAlchemy is to bring to ASP what SQLAlchemy brings to SQL: a thorough encapsulation of the language within strongly-typed Python constructs. While we still think that understanding the basics of ASP is worthwhile, we also think that you should almost never have to construct an ASP program by hand ever again. Let ASPAlchemy handle the details for you.

ASPAlchemy is a Python ORM for clingo: you author Answer Set Programming rules as typed Python objects, it renders them to ASP source, solves through the clingo API, and hands the answer sets — the solutions — back as typed Python values. And because your program is Python, it is dynamic — rules built with loops, functions, and real data reshape themselves around the input in ways a frozen .lp file never can.

Here is the whole of n-Queens on a standard 8×8 chessboard — place 8 queens so that none attacks another. The Python states the rules; the solver does the searching:

from aspalchemy import (
    ASPProgram, Choice, Count, Field, Predicate, RangePool, Variable,
)

class Queen(Predicate, name="q"):
    row: Field[int]
    col: Field[int]

program = ASPProgram()
size = 8
n = program.define_constant("n", size)
R, C, D = (Variable(x) for x in "RCD")
indices = RangePool(1, n)

# every row and every column holds exactly one queen
program.when(R.in_(indices)).choose(Choice(Queen(row=R, col=indices)) == 1)
program.when(C.in_(indices)).choose(Choice(Queen(row=indices, col=C)) == 1)

# no two queens share a diagonal
falling, rising = RangePool(2, 2 * n), RangePool(1 - n, n - 1)
program.forbid(Count(C, condition=Queen(row=D - C, col=C)) >= 2, D.in_(falling))
program.forbid(Count(C, condition=Queen(row=D + C, col=C)) >= 2, D.in_(rising))

# Read off the solution
queens = program.solve().first().atoms(Queen)  # a list of Queen objects

The eight queens come back as typed Python — no parsing, no unwrapping. Whichever of the 92 solutions came back, it holds up:

>>> len(queens)
8
>>> sorted(q.col for q in queens)  # exactly one queen per column
[1, 2, 3, 4, 5, 6, 7, 8]
>>> sorted(q.row for q in queens)  # and per row
[1, 2, 3, 4, 5, 6, 7, 8]
>>> len({q.row - q.col for q in queens})  # no two share a falling diagonal
8
>>> len({q.row + q.col for q in queens})  # nor a rising one
8

And .row and .col are ordinary ints, so drawing the board is just Python:

board = {(q.row, q.col) for q in queens}
for row in range(1, size + 1):
    print(" ".join("Q" if (row, col) in board else "." for col in range(1, size + 1)))
. Q . . . . . .
. . . . . . Q .
. . Q . . . . .
. . . . . Q . .
. . . . . . . Q
. . . . Q . . .
Q . . . . . . .
. . . Q . . . .

(Note that you may get a different solution than the above if you run the code yourself; which of the 92 solutions is returned first is not deterministic.) If you want to see the generated code, program.render() hands back the ASP:

>>> print(program.render())
% Generated by aspalchemy ...
#const n = 8.
{ q(R, 1..n) } = 1 :- R = 1..n.
{ q(1..n, C) } = 1 :- C = 1..n.
:- #count{ C : q(D - C, C) } >= 2, D = 2..2 * n.
:- #count{ C : q(D + C, C) } >= 2, D = 1 - n..n - 1.

#show.
#show q/2.

(For clingo aficionados: the textbook writes those diagonal tests as body braces, { q(D-C, C) } >= 2. ASPAlchemy spells cardinality tests as Count comparisons deliberately: body braces in a body look like a choice rule but are actually a cardinality test. Both ground identically.)

“But the Python is longer!” On a toy, yes. Notice where the length isn’t, though: the rules — the part ASPAlchemy specializes in — are four statements in both versions, line for line. The rest is the imports and the predicate declaration, and that cost is fixed: it buys typed fields, autocomplete, and the typed read-back above, and it does not grow with the problem. What it also buys is the next section, which a .lp file cannot have at any length.

Your program is dynamic

This is the point of the whole library. Your rules aren’t a file, they’re Python — so you can write a module that knows how to reason about a whole family of problems, and emits exactly the rules a given program asks of it.

Take a grid. A puzzle might be laid out on a rectangular board or a hex one, and those are not the same shape of problem: a cell is addressed differently (a row and a column, versus the three cube coordinates a hex grid needs), and “neighbour” means something different. Everything you might want to compute about a grid — regions, lines of sight, distance, connectivity — rests on those two facts.

So state the coordinate system once, and derive the rules from it. Adjacency is the instructive case, because it depends on both halves:

from aspalchemy import ASPProgram, Expression, Field, Predicate, Variable

# What a cell IS — the coordinates it is addressed by.
COORDS: dict[str, list[str]] = {"rect": ["row", "col"], "hex": ["x", "y", "z"]}

# What ADJACENCY means — the effect of taking one positive step along each axis
# defined above: east and south on a rectangular grid; three of the six cube
# directions on a hex grid. (Opposite directions come from a negative step.)
NEIGHBOURS: dict[str, list[tuple[int, ...]]] = {
    "rect": [(0, 1), (1, 0)],
    "hex": [(1, -1, 0), (1, 0, -1), (0, 1, -1)],
}

def step_in_direction(axis: Variable, step: int) -> Variable | Expression:
    """
    Returns an ASP expression corresponding to taking a step along the given axis.
    Note the return type: arithmetic on a Variable does not compute anything, it
    BUILDS a term — `Col + 1` is an Expression, an ASP object that renders as
    `Col + 1`. A step of zero returns the Variable itself.
    """
    if step == 0:
        return axis
    return axis + step

class Grid:
    """
    Grid rules for one coordinate system. A real module would offer plenty of
    them — regions, lines, distance, connectivity; here is one. None is emitted
    until a program asks for it.
    """

    def __init__(self, geometry: str) -> None:
        self.coords: list[str] = COORDS[geometry]
        self.offsets: list[tuple[int, ...]] = NEIGHBOURS[geometry]

        # The geometry decides what a cell even IS: cell(Row, Col) or cell(X, Y, Z).
        # define() returns a predicate CLASS, built at runtime; coordinates are
        # ints, so the fields are still type-checked on every write.
        self.Cell: type[Predicate] = Predicate.define(
            "cell", {coord: int for coord in self.coords}, show=False
        )
        self.Adjacent: type[Predicate] = Predicate.define(
            "adjacent", {"a": self.Cell, "b": self.Cell}
        )
        self.axes: list[Variable] = [Variable(c.capitalize()) for c in self.coords]

    def adjacency(self, program: ASPProgram) -> None:
        """
        Emit one rule per neighbour direction, each saying: these two cells are
        adjacent, provided both of them exist. A rectangular grid gets two rules,
        a hex grid three — the geometry decides.
        """
        # The cell the rules are written about: cell(Row, Col), or cell(X, Y, Z)
        here = self.Cell(**dict(zip(self.coords, self.axes, strict=True)))

        for offset in self.offsets:
            # Its neighbour one step away: cell(Row, Col + 1) for the offset (0, 1).
            # strict=True holds us to the invariant of one step per coordinate.
            there = self.Cell(**{
                coord: step_in_direction(axis, step)
                for coord, axis, step in zip(self.coords, self.axes, offset, strict=True)
            })
            program.when(here, there).derive(self.Adjacent(a=here, b=there))

Ask the same module for adjacency on two geometries, and you get two structurally different ASP programs:

>>> rect = ASPProgram()
>>> grid = Grid("rect")
>>> grid.adjacency(rect)          # add the adjacency rules to the program
>>> print(rect["Rules"].render())
adjacent(cell(Row, Col), cell(Row, Col + 1)) :- cell(Row, Col), cell(Row, Col + 1).
adjacent(cell(Row, Col), cell(Row + 1, Col)) :- cell(Row, Col), cell(Row + 1, Col).

>>> hexagonal = ASPProgram()
>>> grid = Grid("hex")
>>> grid.adjacency(hexagonal)     # the same call, on a different coordinate system
>>> print(hexagonal["Rules"].render())
adjacent(cell(X, Y, Z), cell(X + 1, Y - 1, Z)) :- cell(X, Y, Z), cell(X + 1, Y - 1, Z).
adjacent(cell(X, Y, Z), cell(X + 1, Y, Z - 1)) :- cell(X, Y, Z), cell(X + 1, Y, Z - 1).
adjacent(cell(X, Y, Z), cell(X, Y + 1, Z - 1)) :- cell(X, Y, Z), cell(X, Y + 1, Z - 1).

A cell is cell(Row, Col) in one and cell(X, Y, Z) in the other — different arity, different names, different rules, all from one call. Add regions() or connectivity() to Grid and a puzzle picks up those rules by asking for them; one that doesn’t ask never pays to ground them. To do this with a .lp file you would be templating strings, untyped and unvalidated.

And here is the honest cost. A schema invented at runtime is a schema your type checker cannot see. Predicate.define() gives up static field checking: mypy and your IDE know nothing about Cell, so a misspelled field is no longer caught before you run, and there is no autocomplete. What survives is everything at runtime — a bad field name is rejected on construction, the {coord: int} schema above still validates every write and still reads back as plain ints, and rules are validated exactly as always. So the trade is real but narrow, and you make it per predicate: the Queen class at the top of this page is fully typed, because its shape was known when it was written. Reach for define() when the shape genuinely isn’t. And when the geometries are known in advance, the sharpest answer is neither: declare a typed class per geometry and pick between them at runtime. Static and polymorphic.

Why use ASPAlchemy?

  • Your program writes your program. if, loops, functions, imports and classes all work, so the rules you emit, and even the shape of the predicates they use, can be decided at runtime. A .lp file can never do this, and it’s why the library exists.
  • Your rules are Python objects. Predicates are classes with typed fields, atoms are instances, and rules are built from both. Your type checker and IDE understand all of it, so a misspelled field or a missing argument is caught before clingo ever runs. See Predicates and Data.
  • The code reads fluently. program.when(...).derive(...), program.forbid(...), Choice(...) == 1: you can almost tell what a program does just from the way the verbs are used, even if you’ve never seen ASP. The verbs are in Statements and Terms.
  • Your results are Python too. Answer sets come back as typed atoms with plain int and str fields. Read models one at a time, ask what’s possible or certain across every answer set, and solve prioritized optimizations. See Solving and Results.
  • Debugging is easier. The obvious mistakes are caught in Python, before your program reaches clingo. Errors are raised on the line where the mistake happened, with messages you can understand, and wherever possible they tell you how to fix it. When only clingo can catch a problem, its message still points back to the line of Python that caused it.
  • The tools clingo never gave you. Profiling shows you where the expensive parts of a program come from, statement by statement, so you know exactly which lines of Python to rethink. See Diagnostics and Grounding.

Design choices

A few deliberate choices shape how ASPAlchemy behaves, and each one comes with a trade-off.

Logic, not text. You can express (almost) anything clingo can, but where clingo offers several ways to write the same thing, ASPAlchemy usually supports just the one that fits its fluent vocabulary. The goal isn’t to let you build every possible clingo program byte for byte; it’s to let you say everything you want to. That means some clingo syntax has no ASPAlchemy spelling, and the ASP it emits won’t always be exactly what you would have typed.

Opinionated, to protect you. Some clingo constructs are valid, but are almost always a mistake, or look like they mean something they don’t. ASPAlchemy refuses these outright, and tells you what to write instead. If you truly need something we refuse, raw_asp() lets you write plain clingo text. The full list of opinions, and the reasoning behind each, is in What We Don’t Support.

Loud over permissive. A program that runs and quietly gives you the wrong answer is the thing we try hardest to avoid, so ASPAlchemy errs on the side of stopping you. It treats suspicious code, such as a variable used only once in a rule, as an error, and by default it treats even clingo’s quietest messages as errors too. If you know what you’re doing, each of these checks can be turned off.

As much clingo as you want. If you want to think in Python and forget that there’s a completely separate engine underneath, that works. If, on the other hand, you want to inspect the rendering of every single piece, basically every object has a .render() method that will tell you exactly what clingo it’s generating.

Install

pip install aspalchemy    # or: uv add aspalchemy

Requires Python 3.13+. clingo 5.8+ is installed automatically; ASPAlchemy releases live on PyPI.

These docs run

Every Python block on this site is executed, top to bottom and page by page, by the test suite — each page in one shared namespace, so its examples build on each other the way you read them. When a page shows generated ASP or a solved model, that output is verified by unit tests.

The same culture holds for everything else about this library:

  • Claims about the toolchain are verified against clingo. Where the library’s code or docs assert a fact about gringo, clasp, or CPython, a test runs the real thing and pins the answer — so if anything in the toolchain breaks with a new version, we know exactly where and when it occurred.
  • Everything rendered is parsed by clingo. A suite-wide fixture hands every program and term that any test renders to clingo’s own parser: “clingo accepts everything we render” is an enforced invariant of the entire test suite.
  • 100% unit test coverage. Every line of code is executed, and that’s our bar. While that doesn’t guarantee correctness, it sure helps to have conviction in our code!

Two ways in

More

  • Why not clorm? — the boundary between the two tools, and how they compose.
  • aspuzzle — what this looks like at scale: a puzzle-solving framework built on ASPAlchemy.