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_callermarks 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. TheSourceLocationit consumes is produced bycapture_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--textrendering: 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 whatclingo --mode=gringoemits. 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 distinctCinstead of the join). Occurrences under default negation are left alone — renaming them would be unsafe, and a negated literal carries no join work. TheASPALCHEMY_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 a1..\n3range 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.