Diagnostics and Grounding

Teaching errors, source locations, the rendered program, the ground program, and where a grounding’s size comes from.

When something goes wrong — a rule refused, a surprise UNSAT, a grounding that takes forever — this page is the toolbox. Half of it is errors that come to you: validation at construction, diagnostics mapped back to your Python source. The other half is surfaces you go to: the rendered ASP, the ground program exactly as clasp receives it, and a profile of where the grounding’s size comes from.

Errors that teach

Rules are validated as they are constructed, so errors land on the Python line at fault — not at solve time, three files away — and every message says what you probably meant instead. The net is deliberately one-sided: every refusal is a certain clingo rejection or a near-certain mistake, so valid programs never fight the validator. The deliberate exclusions, and the reasoning behind each, live in What We Don’t Support.

Here is one, fired on purpose. We build a small scheduling program, then try to state a “fact” that still contains a variable:

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

class Task(Predicate, show=False):
    name: Field[str]

class Slot(Predicate, show=False):
    hour: Field[int]

class Scheduled(Predicate):
    task: Field[str]
    hour: Field[int]

program = ASPProgram()
program.fact(Task(name="brew"), Task(name="bottle"))
program.fact(*(Slot(hour=h) for h in (9, 10, 11)))
T, H = Variable("T"), Variable("H")
program.when(Task(name=T)).derive(
    Choice(Scheduled(task=T, hour=H), condition=Slot(hour=H)).exactly(1)
)

A “fact” with a variable in it is not a fact — and the error says so:

>>> program.fact(Scheduled(task=T, hour=9))
Traceback (most recent call last):
  ...
ValueError: fact() requires grounded predicates, but scheduled(T, 9) contains variable(s) T. Use when(*conditions).derive(...) to derive predicates.

It names the offending atom, the variable, and the construct you probably wanted. Every refusal in the library aims for this shape: what broke, where, and what to write instead.

Source locations

Every statement remembers the Python line that authored it (this is the default — ASPProgram(source_locations=False) switches capture off, which you may want when generating rules in bulk; capture is cheap either way, as it reads a few frame attributes and never touches source files). Three consumers use the recorded locations: the dangling-when() report below, render(annotate=True), and the “generated by” notes on grounding diagnostics.

The dangling-when() report is the first one you are likely to meet. A when() must be completed by exactly one closer (derive, choose, require, forbid, or penalize); one left unclosed fails at render — and the error names the file and line where it was opened, which is where the fix goes:

scratch = ASPProgram()
pending = scratch.when(Slot(hour=H))  # opened, never closed
>>> scratch.render()
Traceback (most recent call last):
  ...
ValueError: Segment 'Rules' has incomplete when() statements: when(slot(H)) opened at ... Complete each with .derive/.choose/.require/.forbid/.penalize.

The “opened at” names the file and the line where the when() was written. A held reference can still be completed — and the report clears:

class OpenSlot(Predicate):
    hour: Field[int]

pending.derive(OpenSlot(hour=H))
scratch.render()  # renders cleanly now

By default, the captured line is the first stack frame outside ASPAlchemy itself — exactly right for a script, wrong for a framework built on top of ASPAlchemy, whose own plumbing would soak up every location. Framework authors get a kit of three tools:

  • register_skip_package("myfw") declares a whole package plumbing, so captured locations point at its caller’s code. A package that also contains authored code registers just its plumbing modules by dotted prefix ("myfw.core", "myfw.solvers.base"), leaving the authored modules’ lines intact. Registration is process-wide and permanent — register only names you own ("__main__" is rejected: that is the user’s own script, never plumbing).
  • @attribute_to_caller marks a single helper function that emits rules on its caller’s behalf: the interesting line is the call site, not the helper’s interior. The mark is per code object, so lambdas and comprehensions defined inside the helper are not covered (registering the module covers those too).
  • location_override(loc) attributes everything created in the block to a location you supply — for code where no stack frame is the honest answer, such as a framework module emitting rules during a finalize pass, where the meaningful location is a construction site captured earlier. The SourceLocation it consumes is produced by capture_location(), which walks the stack outward and returns the first frame that is not plumbing.

This is how aspuzzle, a grid-puzzle framework built on ASPAlchemy, keeps diagnostics landing on each puzzle’s own definition rather than on framework internals.

Seeing the ASP

program.render() is the program text — the clingo you would have written by hand. The tutorial’s receipts moment shows it in full; for the scheduling program above it is:

