Escape Hatches
raw_asp() with its seatbelt and lexical rules, grounding contexts, solver options / grounded.control, symbol interop, incremental-ish solving today.
When to reach for these
ASPAlchemy models an opinionated subset of clingo, and sooner or later the
subset will refuse something you genuinely need — a disjunctive head, a
#project directive, a clasp flag, an @-function. The catalogue of what is
excluded and why lives in What We Don’t Support; this page
is the how: the sanctioned exits. Each hatch is designed to give up as
little of the typed loop as possible — raw text still round-trips into typed
atoms, grounding errors still land on your Python line, and the solver
handles still work — so reaching for one is a local decision, not a switch to
a different library.
raw_asp(): verbatim clingo
raw_asp(text, predicates=()) adds a verbatim block of ASP text to the
program. It is available on the program (which writes to the default segment)
and on any named segment, so raw blocks slot
into the same rendered structure as everything else. Here it carries a
disjunctive head — a construct the typed layer deliberately does not model —
and the solutions 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 raw text lands in the render as written, wired to the same #show block
everything else gets — and the two answer sets read back as typed atoms:
>>> 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 trade is explicit. Everything you build through the typed verbs is
validated as it is constructed; a raw block’s internals get no
construction-time validation at all — a typo inside the text surfaces at
grounding, not at the line that wrote it. You are not entirely on your own,
though: grounding diagnostics still map back to the raw_asp() call’s Python
line, so even errors only gringo can catch come home to your source. See
Clingo’s messages.
The predicates= seatbelt
Raw text is invisible to the program’s tree walkers, so predicates= is how
a raw block tells ASPAlchemy what it produces — and the contract is
exhaustive, enforced at ground() against gringo’s own signature table.
Declaring a class does two jobs: it emits the #show directive that lets the
predicate reach the model, and it gives model atoms of that signature a typed
class to round-trip into. One wrinkle: declaring P covers both signs for
round-trip purposes but emits only #show p/n. — if the block also derives
classically negated atoms, declare -P
as well (a NegatedSignature), or the -p atoms stay absent from output:
class Status(Predicate):
node: Field[str]
belt = ASPProgram()
belt.raw_asp('status("a"). -status("b").', predicates=[Status, -Status])
Declaring both signs emits both #show directives, and both signs come back:
>>> 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")']
Forgetting the seatbelt is loud, not silent — an undeclared signature fails the ground with a teaching 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=[...]; ...
Visibility stays under your control as usual: declare the class with
show=False (or hide() it on the program) to keep a scaffolding predicate
out of the model. One genuine exception: atoms carrying escaped strings
cannot round-trip — ASPAlchemy has no escaping support — so if a raw block
produces them, hide() that class and the rest of the model stays readable.
Blocks are lexically self-contained
Every raw block must be lexically self-contained: text that opens a %*
block comment or a #script and never closes it is rejected at construction,
even though gringo’s single-stream lexer would accept a comment opened in one
block and closed in another. The reason is soundness, learned the hard way:
gringo reads the whole program as one stream, so an unclosed construct would
silently swallow everything rendered after the block — and the per-block
directive scanner (the guard in the next section) is only sound if no lexical
state crosses block boundaries. An early version let an unterminated %*
smuggle a #const past the guard in a later block; the rejection is the fix.
>>> 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. ...
The same rule means a #script block must open and close within ONE raw
block — split across blocks, the second half is judged as ASP rather than
script. Strings need no rule of their own: gringo’s no-newline lexing makes
an unterminated string fail loudly at the block boundary anyway.
Blocked directives
Four directives are refused inside raw blocks, each with a redirect in its error message:
#const— register it withdefine_constant()instead. Registered constants are always emitted, so raw text may use them freely; registration is the only door because the const-vs-atom collision checks see registered constants only.#programand#external— the program grounds a singlebasepart, so statements after a part directive would land in an unloaded part and silently vanish, and no ASPAlchemy verb speaksControl.assign_external. This is the multi-shot story; for a per-solve switch, state the atom with a choice and pin it withassumptions=.#include— it’s Python; use Python. Read the file and pass 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
@-functions are supported through the hatch, not refused: an @f(X) term
in raw_asp() text calls a method on the grounding context, an object you
pass as ground(context=obj) — @double(N) calls context.double(N). The
context is passed to clingo verbatim, and the same context= parameter is
threaded through solve(), brave(), cautious(), and optimize() for the
one-shot forms:
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])
The @double(N) term is called during grounding, once per base fact:
>>> model = calls.ground(context=Doubler()).solve().first()
>>> sorted((d.n, d.twice) for d in model.atoms(Doubled))
[(2, 4), (21, 42)]
Honest caveats. This is raw-clingo territory: ASPAlchemy does not model
@-terms, so the text and the context must agree on their own — context
methods take and return clingo.Symbol values, with no typed layer between.
And the ground-program views
(ground_text(), aspif()) work by re-grounding the stored source with the
stored context, in process: a stateful context whose @-functions answer
differently per call may make those views silently diverge from the original
grounding — no detection is promised. If the re-ground fails outright, the
error names a stateful context as the usual cause. If you prefer the Python to live
inside the ASP text itself, a self-contained #script block within one raw
block is also accepted — same territory, same caveats.
Solver options and the raw Control
grounded.control is the general clingo-API exit: the underlying
clingo.Control, exposing configuration, externals, theory atoms, and
observers — at your own risk. ASPAlchemy’s guarantees stop here: direct
mutations bypass the sequential-solve guard and the per-solve settings the
verbs restate on every entry. Reconfigure between solves, never during one,
and prefer the ASPAlchemy verbs where they exist.
This is where a daily clingo user’s command-line questions land. -t 4,
--seed, --configuration=jumpy: set the matching key on
grounded.control.configuration between
ground() and the solve (clingo’s own
documentation is the option inventory — we deliberately don’t mirror it).
--time-limit: modeled directly as solve(timeout=) and
optimize(timeout=), so no hatch needed. The
clingo-map has the full row.
The worked example is the pair of directives that ground silently inert
under the ASPAlchemy verbs: #project does nothing until the solve actually
projects, and #heuristic does nothing until the Domain heuristic is on.
Set the knob through the control, or the directive quietly changes nothing:
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: inert
8
>>> grounded.control.configuration.solve.project = "project"
>>> len(list(grounded.solve())) # one model per pick set
4
>>> grounded.control.configuration.solver.heuristic = "Domain" # the #heuristic knob
Eight models collapse to four: with projection on, models that differ only in
the unprojected audited atom count as one. Both knob paths are exactly as
written above — this page executes in CI, so if clingo ever renames them,
this section breaks before you do.
Clingo symbol interop
When you drive the clingo API directly — externals through the raw Control,
your own on_model callbacks, a foreign clingo library — you cross between
typed atoms and clingo.Symbol objects. convert_predicate_to_symbol and
convert_symbol_to_predicate are the public crossing, recursive in both
directions, with the classical-negation sign mapped to the symbol’s sign:
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))
>>> isinstance(sym, clingo.Symbol)
True
>>> str(sym)
'-cell(1,2)'
>>> back = convert_symbol_to_predicate(sym, {("cell", 2): Cell})
>>> back == -Cell(row=1, col=2)
True
>>> back.negated, back.row
(True, 1)
The reverse direction takes a (name, arity) -> class mapping and returns a
fully typed instance — the same machinery model reads use.
Incremental-ish solving today
The supported way to ask many questions of one program is
ground() plus assumptions: pay the
grounding once, then parameterize each solve with the atoms you want pinned
true or false. True multi-shot solving — #program parts grounded
incrementally onto one Control — is honestly a
future design project,
not a hidden feature of this page.