Skip to main content

A Python ORM interface for building and solving clingo ASP programs

Project description

ASPAlchemy

PyPI CI Coverage: 100% License: MIT

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 back as typed Python values.

Write Clingo using Python

Your rules are Python objects. Predicates are classes with typed fields; atoms are instances; rules are built from both. A misspelled field or a missing argument is a type error before clingo ever runs, and the pieces you use over and over live in ordinary Python variables instead of being typed out again each time.

The code reads as English. program.when(...).derive(...). program.forbid(...). Choice(...).exactly(1). The example below can be read aloud, roughly correctly, by someone who has never seen ASP.

Your program is dynamic. This is the headline, and the second example below demonstrates exactly what we mean. A .lp file is frozen text; ASP written in Python has if statements, loops, functions, and imports, so the rules you emit reshape themselves around the input data and configuration in ways a static program never can. This is what SQLAlchemy did for SQL — hence the name.

You never need to see the clingo code. And just as with SQLAlchemy, the generated language becomes an implementation detail: the whole loop — express the rules, solve, consume the answers — happens in typed Python. The ASP is always there when you want it (render() prints it; the example below shows one), but you can go from problem to solution without reading a line of it.

It emits correct clingo. Code that clingo would reject — unsafe variables, malformed rules, syntax errors — is refused as you build it, at the Python line at fault. The net is deliberately one-sided: every refusal is a certain clingo rejection, and the few shapes only grounding can judge (arithmetic gringo can't invert, a #const bound whose value only grounding knows) still fail loudly at solve, mapped back to your source. And much of that is enforced statically: the type system encodes what may appear where, so autocomplete and your type checker tell you what's permitted before anything runs at all.

It's opinionated, with guardrails. ASPAlchemy deliberately does not admit everything gringo does. Constructs that are nearly always a mistake — singleton variables, negated heads, comparisons against pools — are refused, and every refusal says what you probably meant instead. When you genuinely need the full language, raw_asp() accepts verbatim clingo (with a predicates= seatbelt so its atoms still round-trip typed).

Every exception is a teaching moment. Rules are validated as they're constructed, so errors land on the Python line at fault and explain the fix. For problems only clingo can catch, every generated ASP line carries a reverse map to the Python that authored it: grounding diagnostics come back annotated "generated by yourfile.py:42", and render(annotate=True) prints the full mapping.

Solutions are first-class. Iterate models lazily; run brave/cautious consequence analyses; solve prioritized optimization problems via optimize(); read atoms back as typed objects with plain int/str fields. And when a program grounds big or slow, ground().analyze_grounding() shows exactly which rules blew up.

And it's tested against reality. The test suite holds a hard 100% line-coverage gate, and claims about clingo behavior are never taken on faith from documentation — they're pinned by tests that run gringo and clasp and assert on what actually comes back. Even corners like gringo's arithmetic binding order — which as far as we can tell is documented nowhere — have been nailed down precisely, with the receipts executable in CI.

Example

Color a map so that no two neighbours have the same color:

from aspalchemy import ANY, ASPProgram, Choice, Field, Predicate, Variable

class Edge(Predicate, show=False):
    """States a and b share a border."""
    a: Field[str]
    b: Field[str]

class Color(Predicate, show=False):
    """A color available to paint with."""
    color: Field[str]

class Node(Predicate, show=False):
    """A state on the map, derived from the border list."""
    name: Field[str]

class ColoredNode(Predicate):
    """The color assigned to a state — the solution we read back."""
    name: Field[str]
    color: Field[str]

program = ASPProgram()

# Define the ASP variables our rules will use
N, C, A, B = (Variable(v) for v in "NCAB")

# The map: six states and their borders
borders = [
    ("washington", "oregon"), ("washington", "idaho"),
    ("oregon", "idaho"), ("oregon", "california"), ("oregon", "nevada"),
    ("idaho", "nevada"), ("idaho", "utah"),
    ("california", "nevada"), ("nevada", "utah"),
]
program.fact(*(Edge(a=a, b=b) for a, b in borders))

# Three colors to paint with
colors = ["red", "green", "blue"]
program.fact(*(Color(color=c) for c in colors))

# A state is anything that appears on either side of a border
program.when(Edge(a=N, b=ANY)).derive(Node(name=N))
program.when(Edge(a=ANY, b=N)).derive(Node(name=N))

# Every state picks exactly one color
program.when(Node(name=N)).derive(Choice(ColoredNode(name=N, color=C), condition=Color(color=C)).exactly(1))

# Bordering states never share a color
program.forbid(Edge(a=A, b=B), ColoredNode(name=A, color=C), ColoredNode(name=B, color=C))

model = program.solve().first()
for atom in sorted(model.atoms(ColoredNode), key=lambda c: c.name):
    print(f"{atom.name} -> {atom.color}")  # .name and .color are plain strs
california -> red
idaho -> red
nevada -> blue
oregon -> green
utah -> green
washington -> blue

program.render() shows exactly the clingo program you would have written by hand:

>>> print(program.render())
% Generated by aspalchemy ...
edge("washington", "oregon").
edge("washington", "idaho").
edge("oregon", "idaho").
edge("oregon", "california").
edge("oregon", "nevada").
edge("idaho", "nevada").
edge("idaho", "utah").
edge("california", "nevada").
edge("nevada", "utah").
color("red").
color("green").
color("blue").
node(N) :- edge(N, _).
node(N) :- edge(_, N).
{ colored_node(N, C) : color(C) } = 1 :- node(N).
:- edge(A, B), colored_node(A, C), colored_node(B, C).

#show.
#show colored_node/2.

Your program is dynamic

That example is static: you could have typed it into a .lp file. Here is what you couldn't. A puzzle on a rectangular board and one on a hex board are not the same shape of problem — a cell is addressed differently, and "neighbour" means something different — so let the geometry decide both the predicate schema and the rules:

from aspalchemy import ASPProgram, Predicate, Variable

COORDS = {"rect": ["row", "col"], "hex": ["x", "y", "z"]}
NEIGHBOURS = {"rect": [(0, 1), (1, 0)], "hex": [(1, -1, 0), (1, 0, -1), (0, 1, -1)]}

def step(axis, delta):  # an ASP term: Col, or Col + 1, or Col - 1
    return axis if delta == 0 else axis + delta

def adjacency(program, geometry):
    """Emit adjacency rules in whichever coordinate system this puzzle uses."""
    coords, offsets = COORDS[geometry], NEIGHBOURS[geometry]
    Cell = Predicate.define("cell", {c: int for c in coords}, show=False)  # the schema...
    Adjacent = Predicate.define("adjacent", {"a": Cell, "b": Cell})
    axes = [Variable(c.capitalize()) for c in coords]

    here = Cell(**dict(zip(coords, axes, strict=True)))
    for offset in offsets:                                                 # ...and the rules
        there = Cell(**{c: step(a, d) for c, a, d in zip(coords, axes, offset, strict=True)})
        program.when(here, there).derive(Adjacent(a=here, b=there))

The same call, on two geometries, builds two structurally different ASP programs:

>>> rect = ASPProgram()
>>> adjacency(rect, "rect")
>>> 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).