>>> print(program.render())
% Generated by aspalchemy ...
task("brew").
task("bottle").
slot(9).
slot(10).
slot(11).
{ scheduled(T, H) : slot(H) } = 1 :- task(T).

#show.
#show scheduled/2.

render(annotate=True) appends a trailing % file:line comment to each statement line, naming the Python line that authored it. Line numbering is unchanged from the plain render, which is the point: the annotated text doubles as a reverse map, so when raw clingo (or any external tool) complains about line N, the comment on line N of the annotated render is the explanation.

Here is the same program, rebuilt with location_override so that the comments below name a stable file instead of this page — in your program they simply name your own files and lines:

from aspalchemy import SourceLocation, location_override

brewery = ASPProgram()
with location_override(SourceLocation("brewery.py", 14)):
    brewery.fact(Task(name="brew"), Task(name="bottle"))
with location_override(SourceLocation("brewery.py", 15)):
    brewery.fact(*(Slot(hour=h) for h in (9, 10, 11)))
with location_override(SourceLocation("brewery.py", 19)):
    brewery.when(Task(name=T)).derive(
        Choice(Scheduled(task=T, hour=H), condition=Slot(hour=H)).exactly(1)
    )
>>> print(brewery.render(annotate=True))
% Generated by aspalchemy ...
task("brew").  % brewery.py:14
task("bottle").  % brewery.py:14
slot(9).  % brewery.py:15
slot(10).  % brewery.py:15
slot(11).  % brewery.py:15
{ scheduled(T, H) : slot(H) } = 1 :- task(T).  % brewery.py:19

#show.
#show scheduled/2.

Annotation is off by default for a practical reason: annotated output churns on unrelated edits (every line move rewrites the comments), so keep checked-in or golden-compared renders unannotated.

Organizing output

The render you just saw is meant to be read — in review, in diagnostics, in these docs — and a few formatting verbs shape it. comment(text) emits a % comment, blank_line() drops a separator, and section(title) does both at once. They cost nothing at solve time and pay for themselves the first time you read back a 200-line render.

For programs big enough to have chapters, segments are the chapters. Every program starts with one default segment that the program-level verbs write to; add_segment("name") adds a named one, fixes its position in the output, and returns a handle carrying the full set of statement verbs. You can read a segment back later with program["name"]. Once named segments exist, each renders under a % ===== name ===== banner:

Wall = Predicate.define("wall", ["at"], show=False)

house = ASPProgram()
board_seg = house.add_segment("board")
board_seg.section("Board geometry")
board_seg.fact(Wall(at=1))

house.comment("program-level verbs write to the default segment")
house.fact(Wall(at=2))
>>> print(house.render())
% Generated by aspalchemy ...

% ===== board =====

% Board geometry
wall(1).


% ===== Rules =====

% program-level verbs write to the default segment
wall(2).

#show.

>>> house["board"] is board_seg  # the handle reads back by name
True

Segments render in the order they were added, no matter when statements land in them — so a framework can reserve a “definitions” segment up front and fill it as facts arrive. That’s as deep as most programs need; the framework-author machinery — attributing generated rules to the right source lines across segment boundaries — builds on source locations.

Seeing the ground program

render() shows the program you wrote; the solver runs the ground program — every rule instantiated over the actual data — and when grounding surprises you, that is the text to read. A GroundedProgram exposes it in both of gringo’s own output formats:

  • ground_text() is gringo’s --text rendering: every rule instantiated, atom names intact — what humans read, the programmatic twin of dumping a ground file when debugging grounding size or content.
  • aspif() is the exact statement stream clasp receives, byte-for-byte what clingo --mode=gringo emits. This is the honest measure for grounding-size questions: pretty-printed text re-prints the shared elements of a banded aggregate and looks duplicated, while aspif shows what clasp actually receives.
from aspalchemy import RangePool

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

class Big(Predicate):
    n: Field[int]

X = Variable("X")
pick_program = ASPProgram()
pick_program.choose(Choice(Pick(n=RangePool(1, 3))))
pick_program.when(Pick(n=X), X > 1).derive(Big(n=X))

grounded = pick_program.ground()

The source program has one choice rule and one rule with a variable; ground_text() shows what they became — instantiated, atom names intact:

>>> print(grounded.ground_text())
{pick(1)}.
{pick(2)}.
{pick(3)}.
big(2):-pick(2).
big(3):-pick(3).
#show.
#show big/1.

while aspif() is the same program as clasp sees it — numbered literals, rules, and the output table:

