Solving and Results
Model streams, typed reads, brave/cautious, optimization, ground()+assumptions, unsat cores, statistics, scale honesty.
The model stream
The examples on this page need a program to solve. Here is a small one — the verbs are covered in Statements and Terms; if this is your first ASPAlchemy program, start with Your First Program instead.
from aspalchemy import Field, Predicate, PredicateArg
class Person(Predicate):
name: Field[PredicateArg]
age: Field[PredicateArg]
john = Person(name="john", age=30)
mary = Person(name="mary", age=25)
from aspalchemy import ANY, ASPProgram, Variable
program = ASPProgram()
X, Y = Variable("X"), Variable("Y")
Adult = Predicate.define("adult", ["name"])
# Facts
program.fact(john)
program.fact(mary)
# Rules
program.when(Person(name=X, age=Y), Y >= 18).derive(Adult(name=X))
# Constraints
program.forbid(Person(name=ANY, age=Y), Y < 0)
solve() returns a SolveResult: a lazy, unbounded stream of Model
objects. Rendering, grounding — instantiating every rule over the actual
data, the step that turns your program into the solver’s input — and their
error checks run eagerly at the call; only model enumeration is lazy —
clasp, clingo’s search engine, computes the next model when you resume, so
nothing runs ahead of your consumption. Take what you need:
first() for one answer, itertools.islice for N, a for-loop with break
for a condition. A whole-stream read (list(result), a bare for-loop)
enumerates every model, which an underconstrained program can make
effectively endless. This program has no choices, so it provably has exactly
one answer set and enumerating it all is safe — and the exact transcript
below is legitimate:
>>> result = program.solve()
>>> for model in result:
... for adult in sorted(model.atoms(Adult), key=lambda atom: atom.render()):
... print(adult)
adult("john")
adult("mary")
>>> result.satisfiable and result.exhausted and result.models_yielded == 1
True
satisfiable, exhausted, and models_yielded update as models arrive and
finalize when iteration ends on any path — natural exhaustion, an explicit
close(), or leaving a with-block. first() is the one-answer sugar: it
returns the first model and closes the search, raising UnsatisfiableError
when there is no model at all. If UNSAT is an expected outcome for your
program, use next(iter(result), None) instead, which gives None rather
than raising.
from itertools import islice
from aspalchemy import Choice, Field
class Item(Predicate, show=False):
name: Field[str]
class Chosen(Predicate):
name: Field[str]
N = Variable("N")
menu = ASPProgram()
menu.fact(*(Item(name=n) for n in ["a", "b", "c"]))
menu.choose(Choice(Chosen(name=N), condition=Item(name=N)).at_least(1))
>>> result = menu.solve()
>>> some = list(islice(result, 2)) # take two models; the rest are never computed
>>> len(some), result.models_yielded, result.exhausted
(2, 2, False)
>>> result.close() # finalizes the flags and statistics
>>> len(list(menu.solve())) # all non-empty subsets of three items
7
>>> first = menu.solve().first() # any one of the seven
>>> 1 <= len(first.atoms(Chosen)) <= 3
True
Every call to solve() renders and grounds a fresh clingo Control, so
repeated solves on one program never interfere, and statements added after a
solve simply take effect on the next call. (When you want to pay for
grounding once and solve many times, see
Ground once, solve many.)
The timeout contract, stated once here: everywhere on this page, timeout=
is a wall-clock limit in seconds, counted from the start of iteration. On a
model stream, timing out is quiet — the models found so far have already
been yielded and exhausted stays False, since every yielded model was a
true answer. A timeout before any model raises TimeoutError, because a
silent empty stream would read as unsatisfiable. (Consequence refinements
are stricter — see Brave and cautious.)
Reading models
Every result type on this page — Model, the consequences, Optimum — is
an AtomCollection: one shared reading surface that makes no claim about
what the atoms mean — only the subclass says that (a Model’s atoms are one
answer set; a Consequences’ are a statement about every answer set). atoms(Cls) returns
typed instances of one predicate class, atoms() with no argument returns
everything, membership (atom in model) and iteration work as you’d expect.
Reads are plain Python: a Field[str] field comes back as a real str, a
Field[int] as a real int, and a Field[PredicateArg] slot the same way — the
full field contract lives in
Declaring predicates.
>>> model = menu.solve().first()
>>> names = sorted(atom.name for atom in model.atoms(Chosen))
>>> all(isinstance(name, str) for name in names) # typed reads: plain str
True
>>> bool(names) and set(names) <= {"a", "b", "c"} # which items came back is the solver's call
True
Hidden atoms (show=False and never shown) are never read back into results
at all — skipping them is what keeps model reads fast — so asking for a
hidden class raises with the remedy instead of returning an [] that would
read as “none were derived”:
>>> model.atoms(Item) # Item is show=False
Traceback (most recent call last):
...
ValueError: item/1 is hidden (show=False and never shown): hidden atoms are not read back into results ... show() the class, or define it with show=True, to read it.
Lookup is by exact class (an
in_namespace() clone is a
distinct class), and both signs of a classically negated predicate come back
from the same atoms(Cls) call — filter on .negated if your program uses
classical negation.
Brave and cautious
Two questions you can ask without enumerating every model: what is
possible — true in at least one answer set — and what is certain — true
in every answer set. brave() computes the union of all answer sets,
cautious() the intersection; each returns eagerly:
>>> possible = menu.brave() # true in AT LEAST ONE answer set
>>> sorted(atom.name for atom in possible.atoms(Chosen))
['a', 'b', 'c']
>>> possible.complete # a PROOF the refinement finished
True
>>> certain = menu.cautious() # true in EVERY answer set
>>> certain.complete
True
>>> certain.atoms(Chosen) # something must be chosen, but no one item is forced
[]
>>> len(certain.path) >= 1 # every approximation, kept as receipts
True
BraveConsequences and CautiousConsequences carry their evidence: .path
holds every successive approximation clasp computed (brave grows toward the
union, cautious shrinks toward the intersection), and .complete is a proof
of exhaustion, not a guess. A bounded run — timeout= seconds or
max_iterations= refinement steps — returns an incomplete result whose
knowledge is one-sided: every atom a partial brave result contains is
certified possible (absence proves nothing yet), and every atom a partial
cautious result lacks is certified not-forced (presence proves nothing
yet). Both eager verbs raise UnsatisfiableError when the program has no
answer set, carrying the unsat core and the solve’s messages.
For stepwise control, brave_iter()/cautious_iter() on a
grounding return RefinementSteps: iterate for
successive approximations and stop the moment your question is answered —
each step is a full solver search, so control between steps is control over
real work. Iteration ending naturally means the last approximation is the
true union/intersection; zero yields means unsatisfiable; and a timeout
raises TimeoutError from within iteration, so a timed-out refinement can
never impersonate a completed one.
On an optimizing program, all four verbs refuse with a teaching error: the
refinement would be computed against the solver’s cost-descent path, not the
set of answer sets. Pass ignore_optimization=True to refine over all
answer sets as if there were no objective (see
Optimization, which owns that switch).
Optimization
Objectives enter the program two ways. minimize()/maximize() state an
objective directly: a weight, the tuple terms that make each contribution
distinct, an optional condition=, and a priority= tier (higher tiers
dominate lower ones lexicographically). penalize() is the soft-constraint
spelling — a forbid() that charges instead of forbidding: each ground
match of the conditions adds weight to the cost, rendered as a weak
constraint (:~ ... [w@p, terms]). The two are semantically identical, so
the spelling is intent: penalize() for soft constraints, minimize() for
objectives. penalize() also works as a when() closer, like forbid().
Solving an objective is optimize(), which returns the best answer set as
an Optimum — a model carrying its cost and its certificate:
class Task(Predicate, show=False):
name: Field[str]
class Slot(Predicate, show=False):
n: Field[int]
class Assigned(Predicate):
task: Field[str]
slot: Field[int]
T, S = Variable("T"), Variable("S")
chores = ASPProgram()
chores.fact(*(Task(name=t) for t in ["wash", "dry", "fold"]))
chores.fact(*(Slot(n=s) for s in [1, 2, 3]))
chores.when(Task(name=T)).derive(
Choice(Assigned(task=T, slot=S), condition=Slot(n=S)).exactly(1)
)
chores.penalize(Assigned(task=T, slot=S), weight=S, terms=[T]) # earlier slots are cheaper
best = chores.optimize()
>>> best.cost # all three tasks land in slot 1
(3,)
>>> best.proven # optimality was PROVED, not assumed
True
>>> {atom.slot for atom in best.atoms(Assigned)}
{1}
cost has one entry per surviving priority level, highest first (a
maximization’s cost is reported negated — lower is better in every sense).
.path holds every emission of the descent, each a genuine answer set —
so an interrupted search’s best is still a real solution: a timeout= or
max_iterations= cap returns the best model found so far with
proven=False, the anytime reading, and TimeoutError fires only when the
deadline lands before any model at all. optimize(all_optima=True) continues
past the optimality proof and collects every certified optimum in
.optima (with .complete true, len(optimum.optima) == 1 answers
uniqueness). strategy=OptStrategy.USC swaps clasp’s algorithm — often
dramatically faster when branch and bound stalls, at the price of a sparse
emission stream — and bound= starts the search from a known cost, with the
caveat that a too-tight bound is reported as unsatisfiable (clasp cannot tell
the difference). The stepwise form, optimize_iter(), lives on a
grounding you keep and yields each strictly-better
CostedModel as it is found.
Plain solve() on an optimizing program refuses with a teaching error,
rather than silently enumerating answer sets the objective was supposed to
rank:
>>> chores.solve()
Traceback (most recent call last):
...
ValueError: This program optimizes (#minimize/#maximize present). Solve it with optimize(), or pass ignore_optimization=True to enumerate answer sets as if there were no objective.
>>> len(list(chores.solve(ignore_optimization=True))) # three slots per task, objective ignored
27
ignore_optimization=True (clasp’s opt-mode=ignore) enumerates answer
sets as if the program had no objective, for that solve only — and it
requires an objective to ignore, raising on a program without one rather
than passing vacuously. If what you actually want is every model within a
cost budget, see
Cost-budget enumeration.
Ground once, solve many
ground() renders and grounds the program once and returns a
GroundedProgram: an independent, immutable snapshot that solves exactly
that program forever, unaffected by later mutation of the ASPProgram it
came from — like a compiled regex and its pattern. Every verb above lives on
it (solve, brave, cautious, optimize), each eager verb has a lazy
twin (brave_iter, cautious_iter, optimize_iter — the
findall/finditer pairing), and assumptions parameterize any of them per
call: a grounded atom assumes it true, ~atom assumes it false, for that
solve only.
>>> grounding = menu.ground() # grounding is paid once
>>> len(list(grounding.solve()))
7
>>> len(list(grounding.solve(assumptions=[Chosen(name="a")]))) # the subsets containing "a"
4
>>> len(list(grounding.solve(assumptions=[~Chosen(name="a")]))) # the non-empty subsets of {"b", "c"}
3
Assumptions are also accepted directly by ASPProgram.solve() and the other
program-level verbs — the grounding is not what enables them, it is just
where asking many questions of one program gets cheap, since each
program-level call re-grounds from scratch.
One contract, enforced loudly: solves on a grounding are sequential. A Control cannot run overlapping searches, so starting a new solve while a previous result is unconsumed raises instead of silently corrupting either:
>>> open_result = grounding.solve()
>>> grounding.solve()
Traceback (most recent call last):
...
RuntimeError: The previous solve on this grounding is still open; a Control cannot run overlapping searches. Consume the previous result, close() it, leave its with-block, or call abandon() on this grounding.
>>> open_result.close() # or consume it, or leave its with-block
>>> len(list(grounding.solve())) # the snapshot solves the same program forever
7
ground() + assumptions is also the interim answer to incremental solving:
true multi-shot (clingo’s #program parts) is honestly
a future design project.
Unsat cores
When a solve under assumptions comes back UNSAT, the search leaves evidence:
the set of assumptions clasp reports as jointly unsatisfiable. It rides the
exception as UnsatisfiableError.unsat_core, in the shapes the assumptions
were given:
from aspalchemy import UnsatisfiableError
class Guest(Predicate, show=False):
name: Field[str]
class Invited(Predicate):
name: Field[str]
G = Variable("G")
party = ASPProgram()
party.fact(*(Guest(name=g) for g in ["alice", "bob", "cara"]))
party.choose(Choice(Invited(name=G), condition=Guest(name=G)))
party.forbid(Invited(name="alice"), Invited(name="bob")) # rivals
grounded = party.ground()
try:
grounded.solve(assumptions=[Invited(name="alice"), Invited(name="bob")]).first()
raise AssertionError("conflicting assumptions must be UNSAT")
except UnsatisfiableError as e:
assert e.unsat_core # the evidence rides the exception
assert set(e.unsat_core) <= {Invited(name="alice"), Invited(name="bob")}
The same evidence is available as SolveResult.unsat_core once a search has
proven unsatisfiability (None before then and for satisfiable programs;
() when UNSAT needed no assumptions at all), and the eager verbs —
cautious(), brave(), optimize() — carry it on their
UnsatisfiableError too. It is a core, not necessarily a minimal one:
clasp promises it contains a conflict, nothing more. A core only exists
relative to assumptions — which is why this section lives beside the
grounding story, where assumption-driven questioning is the workflow.
Statistics and messages
Every search handle snapshots clingo’s statistics as it finishes:
.statistics is the raw dict (a copy, plus a wall_time key spanning the
handle’s creation to the end of iteration), and format_statistics()
renders it in clingo’s native output style. The eager results (Optimum,
the consequences) carry the same snapshot, so nothing is lost by not using
the _iter twin.
result = party.solve()
models = list(result)
assert result.statistics is not None and "wall_time" in result.statistics
assert result.format_statistics().startswith("Models")
assert all(model.messages == () for model in models) # solve-phase diagnostics, usually empty
Clingo’s own statistics reflect the most recent search on the shared
Control — snapshotting at finish is what makes each handle’s numbers its
own. Diagnostics emitted during the solve phase never halt solving (the
stop_on_log_level threshold applies to parsing and grounding only); they
are captured on the handle’s .messages and, per model, on
Model.messages. What the messages look like, and how grounding
diagnostics map back to your Python source, is covered in
Clingo’s messages.
A note on scale
An honest note: the model-read path is tuned for puzzle-sized models. Atom
equality goes through rendering and membership checks are linear scans, so
reading models is comfortable at 10^4 atoms and will hurt at 10^5–10^6.
The one lever that matters is visibility: hidden atoms are never read back
at all — hundreds of thousands of scaffolding atoms cost nothing if you
hide() them — so show only what you actually consume.