>>> hexgrid = ASPProgram()
>>> adjacency(hexgrid, "hex")
>>> print(hexgrid["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, from one call site. A module can hold a library of grid rules and emit only the ones a given puzzle asks for; nothing it doesn't ask for is ever grounded. To do this with a .lp file you would be templating strings, untyped and unvalidated. This is what ASPAlchemy is for, and it is why aspuzzle exists.

There is an honest cost: a schema invented at runtime is invisible to your type checker, so Predicate.define() trades the static half of the guarantee for the flexibility (everything is still validated at runtime, and you make the choice per predicate). The full story is in the docs.

Why not clorm?

Different job. clorm types the data boundary — facts in, models out, with a relational query layer over solutions. ASPAlchemy types the program itself: the rules are Python objects, validated as you build them, which clorm by design leaves as ASP text. Solution handling here is deliberately minimal (typed atoms, loud failures); if you want to query solutions relationally while writing your rules in ASP, clorm is the right tool. Note that both tools can be used together: ASPAlchemy to define the program, clorm to handle the data.

Installation

pip install aspalchemy    # or: uv add aspalchemy

Requires Python 3.14+ (clingo is installed automatically).

Documentation

For examples of ASPAlchemy at work solving logic puzzles, see aspuzzle, a puzzle-solving framework built on top of it.

License

MIT. Copyright Jolyon Bloomfield 2025-2026.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

aspalchemy-1.5.0.tar.gz (138.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

aspalchemy-1.5.0-py3-none-any.whl (150.4 kB view details)

Uploaded Python 3

File details

Details for the file aspalchemy-1.5.0.tar.gz.

File metadata

  • Download URL: aspalchemy-1.5.0.tar.gz
  • Upload date:
  • Size: 138.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.29 {"installer":{"name":"uv","version":"0.11.29","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for aspalchemy-1.5.0.tar.gz
Algorithm Hash digest
SHA256 de93db5c17b7164db1834ec77d29fd1153089ee8e21c6a3036db60a908d24e2b
MD5 dbe83396a5ec9b4f2626bcbadb152e28
BLAKE2b-256 b1ea7ad468bec92252a4167f1b9d7de8490484d1e59c72e0489eb8b278a8615b

See more details on using hashes here.

File details

Details for the file aspalchemy-1.5.0-py3-none-any.whl.

File metadata

  • Download URL: aspalchemy-1.5.0-py3-none-any.whl
  • Upload date:
  • Size: 150.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.29 {"installer":{"name":"uv","version":"0.11.29","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for aspalchemy-1.5.0-py3-none-any.whl
Algorithm Hash digest
SHA256 8dfb6a989cee43c311719765f6747040c5e1614b85f43bdc77fa9a1007f019fd
MD5 833d84b12f287fb31238e7dbaff7ea40
BLAKE2b-256 18aff9f8e6fb5ac3b6d7ee186e990b4e5d160ac13718361df61e1d013ddba9f3

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page