>>> print(grounded.aspif())
asp 1 0 0 incremental
1 1 1 1 0 0
1 1 1 2 0 0
1 1 1 3 0 0
1 0 1 4 0 1 2
1 0 1 5 0 1 3
4 6 big(2) 1 4
4 6 big(3) 1 5
0

Both are generated on demand: each call re-grounds this handle’s stored source through clingo’s own application, so the outputs come from clingo’s formatters, never from a hand-maintained serialization — byte-identity against clingo --mode=gringo is pinned by the test suite in CI. For groundings made with ground(context=...), the re-ground runs in process so the stored @-function context still answers; a stateful context whose functions answer differently per call may make the re-ground diverge from the original grounding.

Profiling grounding

When a grounding is slow or huge, grounded.analyze_grounding() is the first stop: ground-atom counts per signature, largest first, each joined back to the Python lines that derive it. The top rows name the lines to rethink.

Here is a program with a deliberate cross-product — 30 items, and a rule that derives every ordered pair of them. (The statements are pinned to named locations with location_override so the report below is reproducible; in your program the rows simply name your files and lines.)

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

class Pair(Predicate):
    a: Field[int]
    b: Field[int]

A, B = Variable("A"), Variable("B")
prof_program = ASPProgram()
with location_override(SourceLocation("puzzle.py", 12)):
    prof_program.fact(*(Item(n=i) for i in range(1, 31)))
with location_override(SourceLocation("puzzle.py", 13)):
    prof_program.when(Item(n=A), Item(n=B)).derive(Pair(a=A, b=B))
>>> report = prof_program.ground().analyze_grounding()
>>> print(report)
Grounding profile: 930 ground atoms across 2 signatures
  pair/2: 900 atoms  derived at puzzle.py:13
  item/1: 30 atoms  derived at puzzle.py:12

The top row is the 30 x 30 cross-product: the line to rethink. Counts are gringo’s ground truth: atoms simplified away during grounding are already gone, and both signs of a signature count together. When you want the same data structured rather than printed, grounding_profile() returns one typed SignatureGrounding row per signature — name, arity, atom_count, and the derived_at locations:

>>> profile = prof_program.ground().grounding_profile()
>>> profile[0].name, profile[0].atom_count
('pair', 900)

One blind spot, stated plainly: the report joins on rule heads, so a statement whose body grounds large without deriving new atoms — a wide join feeding a small head — shows up under its head’s count, not as its own row. The statement profile below closes it. Grounding size is also only half the scale story; the other half, reading large models back, has its own honest paragraph.

Profiling by statement

The signature profile counts the atoms each signature ends up with — but a constraint has no head to charge, and the costliest statements in a program are often exactly the headless ones. analyze_statements() charges every statement its own row: ground instantiation counts, largest first, each with its authoring line. Here is the cross-product again, this time built from a pooled range and joined by a constraint:

X = Variable("X")
stmt_program = ASPProgram()
with location_override(SourceLocation("puzzle.py", 12)):
    stmt_program.when(X.in_(RangePool(1, 30))).derive(Item(n=X))
with location_override(SourceLocation("puzzle.py", 13)):
    stmt_program.when(Item(n=A), Item(n=B)).derive(Pair(a=A, b=B))
with location_override(SourceLocation("puzzle.py", 14)):
    stmt_program.forbid(Pair(a=A, b=B), Pair(a=B, b=A), A != B)
>>> print(stmt_program.ground().analyze_statements())
Statement profile: 1800 ground instantiations across 3 statements
         900  pair(A, B) :- item(A), item(B).   puzzle.py:13
         870  :- pair(A, B), pair(B, A), A != B.   puzzle.py:14
          30  item(X) :- X = 1..30.   puzzle.py:12

The constraint — invisible to the signature profile — is the second-largest row here, and in real programs it is routinely the first. When you want the same data structured, statement_profile() returns one typed StatementGrounding row per statement — statement, location, instances:

>>> row = stmt_program.ground().statement_profile()[1]
>>> row.statement, row.instances
(':- pair(A, B), pair(B, A), A != B.', 870)

