Solving and Results

solve() is one call, but what it returns is a stream — and everything on this page follows from taking that seriously: how to consume models lazily and stop early, how to read typed atoms out of them, the two questions (brave(), cautious()) you can ask about every answer set without enumerating them, optimization with an honest anytime contract, and grounding once to ask one program many questions — assumptions in, unsat cores out. The one scale limit worth knowing in advance is at the end.

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 ASPProgram, Field, Predicate, PredicateArg, Variable

class Person(Predicate):
    name: Field[PredicateArg]
    age: Field[PredicateArg]

class Adult(Predicate):
    name: Field[str]

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

program = ASPProgram()
program.fact(Person(name="john", age=30), Person(name="mary", age=25))
program.when(Person(name=X, age=Y), Y >= 18).derive(Adult(name=X))

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

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

class Chosen(Predicate):
    name: Field[str]

N = Variable("N")
menu = ASPProgram()
menu.fact(*(Dish(name=n) for n in ["a", "b", "c"]))
menu.choose(Choice(Chosen(name=N), condition=Dish(name=N)) >= 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 dishes
7

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 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 str field comes back as a real str, an int field as a real int, and a polymorphic (PredicateArg) slot the same way — the full field contract lives in Declaring predicates.

>>> model = program.solve().first()
>>> sorted(adult.name for adult in model.atoms(Adult))   # typed instances, plain str fields
['john', 'mary']
>>> {type(person.age) for person in model.atoms(Person)}
{<class 'int'>}

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”:

>>> menu.solve().first().atoms(Dish)      # Dish is show=False
Traceback (most recent call last):
  ...
ValueError: dish/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
[]

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)) == 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 cost_bound= to enumerate every answer set within a cost; or pass ignore_optimization=True to enumerate as if there were no objective.
>>> len(list(chores.solve(ignore_optimization=True)))   # three slots per task, objective ignored
27

The error names the two other things an objective can do for a solve(). 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. cost_bound= keeps the objective but demotes it from a goal to an admission rule: every answer set whose cost is within the bound streams back, best or not, and nothing is optimized.

>>> len(list(chores.solve(cost_bound=4)))       # the optimum costs 3; one step of slack admits 4 models
4
>>> sorted(atom.slot for atom in chores.solve(cost_bound=3).first().atoms(Assigned))
[1, 1, 1]

Every admitted model carries the price clasp computed for it: bounded solves yield CostedModels, one cost entry per surviving tier, never proven — nothing was optimized:

>>> sorted(model.cost for model in chores.solve(cost_bound=4))
[(3,), (4,), (4,), (4,)]

The bound is clasp’s own rule, lexicographic and inclusive: tiers are compared from the dominant one down, and a model that beats a tier’s bound is admitted no matter what the lower tiers cost — it is not “each tier within its budget” (the receipt for that distinction lives in the test suite). It takes the same shapes as optimize(bound=): a bare int when one tier survived grounding, otherwise {priority: value} naming a dominant-first prefix of the surviving levels (optimization_levels) — the first tier, the first two, or all of them, with tiers below the prefix unconstrained (bound only the dominant tier and every tie is admitted whatever the lower tiers cost; a lower tier without every tier above it refuses, because that shape has no lexicographic meaning). Unlike optimize(bound=), which is a pruning hint that cannot change the answer, an enumeration bound decides which answer sets you get, so it is validated strictly rather than best-effort — and, same honest caveat as the hint: a bound nothing meets reads as unsatisfiable, because clasp cannot tell the difference. One sign trap, inherited from clasp: a maximize() objective is compiled as negated minimization, so its costs — and therefore your bound — live in negated space, exactly as optimize() reports them: “every model achieving at least 4” is cost_bound=-4 (the naive +4 bounds nothing, since every negated cost is already below it — the receipt is in the test suite). The two compose: bound a solve at an optimize()’s proven cost and you enumerate every model exactly as good as the one it proved.

A budget on a single measure needs none of this. When the budget is the whole question — not a bound on an objective you also care to optimize — it is a hard constraint, and a Sum guard with no objective at all is the sharper tool:

from aspalchemy import ANY, Sum

class Item(Predicate, show=False):
    """An available item and what it costs."""
    name: Field[str]
    cost: Field[int]

class Picked(Predicate):
    """The solver put this item in the basket."""
    name: Field[str]

item_costs = {"anchovies": 1, "basil": 2, "capers": 3}
BUDGET = 3

pantry = ASPProgram()
pantry.fact(*(Item(name=n, cost=c) for n, c in item_costs.items()))

N, C = Variable("N"), Variable("C")
pantry.choose(Choice(Picked(name=N), condition=Item(name=N, cost=ANY)))

# The budget is a hard constraint, not an objective:
pantry.forbid(Sum((C, N), condition=[Picked(name=N), Item(name=N, cost=C)]) > BUDGET)

No #minimize, nothing for a bound to price — plain solve() enumerates every basket inside the budget, and only those:

>>> baskets = {frozenset(a.name for a in model.atoms(Picked)) for model in pantry.solve()}
>>> sorted(sorted(basket) for basket in baskets)
[[], ['anchovies'], ['anchovies', 'basil'], ['basil'], ['capers']]

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

rivals = [Invited(name="alice"), Invited(name="bob")]
core = None
try:
    party.ground().solve(assumptions=rivals).first()
except UnsatisfiableError as e:
    core = e.unsat_core          # the evidence rides the exception

assert core and set(core) <= set(rivals)

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. (There is nothing to show that clingo’s own documentation doesn’t own: .statistics is clingo’s dict, passed through.)

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.