How it counts, stated plainly. Each call re-grounds an instrumented copy of the program, and a ground-program observer tallies a reserved marker literal (__aspalchemy_stmt/1) per statement — no text is ever parsed. Statements are classified structurally from the render itself, so a comment or directive can never be mistaken for one. The instrumentation is chosen per statement kind so the copy grounds as faithfully as possible:

  • Facts stay real facts — they are counted by a companion rule whose pool expansion instantiates once per fact instance — so aggregate evaluation over facts, and ground-evaluable conditions generally, resolve exactly as in the true grounding.
  • Rules carry the marker in their bodies (joined with ;, which a trailing conditional literal’s condition cannot absorb); the marker keeps each instantiation a rule, so instances the real grounding would fold into facts are still counted. Bodiless choice statements gate on the marker instead (choice atoms are open in the real grounding too), as do raw statements whose heads the companion cannot wrap — disjunctions, conditional heads, and cardinality-bounded choices.
  • Weak constraints are not touched at all — their bodies ground exactly as the real program; the tail’s priority is redirected to a reserved value per statement, and gringo’s minimize callback reports one entry per ground tuple there.
  • Anonymous variables are renamed apart in the instrumented copy: gringo’s projection rewrite would otherwise hide a statement’s join work from the marker (p(_, C) counting distinct C instead of the join). Occurrences under default negation are left alone — renaming them would be unsafe, and a negated literal carries no join work. The ASPALCHEMY_ANON* variable names are reserved for this, and fresh indices start past any already present in the program.

Three consequences worth knowing:

  • Counts are the instantiations gringo emits for each statement, kept visible past the final fact-folding. minimize()/maximize() directives are the one excluded statement kind — a wide-bodied objective’s grounding work is invisible here, as it is in the signature profile.
  • Each call pays a full in-process re-ground, with ground_text()’s stateful-context caveat (a context answering differently on the second call skews or breaks the profile) — but none of its threading caveat.
  • Every rendered ASPAlchemy statement except optimization directives is counted. Inside raw_asp() text, attribution is line-based, and lines the pass cannot honestly attribute pass through uncounted: statements spanning several lines (including a 1..\n3 range split), several statements sharing one line, a statement sharing its line with a block comment, and raw weak constraints and directives. A bare choice over a pool counts gringo’s per-element ground rules.
  • One honest upper bound: a rule derived purely from facts is OPEN in the instrumented copy, so an assignment aggregate over such derived atoms grounds over its achievable range rather than evaluating — those aggregate rows charge an upper bound, not the single emitted rule. Aggregates directly over facts are exact.

Profiling recursion

Recursive grounding has its own cost model: gringo grounds each strongly connected component of the positive predicate dependency graph as one fixpoint, re-evaluating the component’s rules across the iteration. A statement inside that fixpoint that need not feed the recursion — a derivation restatable as a requirement — is the classic slow-grounding finding, and it is visible from the program’s structure alone. analyze_recursion() reports it before any grounding is paid:

X, Y, Z = Variable("X"), Variable("Y"), Variable("Z")

class Edge(Predicate, show=False):
    a: Field[int]
    b: Field[int]

class Reach(Predicate):
    a: Field[int]
    b: Field[int]

tc_program = ASPProgram()
with location_override(SourceLocation("paths.py", 7)):
    tc_program.fact(Edge(a=1, b=2), Edge(a=2, b=3))
with location_override(SourceLocation("paths.py", 8)):
    tc_program.when(Edge(a=X, b=Y)).derive(Reach(a=X, b=Y))
with location_override(SourceLocation("paths.py", 9)):
    tc_program.when(Reach(a=X, b=Y), Edge(a=Y, b=Z)).derive(Reach(a=X, b=Z))
>>> print(tc_program.analyze_recursion())
Recursion profile: 1 recursive component
  reach/2
    reach(X, Z) :- reach(X, Y), edge(Y, Z).   paths.py:9

The base rule seeds the component but is not listed: only rules that depend on the component from within it — positively, or through the negation that makes a component unstratified — are part of its cycle. recursion_profile() returns the same data structured — one RecursiveComponent per component: signatures, statements (rendered text with authoring locations), and unstratified — True when some rule of the component also reaches it through default negation, a not p whose p is in the component. Components are the strongly connected components of the FULL dependency graph — positive and default-negated edges together, the textbook object of stratification — so a cycle running entirely through negation (p :- not q. q :- not p.), or two positive fixpoints entangled by mutual negation, is reported and flagged. An unstratified component means strongly circular rules — defined in terms of their own absence: gringo cannot settle them and clasp is relegated to guess-and-check search. A well-shaped encoding is a tower — base data, then choices, then rules on top, each stratum negating only what is settled below — and an unstratified component is the tower folding back on itself: expensive to solve and almost always unintended. Stated plainly: the analysis is static (no grounding runs), and raw_asp() text is invisible here — its predicates= declarations give existence, not structure.

Clingo’s messages

Some mistakes only grounding can catch, and clingo reports them as log messages rather than exceptions: LogLevel.INFO, WARNING, or ERROR (CRITICAL exists only as a threshold meaning “never halt”). ground() — and every verb that grounds: solve, brave, cautious, optimize — takes stop_on_log_level, and any message at or above the threshold raises GroundingError. The default is INFO, the strictest setting, because clingo’s quietest messages are often the loudest problems.

The flagship case is division by zero: raw clingo deletes any rule instance whose arithmetic is undefined, emitting only an info-level message, and your program silently shrinks. Under ASPAlchemy’s default threshold that becomes a loud failure — and the diagnostic carries a “generated by file:line” note joining the offending ASP back to the Python that authored it, so even errors only gringo can catch land on your source. (Again pinned with location_override so the receipt below is stable.)

from aspalchemy import GroundingError, LogLevel

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

class Ratio(Predicate):
    n: Field[int]

ratio_program = ASPProgram()
with location_override(SourceLocation("recipe.py", 30)):
    ratio_program.fact(Amount(n=0), Amount(n=2))
    ratio_program.when(Amount(n=X)).derive(Ratio(n=10 // X))  # 10 // 0 is undefined

The formatted diagnostic quotes the rendered ASP with a caret under the offending expression — Python’s // renders as clingo’s / — then closes the loop back to Python:

>>> ratio_program.solve().first()
Traceback (most recent call last):
  ...
aspalchemy.exceptions.GroundingError: Grounding produced INFO level messages (stop threshold: INFO).
...
INFO: operation undefined:
  (10/X)

  at line 4, columns 7-13

  in block
   3 | amount(2).
   4 | ratio(10 / X) :- amount(X).
             ^^^^^^
  generated by recipe.py:30
...

The same structured diagnostics ride the exception as e.messages, a tuple of ClingoMessage values with the severity, position, and text as fields — assert or branch on those rather than on the prose:

>>> try:
...     ratio_program.solve().first()
... except GroundingError as e:
...     message = e.messages[0]
>>> message.severity
<LogLevel.INFO: 10>

The “generated by” note works for every statement with a recorded location, including errors inside raw_asp() text: the raw block’s internals get no construction-time validation, but grounding diagnostics still map back to the raw_asp call’s line.

The INFO promotion you will actually meet first is quieter: “atom does not occur in any rule head”. A predicate used only under default negation — the everyday shape being input facts loaded from data that happens to be empty today — has no head anywhere in the program, gringo flags it at INFO, and under the default threshold solve() raises. The program below is perfectly sensible; it just has no bookings yet:

class Room(Predicate, show=False):
    number: Field[int]

class Booked(Predicate, show=False):
    room: Field[int]

class Free(Predicate):
    room: Field[int]

R = Variable("R")
free_program = ASPProgram()
free_program.fact(Room(number=101), Room(number=102))
# ...booking data would be loaded here — and today there is none
free_program.when(Room(number=R), ~Booked(room=R)).derive(Free(room=R))
>>> free_program.solve().first()
Traceback (most recent call last):
  ...
aspalchemy.exceptions.GroundingError: Grounding produced INFO level messages (stop threshold: INFO).
...
INFO: atom does not occur in any rule head:
  booked(R)
...
   4 | free(R) :- room(R), not booked(R).
                               ^^^^^^^^^
  generated by ...

gringo’s caution is not misplaced — an atom nothing can derive is often a typo for one that can — but here the absence is the point. Three remedies, in order of preference: tell clingo the predicate is deliberately head-less, give it a head occurrence somewhere, or raise the threshold (below, with its cost). The first is one clingo directive, #defined, delivered through raw_asp():

>>> free_program.raw_asp("#defined booked/1.")
>>> free = free_program.solve().first()
>>> sorted(f.room for f in free.atoms(Free))  # no bookings: all free
[101, 102]

Raising the threshold tolerates the message — and buys exactly the silent behavior the default protects you from:

>>> models = list(ratio_program.solve(stop_on_log_level=LogLevel.WARNING))
>>> len(models)  # facts and one rule: exactly one answer set
1
>>> [r.n for r in models[0].atoms(Ratio)]  # ratio(10/0) is just... gone
[5]

ratio(5) survives; the Amount(n=0) instance vanished without a trace. Relax the threshold only when you have read the message and decided the deletion is what you want. Messages from solving (as opposed to grounding) are collected too — each model carries them as .messages; see Statistics and messages.